package web import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "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 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 }