Initial commit: runnable Ask a Plumber First server.
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
|
||||
"plumber"
|
||||
)
|
||||
|
||||
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, key string, _ io.Reader, _ string, _ int64) (string, error) {
|
||||
f.calls++
|
||||
f.last = key
|
||||
return "https://cdn.example.com/" + 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]
|
||||
}
|
||||
Reference in New Issue
Block a user