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"`, `action="/posts/` + root.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`, `>The model number is 123A.`, `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 }