Make web tests database-free and finish review hardening.

Introduce a Store interface with Postgres and in-memory backends, cover mutations/CSRF/session rotation without Postgres, bound avatar decode dimensions, add truncate/prepareAvatar unit tests, and run go test -race in CI.
This commit is contained in:
2026-08-22 07:36:13 -07:00
parent afd2476f3c
commit f4cec32afb
12 changed files with 945 additions and 223 deletions
+17
View File
@@ -0,0 +1,17 @@
name: CI
on:
push:
branches: [app, master, main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test
run: go test -race ./...
+1 -1
View File
@@ -53,7 +53,7 @@ func openDB() (*sql.DB, *store.SessionStore) {
} }
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler { func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler {
srv, err := web.New(db, sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminUsername: os.Getenv("ADMIN_USERNAME"), AdminUsername: os.Getenv("ADMIN_USERNAME"),
SecureCookie: os.Getenv("SECURE_COOKIE") == "1", SecureCookie: os.Getenv("SECURE_COOKIE") == "1",
Blob: uploader, Blob: uploader,
+327
View File
@@ -0,0 +1,327 @@
package store
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
)
// Memory is an in-process Store for tests.
type Memory struct {
mu sync.Mutex
users map[string]*User // id -> user
byName map[string]string // username -> id
questions map[string]*RankedQuestion // id -> question
answers map[string]*Answer // questionID -> answer
votes map[string]map[string]int // questionID -> userID -> value
}
// NewMemory returns an empty Memory store.
func NewMemory() *Memory {
return &Memory{
users: map[string]*User{},
byName: map[string]string{},
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
}
}
func (m *Memory) CreateUser(_ context.Context, u *User) error {
m.mu.Lock()
defer m.mu.Unlock()
if u.Role != RoleUser && u.Role != RoleAdmin {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
if _, ok := m.byName[u.Username]; ok {
return fmt.Errorf("username taken")
}
if u.ID == "" {
u.ID = uuid.NewString()
}
if u.Name == "" {
u.Name = u.Username
}
if u.CreatedAt == "" {
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
cp := *u
cp.db = nil
m.users[cp.ID] = &cp
m.byName[cp.Username] = cp.ID
*u = cp
return nil
}
func (m *Memory) UserByID(_ context.Context, id string) (*User, error) {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.users[id]
if !ok {
return nil, sql.ErrNoRows
}
cp := *u
return &cp, nil
}
func (m *Memory) UserByUsername(_ context.Context, username string) (*User, error) {
m.mu.Lock()
defer m.mu.Unlock()
id, ok := m.byName[NormalizeUsername(username)]
if !ok {
return nil, sql.ErrNoRows
}
cp := *m.users[id]
return &cp, nil
}
func (m *Memory) ListUsers(_ context.Context) ([]User, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]User, 0, len(m.users))
for _, u := range m.users {
out = append(out, *u)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
return out, nil
}
func (m *Memory) CountAdmins(_ context.Context) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, u := range m.users {
if u.Role == RoleAdmin {
n++
}
}
return n, nil
}
// SetUserRole serializes demotions under m.mu (same critical section as count).
func (m *Memory) SetUserRole(_ context.Context, id string, role Role) error {
m.mu.Lock()
defer m.mu.Unlock()
if role != RoleUser && role != RoleAdmin {
return fmt.Errorf("invalid role")
}
u, ok := m.users[id]
if !ok {
return sql.ErrNoRows
}
if u.Role == RoleAdmin && role == RoleUser {
n := 0
for _, x := range m.users {
if x.Role == RoleAdmin {
n++
}
}
if n <= 1 {
return ErrLastAdmin
}
}
u.Role = role
return nil
}
func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
m.mu.Lock()
defer m.mu.Unlock()
cur, ok := m.users[u.ID]
if !ok {
return sql.ErrNoRows
}
cur.State = strings.TrimSpace(u.State)
if u.AvatarURL != "" {
cur.AvatarURL = u.AvatarURL
}
u.State = cur.State
u.AvatarURL = cur.AvatarURL
return nil
}
func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
m.mu.Lock()
defer m.mu.Unlock()
q.Title = strings.TrimSpace(q.Title)
q.Body = strings.TrimSpace(q.Body)
q.City = strings.TrimSpace(q.City)
if q.ID == "" {
q.ID = uuid.NewString()
}
if q.HuntDate == "" {
q.HuntDate = pacific.Today()
}
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
author, ok := m.users[q.AuthorID]
if !ok {
return fmt.Errorf("unknown author")
}
cp := *q
cp.AuthorName = author.Name
cp.db = nil
m.questions[cp.ID] = &cp
*q = cp
return nil
}
func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
out := *q
score := 0
userVote := 0
if votes, ok := m.votes[q.ID]; ok {
for uid, v := range votes {
score += v
if uid == viewerID {
userVote = v
}
}
}
_, answered := m.answers[q.ID]
out.Score = score
out.Answered = answered
out.UserVote = userVote
out.db = nil
return out
}
func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return nil, sql.ErrNoRows
}
out := m.annotate(q, viewerID)
return &out, nil
}
func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.HuntDate != huntDate || q.Hidden {
continue
}
out = append(out, m.annotate(q, viewerID))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].CreatedAt < out[j].CreatedAt
})
return out, nil
}
func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.AuthorID != authorID || q.Hidden {
continue
}
out = append(out, m.annotate(q, ""))
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
return out, nil
}
func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for qid, a := range m.answers {
if a.AuthorID != adminID {
continue
}
q, ok := m.questions[qid]
if !ok || q.Hidden {
continue
}
rq := m.annotate(q, "")
rq.Answered = true
out = append(out, rq)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
return out, nil
}
func (m *Memory) HideQuestion(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return sql.ErrNoRows
}
q.Hidden = true
return nil
}
func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.answers[questionID]
if !ok {
return nil, sql.ErrNoRows
}
cp := *a
if u, ok := m.users[a.AuthorID]; ok {
cp.AuthorName = u.Name
}
return &cp, nil
}
func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.questions[a.QuestionID]; !ok {
return fmt.Errorf("unknown question")
}
a.Body = strings.TrimSpace(a.Body)
now := time.Now().UTC().Format(time.RFC3339)
if existing, ok := m.answers[a.QuestionID]; ok {
a.CreatedAt = existing.CreatedAt
} else if a.CreatedAt == "" {
a.CreatedAt = now
}
a.UpdatedAt = now
cp := *a
cp.db = nil
m.answers[a.QuestionID] = &cp
*a = cp
return nil
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 {
return fmt.Errorf("invalid vote")
}
if _, ok := m.questions[questionID]; !ok {
return sql.ErrNoRows
}
if m.votes[questionID] == nil {
m.votes[questionID] = map[string]int{}
}
if cur, ok := m.votes[questionID][userID]; ok && cur == value {
delete(m.votes[questionID], userID)
return nil
}
m.votes[questionID][userID] = value
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package store
import (
"context"
"sync"
"testing"
)
func TestMemoryConcurrentLastAdminDemotion(t *testing.T) {
m := NewMemory()
ctx := context.Background()
a := &User{Username: "admin_a", PasswordHash: "x", Role: RoleAdmin}
b := &User{Username: "admin_b", PasswordHash: "x", Role: RoleAdmin}
if err := m.CreateUser(ctx, a); err != nil {
t.Fatal(err)
}
if err := m.CreateUser(ctx, b); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
errs := make(chan error, 2)
wg.Add(2)
go func() {
defer wg.Done()
errs <- m.SetUserRole(ctx, a.ID, RoleUser)
}()
go func() {
defer wg.Done()
errs <- m.SetUserRole(ctx, b.ID, RoleUser)
}()
wg.Wait()
close(errs)
var ok, lastAdmin int
for err := range errs {
switch err {
case nil:
ok++
case ErrLastAdmin:
lastAdmin++
default:
t.Fatalf("unexpected error: %v", err)
}
}
if ok != 1 || lastAdmin != 1 {
t.Fatalf("want 1 success and 1 ErrLastAdmin, got ok=%d lastAdmin=%d", ok, lastAdmin)
}
n, err := m.CountAdmins(ctx)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("admins remaining = %d, want 1", n)
}
}
+86
View File
@@ -0,0 +1,86 @@
package store
import (
"context"
"database/sql"
)
// Postgres implements Store against a sqlc-backed database.
type Postgres struct {
db *sql.DB
}
// NewPostgres wraps db as a Store.
func NewPostgres(db *sql.DB) *Postgres {
return &Postgres{db: db}
}
func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
u.db = p.db
return u.Create(ctx)
}
func (p *Postgres) UserByID(ctx context.Context, id string) (*User, error) {
return UserByID(ctx, p.db, id)
}
func (p *Postgres) UserByUsername(ctx context.Context, username string) (*User, error) {
return UserByUsername(ctx, p.db, username)
}
func (p *Postgres) ListUsers(ctx context.Context) ([]User, error) {
return ListUsers(ctx, p.db)
}
func (p *Postgres) CountAdmins(ctx context.Context) (int, error) {
return CountAdmins(ctx, p.db)
}
func (p *Postgres) SetUserRole(ctx context.Context, id string, role Role) error {
u := &User{ID: id, db: p.db}
return u.SetRole(ctx, role)
}
func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
u.db = p.db
return u.SaveProfile(ctx)
}
func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error {
q.db = p.db
return q.Create(ctx)
}
func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
return GetQuestion(ctx, p.db, id, viewerID)
}
func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
return ListHunt(ctx, p.db, huntDate, viewerID)
}
func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
return ListQuestionsByAuthor(ctx, p.db, authorID)
}
func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
return ListQuestionsAnsweredBy(ctx, p.db, adminID)
}
func (p *Postgres) HideQuestion(ctx context.Context, id string) error {
q := &RankedQuestion{ID: id, db: p.db}
return q.Hide(ctx)
}
func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
return GetAnswer(ctx, p.db, questionID)
}
func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
a.db = p.db
return a.Upsert(ctx)
}
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
return Vote(ctx, p.db, userID, questionID, value)
}
+26
View File
@@ -0,0 +1,26 @@
package store
import "context"
// Store is the application persistence API used by the web layer.
type Store interface {
CreateUser(ctx context.Context, u *User) error
UserByID(ctx context.Context, id string) (*User, error)
UserByUsername(ctx context.Context, username string) (*User, error)
ListUsers(ctx context.Context) ([]User, error)
CountAdmins(ctx context.Context) (int, error)
SetUserRole(ctx context.Context, id string, role Role) error
SaveUserProfile(ctx context.Context, u *User) error
CreateQuestion(ctx context.Context, q *RankedQuestion) error
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
HideQuestion(ctx context.Context, id string) error
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, a *Answer) error
Vote(ctx context.Context, userID, questionID string, value int) error
}
+4 -4
View File
@@ -28,7 +28,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if s.requireAdmin(w, r) == nil { if s.requireAdmin(w, r) == nil {
return return
} }
users, err := store.ListUsers(r.Context(), s.db) users, err := s.store.ListUsers(r.Context())
if err != nil { if err != nil {
http.Error(w, "could not load users", http.StatusInternalServerError) http.Error(w, "could not load users", http.StatusInternalServerError)
return return
@@ -48,14 +48,14 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
} }
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
role := store.Role(r.PostFormValue("role")) role := store.Role(r.PostFormValue("role"))
u, err := store.UserByID(r.Context(), s.db, id) _, err := s.store.UserByID(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, "could not update role", http.StatusBadRequest) http.Error(w, "could not update role", http.StatusBadRequest)
return return
} }
err = u.SetRole(r.Context(), role) err = s.store.SetUserRole(r.Context(), id, role)
if errors.Is(err, store.ErrLastAdmin) { if errors.Is(err, store.ErrLastAdmin) {
users, listErr := store.ListUsers(r.Context(), s.db) users, listErr := s.store.ListUsers(r.Context())
if listErr != nil { if listErr != nil {
http.Error(w, "could not demote last admin", http.StatusBadRequest) http.Error(w, "could not demote last admin", http.StatusBadRequest)
return return
+8 -7
View File
@@ -43,7 +43,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username")) username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password") password := r.PostFormValue("password")
next := safeNext(r.PostFormValue("next")) next := safeNext(r.PostFormValue("next"))
u, err := store.UserByUsername(r.Context(), s.db, username) u, err := s.store.UserByUsername(r.Context(), username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil { if err != nil || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
s.exec(w, "login", authPage{ s.exec(w, "login", authPage{
@@ -94,7 +94,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
} }
role := store.RoleUser role := store.RoleUser
if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) { if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) {
n, err := store.CountAdmins(r.Context(), s.db) n, err := s.store.CountAdmins(r.Context())
if err != nil { if err != nil {
http.Error(w, "could not create account", http.StatusInternalServerError) http.Error(w, "could not create account", http.StatusInternalServerError)
return return
@@ -103,11 +103,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
role = store.RoleAdmin role = store.RoleAdmin
} }
} }
u := store.NewUser(s.db) u := &store.User{
u.Username = username Username: username,
u.PasswordHash = string(hash) PasswordHash: string(hash),
u.Role = role Role: role,
if err := u.Create(r.Context()); err != nil { }
if err := s.store.CreateUser(r.Context(), u); err != nil {
p.Error = "That username is taken." p.Error = "That username is taken."
s.exec(w, "register", p) s.exec(w, "register", p)
return return
+105
View File
@@ -0,0 +1,105 @@
package web
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"image/jpeg"
"image/png"
"strings"
"testing"
)
func TestTruncateRunes(t *testing.T) {
tests := []struct {
in string
max int
want string
}{
{"abc", 10, "abc"},
{"abcdef", 3, "abc"},
{"héllo", 3, "hél"},
{"🙂🙂🙂", 2, "🙂🙂"},
{"世界和平", 2, "世界"},
{"abc", 0, ""},
{"abc", -1, ""},
{"", 5, ""},
}
for _, tc := range tests {
if got := truncateRunes(tc.in, tc.max); got != tc.want {
t.Fatalf("truncateRunes(%q, %d)=%q want %q", tc.in, tc.max, got, tc.want)
}
}
}
func TestPrepareAvatar(t *testing.T) {
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
var jpegBuf bytes.Buffer
if err := jpeg.Encode(&jpegBuf, image.NewRGBA(image.Rect(0, 0, 2, 2)), &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
oversized := bytes.Repeat([]byte{0x89}, (2<<20)+2)
tests := []struct {
name string
in []byte
max int64
wantExt string
wantErr string
}{
{name: "png", in: pngBuf.Bytes(), max: 2 << 20, wantExt: ".png"},
{name: "jpeg", in: jpegBuf.Bytes(), max: 2 << 20, wantExt: ".jpg"},
{name: "empty", in: nil, max: 2 << 20, wantErr: "empty"},
{name: "invalid", in: []byte("not-an-image"), max: 2 << 20, wantErr: "unsupported"},
{name: "oversized", in: oversized, max: 2 << 20, wantErr: "too large"},
{name: "huge dims", in: pngWithDims(100000, 100000), max: 2 << 20, wantErr: "dimensions"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, ext, ct, err := prepareAvatar(bytes.NewReader(tc.in), tc.max)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err=%v want substring %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
if ext != tc.wantExt {
t.Fatalf("ext=%q want %q", ext, tc.wantExt)
}
if len(body) == 0 || ct == "" {
t.Fatalf("empty output body/ct")
}
})
}
}
func pngWithDims(w, h int) []byte {
var buf bytes.Buffer
buf.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a})
var ihdr bytes.Buffer
_ = binary.Write(&ihdr, binary.BigEndian, uint32(w))
_ = binary.Write(&ihdr, binary.BigEndian, uint32(h))
ihdr.Write([]byte{8, 2, 0, 0, 0}) // bit depth, color type, compression, filter, interlace
writePNGChunk(&buf, "IHDR", ihdr.Bytes())
writePNGChunk(&buf, "IDAT", []byte{0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01})
writePNGChunk(&buf, "IEND", nil)
return buf.Bytes()
}
func writePNGChunk(buf *bytes.Buffer, name string, data []byte) {
_ = binary.Write(buf, binary.BigEndian, uint32(len(data)))
buf.WriteString(name)
buf.Write(data)
crc := crc32.NewIEEE()
_, _ = crc.Write([]byte(name))
_, _ = crc.Write(data)
_ = binary.Write(buf, binary.BigEndian, crc.Sum32())
}
+23 -7
View File
@@ -99,7 +99,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
if avatarURL != "" { if avatarURL != "" {
u.AvatarURL = avatarURL u.AvatarURL = avatarURL
} }
if err := u.SaveProfile(r.Context()); err != nil { if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
http.Error(w, "could not save profile", http.StatusInternalServerError) http.Error(w, "could not save profile", http.StatusInternalServerError)
return return
} }
@@ -131,13 +131,29 @@ func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType s
return nil, "", "", fmt.Errorf("unsupported type %s", sniff) return nil, "", "", fmt.Errorf("unsupported type %s", sniff)
} }
img, format, err := image.Decode(bytes.NewReader(raw)) cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil { if err != nil {
return nil, "", "", err return nil, "", "", err
} }
const maxDim = 4096
const maxPixels = 4096 * 4096
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim {
return nil, "", "", fmt.Errorf("image dimensions out of range")
}
if int64(cfg.Width)*int64(cfg.Height) > maxPixels {
return nil, "", "", fmt.Errorf("image too many pixels")
}
img, decodedFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, "", "", err
}
if format != "" {
decodedFormat = format
}
var out bytes.Buffer var out bytes.Buffer
switch format { switch decodedFormat {
case "jpeg": case "jpeg":
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil { if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil {
return nil, "", "", err return nil, "", "", err
@@ -149,7 +165,7 @@ func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType s
} }
return out.Bytes(), ".png", "image/png", nil return out.Bytes(), ".png", "image/png", nil
default: default:
return nil, "", "", fmt.Errorf("unsupported format %s", format) return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat)
} }
} }
@@ -161,16 +177,16 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
) )
if u.Admin() { if u.Admin() {
label = "Questions you answered" label = "Questions you answered"
questions, err = store.ListQuestionsAnsweredBy(r.Context(), s.db, u.ID) questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
} else { } else {
label = "Your questions" label = "Your questions"
questions, err = store.ListQuestionsByAuthor(r.Context(), s.db, u.ID) questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
} }
if err != nil { if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError) http.Error(w, "could not load questions", http.StatusInternalServerError)
return return
} }
if fresh, e := store.UserByID(r.Context(), s.db, u.ID); e == nil { if fresh, e := s.store.UserByID(r.Context(), u.ID); e == nil {
u = fresh u = fresh
} }
p := s.basePage(r, "Profile") p := s.basePage(r, "Profile")
+26 -25
View File
@@ -3,7 +3,6 @@ package web
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"html/template" "html/template"
@@ -31,7 +30,7 @@ type Config struct {
} }
type Server struct { type Server struct {
db *sql.DB store store.Store
sessions *scs.SessionManager sessions *scs.SessionManager
tmpl *template.Template tmpl *template.Template
cfg Config cfg Config
@@ -85,7 +84,7 @@ type voteCtx struct {
Question store.RankedQuestion Question store.RankedQuestion
} }
func New(db *sql.DB, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) { func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
if cfg.Blob == nil { if cfg.Blob == nil {
cfg.Blob = blob.Disabled{} cfg.Blob = blob.Disabled{}
} }
@@ -125,7 +124,7 @@ func New(db *sql.DB, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, c
} }
return &Server{ return &Server{
db: db, store: st,
sessions: sessions, sessions: sessions,
tmpl: tmpl, tmpl: tmpl,
cfg: cfg, cfg: cfg,
@@ -181,7 +180,7 @@ func (s *Server) withUser(next http.Handler) http.Handler {
} }
id := s.sessions.GetString(r.Context(), "user_id") id := s.sessions.GetString(r.Context(), "user_id")
if id != "" { if id != "" {
u, err := store.UserByID(r.Context(), s.db, id) u, err := s.store.UserByID(r.Context(), id)
if err == nil { if err == nil {
r = r.WithContext(context.WithValue(r.Context(), userKey, u)) r = r.WithContext(context.WithValue(r.Context(), userKey, u))
} }
@@ -259,7 +258,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
if u := currentUser(r); u != nil { if u := currentUser(r); u != nil {
viewer = u.ID viewer = u.ID
} }
questions, err := store.ListHunt(r.Context(), s.db, date, viewer) questions, err := s.store.ListHunt(r.Context(), date, viewer)
if err != nil { if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError) http.Error(w, "could not load questions", http.StatusInternalServerError)
return return
@@ -319,12 +318,13 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
if len(city) > 80 { if len(city) > 80 {
city = truncateRunes(city, 80) city = truncateRunes(city, 80)
} }
q := store.NewQuestion(s.db) q := &store.RankedQuestion{
q.AuthorID = u.ID AuthorID: u.ID,
q.Title = title Title: title,
q.Body = body Body: body,
q.City = city City: city,
if err := q.Create(r.Context()); err != nil { }
if err := s.store.CreateQuestion(r.Context(), q); err != nil {
http.Error(w, "could not save question", http.StatusInternalServerError) http.Error(w, "could not save question", http.StatusInternalServerError)
return return
} }
@@ -337,14 +337,14 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
if u := currentUser(r); u != nil { if u := currentUser(r); u != nil {
viewer = u.ID viewer = u.ID
} }
q, err := store.GetQuestion(r.Context(), s.db, id, viewer) q, err := s.store.GetQuestion(r.Context(), id, viewer)
if err != nil || (q.Hidden && !currentUser(r).Admin()) { if err != nil || (q.Hidden && !currentUser(r).Admin()) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
var ans *store.Answer var ans *store.Answer
if q.Answered { if q.Answered {
ans, _ = store.GetAnswer(r.Context(), s.db, q.ID) ans, _ = s.store.GetAnswer(r.Context(), q.ID)
} }
s.exec(w, "question", questionPage{ s.exec(w, "question", questionPage{
page: s.basePage(r, q.Title), page: s.basePage(r, q.Title),
@@ -377,7 +377,7 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid vote", http.StatusBadRequest) http.Error(w, "invalid vote", http.StatusBadRequest)
return return
} }
if err := store.Vote(r.Context(), s.db, u.ID, id, value); err != nil { if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
http.Error(w, "could not vote", http.StatusInternalServerError) http.Error(w, "could not vote", http.StatusInternalServerError)
return return
} }
@@ -388,7 +388,7 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
s.renderLeaderboard(w, r, date) s.renderLeaderboard(w, r, date)
return return
} }
q, err := store.GetQuestion(r.Context(), s.db, id, u.ID) q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil { if err != nil {
http.Error(w, "not found", http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
@@ -421,7 +421,7 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
if u := currentUser(r); u != nil { if u := currentUser(r); u != nil {
viewer = u.ID viewer = u.ID
} }
questions, err := store.ListHunt(r.Context(), s.db, date, viewer) questions, err := s.store.ListHunt(r.Context(), date, viewer)
if err != nil { if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError) http.Error(w, "could not load questions", http.StatusInternalServerError)
return return
@@ -451,15 +451,16 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
if len(body) > 12000 { if len(body) > 12000 {
body = truncateRunes(body, 12000) body = truncateRunes(body, 12000)
} }
ans := store.NewAnswer(s.db) ans := &store.Answer{
ans.QuestionID = id QuestionID: id,
ans.AuthorID = u.ID AuthorID: u.ID,
ans.Body = body Body: body,
if err := ans.Upsert(r.Context()); err != nil { }
if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
http.Error(w, "could not save answer", http.StatusInternalServerError) http.Error(w, "could not save answer", http.StatusInternalServerError)
return return
} }
saved, err := store.GetAnswer(r.Context(), s.db, id) saved, err := s.store.GetAnswer(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, "could not load answer", http.StatusInternalServerError) http.Error(w, "could not load answer", http.StatusInternalServerError)
return return
@@ -481,12 +482,12 @@ func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
return return
} }
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
q, err := store.GetQuestion(r.Context(), s.db, id, u.ID) q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if err := q.Hide(r.Context()); err != nil { if err := s.store.HideQuestion(r.Context(), id); err != nil {
http.Error(w, "could not hide", http.StatusInternalServerError) http.Error(w, "could not hide", http.StatusInternalServerError)
return return
} }
+266 -179
View File
@@ -3,85 +3,83 @@ package web
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"image" "image"
"image/png" "image/png"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"strings" "strings"
"testing" "testing"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2/memstore"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"plumber" "plumber"
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/pacific"
"plumber/internal/store" "plumber/internal/store"
) )
func testDBURL() string { func newTestServer(t *testing.T, cfg Config) (*Server, *store.Memory) {
_ = godotenv.Load() t.Helper()
return strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) mem := store.NewMemory()
return newTestServerStore(t, mem, cfg), mem
} }
func newTestServer(t *testing.T, cfg Config) (*Server, *sql.DB) { func newTestServerStore(t *testing.T, st store.Store, cfg Config) *Server {
t.Helper() t.Helper()
url := testDBURL()
if url == "" {
t.Skip("set TEST_DATABASE_URL for web tests")
}
db, sessions, err := store.OpenPostgres(url, plumber.SchemaSQL)
if err != nil {
t.Fatalf("open postgres: %v", err)
}
t.Cleanup(func() {
sessions.Close()
_ = db.Close()
})
if cfg.Blob == nil { if cfg.Blob == nil {
cfg.Blob = blob.Disabled{} cfg.Blob = blob.Disabled{}
} }
srv, err := New(db, sessions.Store(), plumber.TemplateFS, plumber.StaticFS, cfg) srv, err := New(st, memstore.New(), plumber.TemplateFS, plumber.StaticFS, cfg)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return srv, db return srv
} }
func uniq(prefix string) string { func uniq(prefix string) string {
return prefix + "_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "") return prefix + "_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
} }
func seedUser(t *testing.T, db *sql.DB, username, password string, role store.Role) *store.User { func seedUser(t *testing.T, st store.Store, username, password string, role store.Role) *store.User {
t.Helper() t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
u := store.NewUser(db) u := &store.User{
u.Username = username Username: username,
u.PasswordHash = string(hash) PasswordHash: string(hash),
u.Role = role Role: role,
if err := u.Create(context.Background()); err != nil { }
if err := st.CreateUser(context.Background(), u); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return u return u
} }
func sessionValue(cookies []*http.Cookie) string {
for _, c := range cookies {
if c.Name == "plumber_session" {
return c.Value
}
}
return ""
}
func loginUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie { func loginUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie {
t.Helper() t.Helper()
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
cookies := rec.Result().Cookies() pre := rec.Result().Cookies()
preToken := sessionValue(pre)
csrf := csrfFrom(rec.Body.String()) csrf := csrfFrom(rec.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password) form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password)
req := httptest.NewRequest(http.MethodPost, "/login", form) req := httptest.NewRequest(http.MethodPost, "/login", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies { for _, c := range pre {
req.AddCookie(c) req.AddCookie(c)
} }
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@@ -89,7 +87,48 @@ func loginUser(t *testing.T, h http.Handler, username, password string) []*http.
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusSeeOther {
t.Fatalf("login %s: %d %s", username, rec.Code, rec.Body.String()) t.Fatalf("login %s: %d %s", username, rec.Code, rec.Body.String())
} }
return mergeCookies(cookies, rec.Result().Cookies()) post := mergeCookies(pre, rec.Result().Cookies())
postToken := sessionValue(post)
if preToken == "" || postToken == "" || preToken == postToken {
t.Fatalf("expected session token rotation on login; pre=%q post=%q", preToken, postToken)
}
// Old anonymous token must not unlock authenticated routes.
req = httptest.NewRequest(http.MethodGet, "/profile", nil)
for _, c := range pre {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("pre-auth cookie should not access profile, got %d", rec.Code)
}
return post
}
func registerUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
pre := rec.Result().Cookies()
preToken := sessionValue(pre)
csrf := csrfFrom(rec.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password)
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range pre {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("register %s: %d %s", username, rec.Code, rec.Body.String())
}
post := mergeCookies(pre, rec.Result().Cookies())
postToken := sessionValue(post)
if preToken == "" || postToken == "" || preToken == postToken {
t.Fatalf("expected session token rotation on register; pre=%q post=%q", preToken, postToken)
}
return post
} }
func TestHomeEmptyAndViewport(t *testing.T) { func TestHomeEmptyAndViewport(t *testing.T) {
@@ -113,36 +152,18 @@ func TestRegisterLoginAsk(t *testing.T) {
srv, _ := newTestServer(t, Config{}) srv, _ := newTestServer(t, Config{})
h := srv.Handler() h := srv.Handler()
name := uniq("ask") name := uniq("ask")
session := registerUser(t, h, name, "hunter22")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil)) req := httptest.NewRequest(http.MethodGet, "/submit", nil)
cookie := rec.Result().Cookies()
csrf := csrfFrom(rec.Body.String())
if csrf == "" {
t.Fatal("no csrf")
}
form := strings.NewReader("_csrf=" + csrf + "&username=" + name + "&password=hunter22")
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookie {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("register status %d %s", rec.Code, rec.Body.String())
}
session := rec.Result().Cookies()
req = httptest.NewRequest(http.MethodGet, "/submit", nil)
for _, c := range session { for _, c := range session {
req.AddCookie(c) req.AddCookie(c)
} }
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != 200 { if rec.Code != 200 {
t.Fatalf("submit form %d", rec.Code) t.Fatalf("submit form %d", rec.Code)
} }
csrf = csrfFrom(rec.Body.String()) csrf := csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland")
req = httptest.NewRequest(http.MethodPost, "/submit", form) req = httptest.NewRequest(http.MethodPost, "/submit", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range session { for _, c := range session {
@@ -158,107 +179,20 @@ func TestRegisterLoginAsk(t *testing.T) {
} }
} }
func TestSessionSurvivesServerRestart(t *testing.T) {
url := testDBURL()
if url == "" {
t.Skip("set TEST_DATABASE_URL for web tests")
}
db, sessions, err := store.OpenPostgres(url, plumber.SchemaSQL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
sessions.Close()
_ = db.Close()
})
sessionStore := sessions.Store()
srv1, err := New(db, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{})
if err != nil {
t.Fatal(err)
}
h1 := srv1.Handler()
name := uniq("sess")
rec := httptest.NewRecorder()
h1.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
preCookies := rec.Result().Cookies()
csrf := csrfFrom(rec.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&username=" + name + "&password=hunter22")
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range preCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h1.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("register status %d %s", rec.Code, rec.Body.String())
}
sessionCookies := mergeCookies(preCookies, rec.Result().Cookies())
srv2, err := New(db, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{})
if err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodGet, "/submit", nil)
for _, c := range sessionCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
srv2.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected authenticated submit form after restart, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Ask a question") {
t.Fatal("session did not survive restart")
}
}
func registerUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
cookies := rec.Result().Cookies()
csrf := csrfFrom(rec.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password)
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("register %s: %d %s", username, rec.Code, rec.Body.String())
}
return mergeCookies(cookies, rec.Result().Cookies())
}
func TestAdminSeedOnlyWhenNoAdmins(t *testing.T) { func TestAdminSeedOnlyWhenNoAdmins(t *testing.T) {
srv, db := newTestServer(t, Config{}) mem := store.NewMemory()
n, err := store.CountAdmins(context.Background(), db)
if err != nil {
t.Fatal(err)
}
if n > 0 {
t.Skip("admin already exists in database; bootstrap seed not exercised")
}
adminName := uniq("seed") adminName := uniq("seed")
srv.cfg.AdminUsername = adminName srv := newTestServerStore(t, mem, Config{AdminUsername: adminName})
h := srv.Handler() h := srv.Handler()
registerUser(t, h, adminName, "hunter22") registerUser(t, h, adminName, "hunter22")
u, err := store.UserByUsername(context.Background(), db, adminName) u, err := mem.UserByUsername(context.Background(), adminName)
if err != nil || !u.Admin() { if err != nil || !u.Admin() {
t.Fatalf("first matching registrant should be admin: %+v %v", u, err) t.Fatalf("first matching registrant should be admin: %+v %v", u, err)
} }
later := uniq("later") later := uniq("later")
srv2, err := New(db, scs.New().Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: later}) srv2 := newTestServerStore(t, mem, Config{AdminUsername: later})
if err != nil {
t.Fatal(err)
}
registerUser(t, srv2.Handler(), later, "hunter22") registerUser(t, srv2.Handler(), later, "hunter22")
u2, err := store.UserByUsername(context.Background(), db, later) u2, err := mem.UserByUsername(context.Background(), later)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -268,12 +202,12 @@ func TestAdminSeedOnlyWhenNoAdmins(t *testing.T) {
} }
func TestAdminUsersPageAccessAndRoles(t *testing.T) { func TestAdminUsersPageAccessAndRoles(t *testing.T) {
srv, db := newTestServer(t, Config{}) srv, mem := newTestServer(t, Config{})
h := srv.Handler() h := srv.Handler()
hubName := uniq("hub") hubName := uniq("hub")
bobName := uniq("bob") bobName := uniq("bob")
carolName := uniq("carol") carolName := uniq("carol")
seedUser(t, db, hubName, "hunter22", store.RoleAdmin) seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
adminCookies := loginUser(t, h, hubName, "hunter22") adminCookies := loginUser(t, h, hubName, "hunter22")
registerUser(t, h, bobName, "hunter22") registerUser(t, h, bobName, "hunter22")
@@ -290,7 +224,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
t.Fatal("missing bob on admin page") t.Fatal("missing bob on admin page")
} }
bob, err := store.UserByUsername(context.Background(), db, bobName) bob, err := mem.UserByUsername(context.Background(), bobName)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -306,15 +240,15 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusSeeOther {
t.Fatalf("promote %d %s", rec.Code, rec.Body.String()) t.Fatalf("promote %d %s", rec.Code, rec.Body.String())
} }
bob, _ = store.UserByUsername(context.Background(), db, bobName) bob, _ = mem.UserByUsername(context.Background(), bobName)
if !bob.Admin() { if !bob.Admin() {
t.Fatal("bob should be admin") t.Fatal("bob should be admin")
} }
bobCookies := registerUser(t, h, carolName, "hunter22") carolCookies := registerUser(t, h, carolName, "hunter22")
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
for _, c := range bobCookies { for _, c := range carolCookies {
req.AddCookie(c) req.AddCookie(c)
} }
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
@@ -322,7 +256,6 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
t.Fatalf("non-admin expected 403, got %d", rec.Code) t.Fatalf("non-admin expected 403, got %d", rec.Code)
} }
// Demote bob back to user
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
for _, c := range adminCookies { for _, c := range adminCookies {
@@ -342,15 +275,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
t.Fatalf("demote bob %d", rec.Code) t.Fatalf("demote bob %d", rec.Code)
} }
admins, err := store.CountAdmins(context.Background(), db) hub, err := mem.UserByUsername(context.Background(), hubName)
if err != nil {
t.Fatal(err)
}
if admins != 1 {
t.Skip("shared database has other admins; last-admin demote not isolated")
}
hub, err := store.UserByUsername(context.Background(), db, hubName)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -375,7 +300,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") { if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") {
t.Fatalf("missing last-admin error: %s", rec.Body.String()) t.Fatalf("missing last-admin error: %s", rec.Body.String())
} }
hub, _ = store.UserByUsername(context.Background(), db, hubName) hub, _ = mem.UserByUsername(context.Background(), hubName)
if !hub.Admin() { if !hub.Admin() {
t.Fatal("hub must remain admin") t.Fatal("hub must remain admin")
} }
@@ -395,7 +320,7 @@ func (f *fakeBlob) Upload(_ context.Context, obj blob.FileUpload) (string, error
} }
func TestProfilePageAndState(t *testing.T) { func TestProfilePageAndState(t *testing.T) {
srv, db := newTestServer(t, Config{}) srv, mem := newTestServer(t, Config{})
h := srv.Handler() h := srv.Handler()
name := uniq("alice") name := uniq("alice")
cookies := registerUser(t, h, name, "hunter22") cookies := registerUser(t, h, name, "hunter22")
@@ -432,7 +357,7 @@ func TestProfilePageAndState(t *testing.T) {
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusSeeOther {
t.Fatalf("save profile %d %s", rec.Code, rec.Body.String()) t.Fatalf("save profile %d %s", rec.Code, rec.Body.String())
} }
u, err := store.UserByUsername(context.Background(), db, name) u, err := mem.UserByUsername(context.Background(), name)
if err != nil || u.State != "CA" { if err != nil || u.State != "CA" {
t.Fatalf("state not saved: %+v %v", u, err) t.Fatalf("state not saved: %+v %v", u, err)
} }
@@ -463,27 +388,29 @@ func TestProfilePageAndState(t *testing.T) {
func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) { func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
fb := &fakeBlob{} fb := &fakeBlob{}
srv, db := newTestServer(t, Config{Blob: fb}) srv, mem := newTestServer(t, Config{Blob: fb})
h := srv.Handler() h := srv.Handler()
hubName := uniq("hub") hubName := uniq("hub")
aliceName := uniq("alice") aliceName := uniq("alice")
hub := seedUser(t, db, hubName, "hunter22", store.RoleAdmin) hub := seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
alice := seedUser(t, db, aliceName, "hunter22", store.RoleUser) alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, hubName, "hunter22") adminCookies := loginUser(t, h, hubName, "hunter22")
q := store.NewQuestion(db) q := &store.RankedQuestion{
q.AuthorID = alice.ID AuthorID: alice.ID,
q.Title = "Drip" Title: "Drip",
q.Body = "Under sink" Body: "Under sink",
q.City = "Oakland" City: "Oakland",
if err := q.Create(context.Background()); err != nil { }
if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err) t.Fatal(err)
} }
ans := store.NewAnswer(db) ans := &store.Answer{
ans.QuestionID = q.ID QuestionID: q.ID,
ans.AuthorID = hub.ID AuthorID: hub.ID,
ans.Body = "Replace the cartridge." Body: "Replace the cartridge.",
if err := ans.Upsert(context.Background()); err != nil { }
if err := mem.UpsertAnswer(context.Background(), ans); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -528,12 +455,172 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
if fb.calls != 1 { if fb.calls != 1 {
t.Fatalf("expected 1 upload, got %d", fb.calls) t.Fatalf("expected 1 upload, got %d", fb.calls)
} }
hub, _ = store.UserByUsername(context.Background(), db, hubName) hub, _ = mem.UserByUsername(context.Background(), hubName)
if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") { if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") {
t.Fatalf("avatar url %q", hub.AvatarURL) t.Fatalf("avatar url %q", hub.AvatarURL)
} }
} }
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
srv, mem := newTestServer(t, Config{})
h := srv.Handler()
adminName := uniq("admin")
userName := uniq("user")
admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
user := seedUser(t, mem, userName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, adminName, "hunter22")
userCookies := loginUser(t, h, userName, "hunter22")
q := &store.RankedQuestion{
AuthorID: user.ID,
Title: "Pipe noise",
Body: "Clanking",
City: "SF",
HuntDate: pacific.Today(),
}
if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err)
}
// Missing CSRF
form := strings.NewReader("value=1&view=question")
req := httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range userCookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing csrf want 403, got %d", rec.Code)
}
// Anonymous HTMX vote → sign-in prompt
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/login", nil)
h.ServeHTTP(rec, req)
anon := rec.Result().Cookies()
csrf := csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range anon {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Sign in") {
t.Fatalf("anon htmx vote: %d %s", rec.Code, rec.Body.String())
}
// User vote + HTMX fragment
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range userCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String())
}
got, err := mem.GetQuestion(context.Background(), q.ID, user.ID)
if err != nil || got.UserVote != 1 || got.Score != 1 {
t.Fatalf("vote not applied: %+v %v", got, err)
}
// Non-admin answer rejected
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&body=Nope")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range userCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-admin answer want 403, got %d", rec.Code)
}
// Admin answer success (HTMX)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf + "&body=Tighten+the+nuts.")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range adminCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
}
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
t.Fatal(err)
}
// Hide invalid id
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
csrf = csrfFrom(rec.Body.String())
form = strings.NewReader("_csrf=" + csrf)
req = httptest.NewRequest(http.MethodPost, "/questions/does-not-exist/hide", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range adminCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("hide missing want 404, got %d", rec.Code)
}
// Admin hide success
form = strings.NewReader("_csrf=" + csrf)
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/hide", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range adminCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("hide %d %s", rec.Code, rec.Body.String())
}
hidden, err := mem.GetQuestion(context.Background(), q.ID, admin.ID)
if err != nil || !hidden.Hidden {
t.Fatalf("question not hidden: %+v %v", hidden, err)
}
}
func mergeCookies(sets ...[]*http.Cookie) []*http.Cookie { func mergeCookies(sets ...[]*http.Cookie) []*http.Cookie {
byName := map[string]*http.Cookie{} byName := map[string]*http.Cookie{}
for _, set := range sets { for _, set := range sets {