From f4cec32afb174e767f0c6357b108a05554006590 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Sat, 22 Aug 2026 07:36:13 -0700 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 17 ++ cmd/server/main.go | 2 +- internal/store/memory.go | 327 +++++++++++++++++++++++ internal/store/memory_test.go | 56 ++++ internal/store/postgres_store.go | 86 ++++++ internal/store/store.go | 26 ++ internal/web/admin.go | 8 +- internal/web/auth.go | 15 +- internal/web/helpers_test.go | 105 ++++++++ internal/web/profile.go | 30 ++- internal/web/server.go | 51 ++-- internal/web/server_test.go | 445 ++++++++++++++++++------------- 12 files changed, 945 insertions(+), 223 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 internal/store/memory.go create mode 100644 internal/store/memory_test.go create mode 100644 internal/store/postgres_store.go create mode 100644 internal/store/store.go create mode 100644 internal/web/helpers_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb0f63c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/cmd/server/main.go b/cmd/server/main.go index b237b53..b73377e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -53,7 +53,7 @@ func openDB() (*sql.DB, *store.SessionStore) { } 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"), SecureCookie: os.Getenv("SECURE_COOKIE") == "1", Blob: uploader, diff --git a/internal/store/memory.go b/internal/store/memory.go new file mode 100644 index 0000000..a2e8cad --- /dev/null +++ b/internal/store/memory.go @@ -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 +} diff --git a/internal/store/memory_test.go b/internal/store/memory_test.go new file mode 100644 index 0000000..c5e3239 --- /dev/null +++ b/internal/store/memory_test.go @@ -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) + } +} diff --git a/internal/store/postgres_store.go b/internal/store/postgres_store.go new file mode 100644 index 0000000..e054677 --- /dev/null +++ b/internal/store/postgres_store.go @@ -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) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..8a93a1c --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/web/admin.go b/internal/web/admin.go index b44c966..a840cad 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -28,7 +28,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { if s.requireAdmin(w, r) == nil { return } - users, err := store.ListUsers(r.Context(), s.db) + users, err := s.store.ListUsers(r.Context()) if err != nil { http.Error(w, "could not load users", http.StatusInternalServerError) return @@ -48,14 +48,14 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) { } id := chi.URLParam(r, "id") 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 { http.Error(w, "could not update role", http.StatusBadRequest) return } - err = u.SetRole(r.Context(), role) + err = s.store.SetUserRole(r.Context(), id, role) if errors.Is(err, store.ErrLastAdmin) { - users, listErr := store.ListUsers(r.Context(), s.db) + users, listErr := s.store.ListUsers(r.Context()) if listErr != nil { http.Error(w, "could not demote last admin", http.StatusBadRequest) return diff --git a/internal/web/auth.go b/internal/web/auth.go index 307900f..683b537 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -43,7 +43,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PostFormValue("username")) password := r.PostFormValue("password") 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 { w.WriteHeader(http.StatusUnauthorized) s.exec(w, "login", authPage{ @@ -94,7 +94,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } role := store.RoleUser 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 { http.Error(w, "could not create account", http.StatusInternalServerError) return @@ -103,11 +103,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { role = store.RoleAdmin } } - u := store.NewUser(s.db) - u.Username = username - u.PasswordHash = string(hash) - u.Role = role - if err := u.Create(r.Context()); err != nil { + u := &store.User{ + Username: username, + PasswordHash: string(hash), + Role: role, + } + if err := s.store.CreateUser(r.Context(), u); err != nil { p.Error = "That username is taken." s.exec(w, "register", p) return diff --git a/internal/web/helpers_test.go b/internal/web/helpers_test.go new file mode 100644 index 0000000..4c95805 --- /dev/null +++ b/internal/web/helpers_test.go @@ -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()) +} diff --git a/internal/web/profile.go b/internal/web/profile.go index 9bf771b..536c5ca 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -99,7 +99,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { if 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) 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) } - img, format, err := image.Decode(bytes.NewReader(raw)) + cfg, format, err := image.DecodeConfig(bytes.NewReader(raw)) if err != nil { 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 - switch format { + switch decodedFormat { case "jpeg": if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil { 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 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() { label = "Questions you answered" - questions, err = store.ListQuestionsAnsweredBy(r.Context(), s.db, u.ID) + questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID) } else { 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 { http.Error(w, "could not load questions", http.StatusInternalServerError) 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 } p := s.basePage(r, "Profile") diff --git a/internal/web/server.go b/internal/web/server.go index c05666b..4be3153 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -3,7 +3,6 @@ package web import ( "context" "crypto/rand" - "database/sql" "encoding/hex" "fmt" "html/template" @@ -31,7 +30,7 @@ type Config struct { } type Server struct { - db *sql.DB + store store.Store sessions *scs.SessionManager tmpl *template.Template cfg Config @@ -85,7 +84,7 @@ type voteCtx struct { 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 { 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{ - db: db, + store: st, sessions: sessions, tmpl: tmpl, cfg: cfg, @@ -181,7 +180,7 @@ func (s *Server) withUser(next http.Handler) http.Handler { } id := s.sessions.GetString(r.Context(), "user_id") if id != "" { - u, err := store.UserByID(r.Context(), s.db, id) + u, err := s.store.UserByID(r.Context(), id) if err == nil { 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 { 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 { http.Error(w, "could not load questions", http.StatusInternalServerError) return @@ -319,12 +318,13 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) { if len(city) > 80 { city = truncateRunes(city, 80) } - q := store.NewQuestion(s.db) - q.AuthorID = u.ID - q.Title = title - q.Body = body - q.City = city - if err := q.Create(r.Context()); err != nil { + q := &store.RankedQuestion{ + AuthorID: u.ID, + Title: title, + Body: body, + City: city, + } + if err := s.store.CreateQuestion(r.Context(), q); err != nil { http.Error(w, "could not save question", http.StatusInternalServerError) return } @@ -337,14 +337,14 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) { if u := currentUser(r); u != nil { 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()) { http.NotFound(w, r) return } var ans *store.Answer 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{ 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) 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) return } @@ -388,7 +388,7 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) { s.renderLeaderboard(w, r, date) 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 { http.Error(w, "not found", http.StatusNotFound) return @@ -421,7 +421,7 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date if u := currentUser(r); u != nil { 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 { http.Error(w, "could not load questions", http.StatusInternalServerError) return @@ -451,15 +451,16 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) { if len(body) > 12000 { body = truncateRunes(body, 12000) } - ans := store.NewAnswer(s.db) - ans.QuestionID = id - ans.AuthorID = u.ID - ans.Body = body - if err := ans.Upsert(r.Context()); err != nil { + ans := &store.Answer{ + QuestionID: id, + AuthorID: u.ID, + Body: body, + } + if err := s.store.UpsertAnswer(r.Context(), ans); err != nil { http.Error(w, "could not save answer", http.StatusInternalServerError) return } - saved, err := store.GetAnswer(r.Context(), s.db, id) + saved, err := s.store.GetAnswer(r.Context(), id) if err != nil { http.Error(w, "could not load answer", http.StatusInternalServerError) return @@ -481,12 +482,12 @@ func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) { return } 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 { http.NotFound(w, r) 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) return } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 530a8bd..c7a0da4 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -3,85 +3,83 @@ package web import ( "bytes" "context" - "database/sql" "image" "image/png" "mime/multipart" "net/http" "net/http/httptest" - "os" "strings" "testing" - "github.com/alexedwards/scs/v2" + "github.com/alexedwards/scs/v2/memstore" "github.com/google/uuid" - "github.com/joho/godotenv" "golang.org/x/crypto/bcrypt" "plumber" "plumber/internal/blob" + "plumber/internal/pacific" "plumber/internal/store" ) -func testDBURL() string { - _ = godotenv.Load() - return strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) +func newTestServer(t *testing.T, cfg Config) (*Server, *store.Memory) { + t.Helper() + 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() - 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 { 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 { t.Fatal(err) } - return srv, db + return srv } func uniq(prefix string) string { 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() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) if err != nil { t.Fatal(err) } - u := store.NewUser(db) - u.Username = username - u.PasswordHash = string(hash) - u.Role = role - if err := u.Create(context.Background()); err != nil { + u := &store.User{ + Username: username, + PasswordHash: string(hash), + Role: role, + } + if err := st.CreateUser(context.Background(), u); err != nil { t.Fatal(err) } 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 { t.Helper() rec := httptest.NewRecorder() 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()) form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password) req := httptest.NewRequest(http.MethodPost, "/login", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - for _, c := range cookies { + for _, c := range pre { req.AddCookie(c) } rec = httptest.NewRecorder() @@ -89,7 +87,48 @@ func loginUser(t *testing.T, h http.Handler, username, password string) []*http. if rec.Code != http.StatusSeeOther { 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) { @@ -113,36 +152,18 @@ func TestRegisterLoginAsk(t *testing.T) { srv, _ := newTestServer(t, Config{}) h := srv.Handler() name := uniq("ask") + session := registerUser(t, h, name, "hunter22") rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", 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) + req := httptest.NewRequest(http.MethodGet, "/submit", nil) for _, c := range session { req.AddCookie(c) } - rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("submit form %d", rec.Code) } - csrf = csrfFrom(rec.Body.String()) - form = strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") + csrf := csrfFrom(rec.Body.String()) + form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") req = httptest.NewRequest(http.MethodPost, "/submit", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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) { - srv, db := newTestServer(t, Config{}) - 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") - } + mem := store.NewMemory() adminName := uniq("seed") - srv.cfg.AdminUsername = adminName + srv := newTestServerStore(t, mem, Config{AdminUsername: adminName}) h := srv.Handler() 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() { t.Fatalf("first matching registrant should be admin: %+v %v", u, err) } later := uniq("later") - srv2, err := New(db, scs.New().Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: later}) - if err != nil { - t.Fatal(err) - } + srv2 := newTestServerStore(t, mem, Config{AdminUsername: later}) registerUser(t, srv2.Handler(), later, "hunter22") - u2, err := store.UserByUsername(context.Background(), db, later) + u2, err := mem.UserByUsername(context.Background(), later) if err != nil { t.Fatal(err) } @@ -268,12 +202,12 @@ func TestAdminSeedOnlyWhenNoAdmins(t *testing.T) { } func TestAdminUsersPageAccessAndRoles(t *testing.T) { - srv, db := newTestServer(t, Config{}) + srv, mem := newTestServer(t, Config{}) h := srv.Handler() hubName := uniq("hub") bobName := uniq("bob") carolName := uniq("carol") - seedUser(t, db, hubName, "hunter22", store.RoleAdmin) + seedUser(t, mem, hubName, "hunter22", store.RoleAdmin) adminCookies := loginUser(t, h, hubName, "hunter22") registerUser(t, h, bobName, "hunter22") @@ -290,7 +224,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) { 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 { t.Fatal(err) } @@ -306,15 +240,15 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) { if rec.Code != http.StatusSeeOther { 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() { t.Fatal("bob should be admin") } - bobCookies := registerUser(t, h, carolName, "hunter22") + carolCookies := registerUser(t, h, carolName, "hunter22") rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) - for _, c := range bobCookies { + for _, c := range carolCookies { req.AddCookie(c) } h.ServeHTTP(rec, req) @@ -322,7 +256,6 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) { t.Fatalf("non-admin expected 403, got %d", rec.Code) } - // Demote bob back to user rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) for _, c := range adminCookies { @@ -342,15 +275,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) { t.Fatalf("demote bob %d", rec.Code) } - admins, err := store.CountAdmins(context.Background(), db) - 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) + hub, err := mem.UserByUsername(context.Background(), hubName) if err != nil { t.Fatal(err) } @@ -375,7 +300,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) { if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") { 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() { 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) { - srv, db := newTestServer(t, Config{}) + srv, mem := newTestServer(t, Config{}) h := srv.Handler() name := uniq("alice") cookies := registerUser(t, h, name, "hunter22") @@ -432,7 +357,7 @@ func TestProfilePageAndState(t *testing.T) { if rec.Code != http.StatusSeeOther { 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" { t.Fatalf("state not saved: %+v %v", u, err) } @@ -463,27 +388,29 @@ func TestProfilePageAndState(t *testing.T) { func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) { fb := &fakeBlob{} - srv, db := newTestServer(t, Config{Blob: fb}) + srv, mem := newTestServer(t, Config{Blob: fb}) h := srv.Handler() hubName := uniq("hub") aliceName := uniq("alice") - hub := seedUser(t, db, hubName, "hunter22", store.RoleAdmin) - alice := seedUser(t, db, aliceName, "hunter22", store.RoleUser) + hub := seedUser(t, mem, hubName, "hunter22", store.RoleAdmin) + alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser) adminCookies := loginUser(t, h, hubName, "hunter22") - q := store.NewQuestion(db) - q.AuthorID = alice.ID - q.Title = "Drip" - q.Body = "Under sink" - q.City = "Oakland" - if err := q.Create(context.Background()); err != nil { + q := &store.RankedQuestion{ + AuthorID: alice.ID, + Title: "Drip", + Body: "Under sink", + City: "Oakland", + } + if err := mem.CreateQuestion(context.Background(), q); err != nil { t.Fatal(err) } - ans := store.NewAnswer(db) - ans.QuestionID = q.ID - ans.AuthorID = hub.ID - ans.Body = "Replace the cartridge." - if err := ans.Upsert(context.Background()); err != nil { + ans := &store.Answer{ + QuestionID: q.ID, + AuthorID: hub.ID, + Body: "Replace the cartridge.", + } + if err := mem.UpsertAnswer(context.Background(), ans); err != nil { t.Fatal(err) } @@ -528,12 +455,172 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) { if fb.calls != 1 { 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/") { 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 { byName := map[string]*http.Cookie{} for _, set := range sets {