Files
plumber/internal/web/posts_test.go
T
codegirl007 f420f888af Remove legacy question storage (#7)
Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting.

Reviewed-on: #7
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:17:57 +00:00

535 lines
17 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestCreatePostRoutePermissions(t *testing.T) {
t.Parallel()
srv, mem := newTestServer(t, Config{})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
other := seedUser(t, mem, uniq("other"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
otherCookies := loginUser(t, handler, other.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
otherCSRF := csrfForCookies(t, handler, otherCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
rec := postForm(handler, "/posts", url.Values{
"title": {"No CSRF"},
"body": {"Body"},
}, homeownerCookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("missing CSRF status = %d, want 403", rec.Code)
}
anonRec := httptest.NewRecorder()
handler.ServeHTTP(anonRec, httptest.NewRequest(http.MethodGet, "/login", nil))
anonCookies := anonRec.Result().Cookies()
anonCSRF := csrfFrom(anonRec.Body.String())
rec = postForm(handler, "/posts", url.Values{
"_csrf": {anonCSRF},
"title": {"Anonymous"},
"body": {"Body"},
}, anonCookies)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous create status = %d, want 401", rec.Code)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Leaky sink"},
"body": {"It drips."},
"city": {"Oakland"},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 ||
roots[0].AuthorID != homeowner.ID ||
roots[0].Title != "Leaky sink" ||
roots[0].PostState != store.PostStateVisible {
t.Fatalf("created root = %+v", roots)
}
root := roots[0]
if got := rec.Header().Get("Location"); got != "/questions/"+root.ID+"#post-"+root.ID {
t.Fatalf("root redirect = %q", got)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {root.ID},
"body": {"The model is 123."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if len(thread.Replies) != 1 || thread.Replies[0].AuthorID != homeowner.ID {
t.Fatalf("homeowner reply missing: %+v", thread)
}
homeownerReply := thread.Replies[0]
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {homeownerReply.ID},
"body": {"Replace the cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin nested reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err = mem.GetPostThread(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if len(thread.Replies[0].Replies) != 1 ||
thread.Replies[0].Replies[0].AuthorID != admin.ID {
t.Fatalf("admin nested reply missing: %+v", thread)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {otherCSRF},
"parent_id": {homeownerReply.ID},
"body": {"I should not be here."},
}, otherCookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("unrelated reply status = %d, want 403", rec.Code)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {"missing"},
"body": {"Missing parent"},
}, homeownerCookies)
if rec.Code != http.StatusNotFound {
t.Fatalf("missing-parent reply status = %d, want 404", rec.Code)
}
hidden := &store.Post{
AuthorID: homeowner.ID,
Title: "Hidden thread",
Body: "Body",
PostDate: pacific.Today(),
PostState: store.PostStateHidden,
}
if err := mem.CreatePost(context.Background(), hidden); err != nil {
t.Fatal(err)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {hidden.ID},
"body": {"Hidden reply"},
}, homeownerCookies)
if rec.Code != http.StatusNotFound {
t.Fatalf("hidden-thread reply status = %d, want 404", rec.Code)
}
}
func TestEditPostRoutePermissions(t *testing.T) {
t.Parallel()
srv, mem := newTestServer(t, Config{})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
other := seedUser(t, mem, uniq("other"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
secondAdmin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
if err := mem.SetUserRole(context.Background(), secondAdmin.ID, store.RoleAdmin); err != nil {
t.Fatal(err)
}
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
otherCookies := loginUser(t, handler, other.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
secondAdminCookies := loginUser(t, handler, secondAdmin.Username, "hunter22")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
otherCSRF := csrfForCookies(t, handler, otherCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
secondAdminCSRF := csrfForCookies(t, handler, secondAdminCookies)
root := &store.Post{
AuthorID: homeowner.ID,
Title: "Leaky sink",
Body: "Original body",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
rootID := root.ID
adminReply := &store.Post{
ParentID: &rootID,
AuthorID: admin.ID,
Body: "Original answer",
}
if err := mem.CreatePost(context.Background(), adminReply); err != nil {
t.Fatal(err)
}
anonRec := httptest.NewRecorder()
handler.ServeHTTP(anonRec, httptest.NewRequest(http.MethodGet, "/login", nil))
rec := postForm(handler, "/posts/"+root.ID+"/edit", url.Values{
"_csrf": {csrfFrom(anonRec.Body.String())},
"body": {"Anonymous edit"},
}, anonRec.Result().Cookies())
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous edit status = %d, want 401", rec.Code)
}
rec = postForm(handler, "/posts/"+root.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Updated homeowner body"},
"parent_id": {adminReply.ID},
"author_id": {other.ID},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("homeowner edit status = %d: %s", rec.Code, rec.Body.String())
}
saved, err := mem.GetPost(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if saved.Body != "Updated homeowner body" ||
saved.ParentID != nil ||
saved.AuthorID != homeowner.ID {
t.Fatalf("homeowner edit changed immutable fields: %+v", saved)
}
for name, session := range map[string]struct {
cookies []*http.Cookie
csrf string
}{
"other homeowner": {otherCookies, otherCSRF},
"admin": {adminCookies, adminCSRF},
} {
t.Run(name+" cannot edit homeowner post", func(t *testing.T) {
rec := postForm(handler, "/posts/"+root.ID+"/edit", url.Values{
"_csrf": {session.csrf},
"body": {"Unauthorized edit"},
}, session.cookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
}
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Homeowner edit"},
}, homeownerCookies)
if rec.Code != http.StatusForbidden {
t.Fatalf("homeowner editing admin post status = %d, want 403", rec.Code)
}
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
"_csrf": {secondAdminCSRF},
"body": {"Updated admin answer"},
}, secondAdminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin edit status = %d: %s", rec.Code, rec.Body.String())
}
saved, err = mem.GetPost(context.Background(), adminReply.ID)
if err != nil {
t.Fatal(err)
}
if saved.Body != "Updated admin answer" ||
saved.ParentID == nil ||
*saved.ParentID != root.ID ||
saved.AuthorID != admin.ID {
t.Fatalf("admin edit changed immutable fields: %+v", saved)
}
}
func TestPostReplyNotifications(t *testing.T) {
t.Parallel()
recording := &mail.Recording{}
srv, mem := newTestServer(t, Config{Mail: recording})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
rec := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Leaky sink"},
"body": {"Water under the cabinet."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
}
if recording.Len() != 0 {
t.Fatalf("root create sent %d notifications", recording.Len())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 1 {
t.Fatalf("created roots = %+v, %v", roots, err)
}
root := roots[0]
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {root.ID},
"body": {"Replace the cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("admin reply thread = %+v, %v", thread, err)
}
adminReply := thread.Replies[0]
msgs := waitForMail(t, recording, 1)
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.RootTitle != root.Title ||
msg.ReplyID != adminReply.ID ||
msg.ReplyBody != adminReply.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("admin reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {adminReply.ID},
"body": {"That fixed the drip."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err = mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies[0].Replies) != 1 {
t.Fatalf("homeowner nested reply thread = %+v, %v", thread, err)
}
homeownerReply := thread.Replies[0].Replies[0]
msgs = waitForMail(t, recording, 2)
if msg := msgs[1]; msg.ToEmail != admin.Email ||
msg.RootID != root.ID ||
msg.ReplyID != homeownerReply.ID ||
msg.ReplyAuthorName != homeowner.Name {
t.Fatalf("homeowner reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {adminReply.ID},
"body": {"One more plumber detail."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("nested admin reply status = %d: %s", rec.Code, rec.Body.String())
}
msgs = waitForMail(t, recording, 3)
if msg := msgs[2]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.ReplyBody != "One more plumber detail." ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("nested admin reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {root.ID},
"body": {"A note to myself."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("self reply status = %d: %s", rec.Code, rec.Body.String())
}
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
"_csrf": {adminCSRF},
"body": {"Replace the ceramic cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("edit status = %d: %s", rec.Code, rec.Body.String())
}
noEmail := &store.User{
Username: uniq("no-email"),
PasswordHash: homeowner.PasswordHash,
Role: store.RoleUser,
}
if err := mem.CreateUser(context.Background(), noEmail); err != nil {
t.Fatal(err)
}
noEmailRoot := &store.Post{
AuthorID: noEmail.ID,
Title: "Quiet thread",
Body: "No email configured.",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), noEmailRoot); err != nil {
t.Fatal(err)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {noEmailRoot.ID},
"body": {"This should not send."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String())
}
time.Sleep(50 * time.Millisecond)
if recording.Len() != 3 {
t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot())
}
}
func TestQuestionPageRendersNestedPostControls(t *testing.T) {
t.Parallel()
srv, mem := newTestServer(t, Config{})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
root := &store.Post{
AuthorID: homeowner.ID,
Title: "Leaky sink",
Body: "Water under the cabinet.",
City: "Oakland",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
homeownerReply := &store.Post{
ParentID: &root.ID,
AuthorID: homeowner.ID,
Body: "The model number is 123.",
}
if err := mem.CreatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
}
adminReply := &store.Post{
ParentID: &homeownerReply.ID,
AuthorID: admin.ID,
Body: "Replace the cartridge.",
}
if err := mem.CreatePost(context.Background(), adminReply); err != nil {
t.Fatal(err)
}
homeownerReply.Body = "The model number is 123A."
if err := mem.UpdatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil)
for _, cookie := range homeownerCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("question page status = %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`id="post-` + root.ID + `"`,
`id="post-` + homeownerReply.ID + `"`,
`id="post-` + adminReply.ID + `"`,
`class="thread-post thread-post-branch`,
`class="thread-post thread-post-deep is-shop"`,
"Homeowner",
"Shop response",
"Edited",
`action="/posts"`,
`data-submit-once`,
`data-submit-button`,
`action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`,
`>The model number is 123A.</textarea>`,
`removeAttribute('open')`,
} {
if !strings.Contains(body, want) {
t.Fatalf("question page missing %q: %s", want, body)
}
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil)
for _, cookie := range adminCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `action="/posts/`+adminReply.ID+`/edit"`) ||
strings.Contains(rec.Body.String(), `action="/posts/`+root.ID+`/edit"`) {
t.Fatalf("admin edit controls are incorrect: %d %s", rec.Code, rec.Body.String())
}
}
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("load CSRF form status = %d", rec.Code)
}
csrf := csrfFrom(rec.Body.String())
if csrf == "" {
t.Fatal("CSRF token missing")
}
return csrf
}
func postForm(
handler http.Handler,
path string,
values url.Values,
cookies []*http.Cookie,
) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}