package web import ( "bytes" "context" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/alexedwards/scs/v2" "plumber" "plumber/internal/blob" ) func newTestServer(t *testing.T) (*Server, *memDB, scs.Store) { t.Helper() fake := newMemDB() sessions := scs.New() srv, err := New(fake, sessions.Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"}) if err != nil { t.Fatal(err) } return srv, fake, sessions.Store } func TestHomeEmptyAndViewport(t *testing.T) { srv, _, _ := newTestServer(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "No questions yet") { t.Fatal("missing empty state") } if !strings.Contains(body, "width=device-width") { t.Fatal("missing mobile viewport") } if !strings.Contains(body, "not a substitute for a licensed plumber") { t.Fatal("missing disclaimer") } } func TestRegisterLoginAsk(t *testing.T) { srv, _, _ := newTestServer(t) h := srv.Handler() 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=hub&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 { 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") req = httptest.NewRequest(http.MethodPost, "/submit", form) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for _, c := range session { req.AddCookie(c) } for _, c := range rec.Result().Cookies() { req.AddCookie(c) } rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if rec2.Code != http.StatusSeeOther { t.Fatalf("submit %d %s", rec2.Code, rec2.Body.String()) } } func TestSessionSurvivesServerRestart(t *testing.T) { fake := newMemDB() sessionStore := scs.New().Store srv1, err := New(fake, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"}) if err != nil { t.Fatal(err) } h1 := srv1.Handler() rec := httptest.NewRecorder() h1.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil)) preCookies := rec.Result().Cookies() csrf := csrfFrom(rec.Body.String()) if csrf == "" { t.Fatal("no csrf") } form := strings.NewReader("_csrf=" + csrf + "&username=hub&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(fake, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"}) 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, fake, _ := newTestServer(t) h := srv.Handler() registerUser(t, h, "hub", "hunter22") u, err := fake.UserByUsername(context.Background(), "hub") if err != nil || !u.Admin() { t.Fatalf("hub should be first admin: %+v %v", u, err) } registerUser(t, h, "hub2", "hunter22") // Create another account that also matches AdminUsername after an admin exists — use a fresh server config with AdminUsername hub2 after hub exists srv2, err := New(fake, scs.New().Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "lateradmin"}) if err != nil { t.Fatal(err) } registerUser(t, srv2.Handler(), "lateradmin", "hunter22") u2, err := fake.UserByUsername(context.Background(), "lateradmin") if err != nil { t.Fatal(err) } if u2.Admin() { t.Fatal("lateradmin must stay user when an admin already exists") } } func TestAdminUsersPageAccessAndRoles(t *testing.T) { srv, fake, _ := newTestServer(t) h := srv.Handler() adminCookies := registerUser(t, h, "hub", "hunter22") registerUser(t, h, "bob", "hunter22") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/admin/users", nil) for _, c := range adminCookies { req.AddCookie(c) } h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("admin list %d", rec.Code) } if !strings.Contains(rec.Body.String(), "bob") { t.Fatal("missing bob on admin page") } bob, err := fake.UserByUsername(context.Background(), "bob") if err != nil { t.Fatal(err) } csrf := csrfFrom(rec.Body.String()) form := strings.NewReader("_csrf=" + csrf + "&role=admin") req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", 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("promote %d %s", rec.Code, rec.Body.String()) } bob, _ = fake.UserByUsername(context.Background(), "bob") if !bob.Admin() { t.Fatal("bob should be admin") } // Non-admin forbidden bobCookies := registerUser(t, h, "carol", "hunter22") rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) for _, c := range bobCookies { req.AddCookie(c) } h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("non-admin expected 403, got %d", rec.Code) } // Demote last remaining admin after demoting bob first — leave only hub, then demote hub hub, err := fake.UserByUsername(context.Background(), "hub") if err != nil { t.Fatal(err) } rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) for _, c := range adminCookies { req.AddCookie(c) } h.ServeHTTP(rec, req) csrf = csrfFrom(rec.Body.String()) form = strings.NewReader("_csrf=" + csrf + "&role=user") req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", 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("demote bob %d", rec.Code) } rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) for _, c := range adminCookies { req.AddCookie(c) } h.ServeHTTP(rec, req) csrf = csrfFrom(rec.Body.String()) form = strings.NewReader("_csrf=" + csrf + "&role=user") req = httptest.NewRequest(http.MethodPost, "/admin/users/"+hub.ID+"/role", 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 != 200 { t.Fatalf("demote last admin expected page with error, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") { t.Fatalf("missing last-admin error: %s", rec.Body.String()) } hub, _ = fake.UserByUsername(context.Background(), "hub") if !hub.Admin() { t.Fatal("hub must remain admin") } } type fakeBlob struct { calls int last string } func (f *fakeBlob) Enabled() bool { return true } func (f *fakeBlob) Upload(_ context.Context, obj blob.Object) (string, error) { f.calls++ f.last = obj.Key return "https://cdn.example.com/" + obj.Key, nil } func TestProfilePageAndState(t *testing.T) { srv, fake, _ := newTestServer(t) h := srv.Handler() cookies := registerUser(t, h, "alice", "hunter22") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/profile", nil) for _, c := range cookies { req.AddCookie(c) } h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("profile %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Your questions") { t.Fatal("expected user questions label") } if !strings.Contains(rec.Body.String(), "local plumbing codes") { t.Fatal("missing state helper copy") } csrf := csrfFrom(rec.Body.String()) var buf bytes.Buffer w := multipart.NewWriter(&buf) _ = w.WriteField("_csrf", csrf) _ = w.WriteField("state", "CA") _ = w.Close() req = httptest.NewRequest(http.MethodPost, "/profile", &buf) req.Header.Set("Content-Type", w.FormDataContentType()) for _, c := range cookies { req.AddCookie(c) } rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("save profile %d %s", rec.Code, rec.Body.String()) } u, err := fake.UserByUsername(context.Background(), "alice") if err != nil || u.State != "CA" { t.Fatalf("state not saved: %+v %v", u, err) } // invalid state rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/profile", nil) for _, c := range cookies { req.AddCookie(c) } h.ServeHTTP(rec, req) csrf = csrfFrom(rec.Body.String()) buf.Reset() w = multipart.NewWriter(&buf) _ = w.WriteField("_csrf", csrf) _ = w.WriteField("state", "ZZ") _ = w.Close() req = httptest.NewRequest(http.MethodPost, "/profile", &buf) req.Header.Set("Content-Type", w.FormDataContentType()) for _, c := range cookies { req.AddCookie(c) } rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "valid US state") { t.Fatalf("expected invalid state error, got %d %s", rec.Code, rec.Body.String()) } } func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) { fake := newMemDB() blob := &fakeBlob{} sessions := scs.New() srv, err := New(fake, sessions.Store, plumber.TemplateFS, plumber.StaticFS, Config{ AdminUsername: "hub", Blob: blob, }) if err != nil { t.Fatal(err) } h := srv.Handler() adminCookies := registerUser(t, h, "hub", "hunter22") userCookies := registerUser(t, h, "alice", "hunter22") alice, _ := fake.UserByUsername(context.Background(), "alice") hub, _ := fake.UserByUsername(context.Background(), "hub") q, err := fake.CreateQuestion(context.Background(), alice.ID, "Drip", "Under sink", "Oakland") if err != nil { t.Fatal(err) } if err := fake.UpsertAnswer(context.Background(), q.ID, hub.ID, "Replace the cartridge."); err != nil { t.Fatal(err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/profile", nil) for _, c := range adminCookies { req.AddCookie(c) } h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("admin profile %d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "Questions you answered") || !strings.Contains(body, "Drip") { t.Fatalf("admin answered list missing: %s", body) } csrf := csrfFrom(body) var buf bytes.Buffer w := multipart.NewWriter(&buf) _ = w.WriteField("_csrf", csrf) _ = w.WriteField("state", "OR") part, err := w.CreateFormFile("avatar", "pic.png") if err != nil { t.Fatal(err) } _, _ = part.Write([]byte("fakepngbytes")) _ = w.Close() req = httptest.NewRequest(http.MethodPost, "/profile", &buf) req.Header.Set("Content-Type", w.FormDataContentType()) for _, c := range adminCookies { req.AddCookie(c) } rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("avatar upload %d %s", rec.Code, rec.Body.String()) } if blob.calls != 1 { t.Fatalf("expected 1 upload, got %d", blob.calls) } hub, _ = fake.UserByUsername(context.Background(), "hub") if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") { t.Fatalf("avatar url %q", hub.AvatarURL) } _ = userCookies } func mergeCookies(sets ...[]*http.Cookie) []*http.Cookie { byName := map[string]*http.Cookie{} for _, set := range sets { for _, c := range set { byName[c.Name] = c } } out := make([]*http.Cookie, 0, len(byName)) for _, c := range byName { out = append(out, c) } return out } func csrfFrom(html string) string { const needle = `name="_csrf" value="` i := strings.Index(html, needle) if i < 0 { return "" } html = html[i+len(needle):] j := strings.Index(html, `"`) if j < 0 { return "" } return html[:j] }