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>
714 lines
21 KiB
Go
714 lines
21 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"image"
|
|
"image/png"
|
|
"mime/multipart"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alexedwards/scs/v2/memstore"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"plumber"
|
|
"plumber/internal/blob"
|
|
"plumber/internal/pacific"
|
|
"plumber/internal/store"
|
|
)
|
|
|
|
func newTestServer(t *testing.T, cfg Config) (*Server, *store.Memory) {
|
|
t.Helper()
|
|
mem := store.NewMemory()
|
|
return newTestServerStore(t, mem, cfg), mem
|
|
}
|
|
|
|
func newTestServerStore(t *testing.T, st store.Store, cfg Config) *Server {
|
|
t.Helper()
|
|
if cfg.Blob == nil {
|
|
cfg.Blob = blob.Disabled{}
|
|
}
|
|
srv, err := New(st, memstore.New(), plumber.TemplateFS, plumber.StaticFS, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return srv
|
|
}
|
|
|
|
func uniq(prefix string) string {
|
|
return prefix + "_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
|
|
}
|
|
|
|
func seedUser(t *testing.T, st store.Store, username, password string, role store.Role) *store.User {
|
|
t.Helper()
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u := &store.User{
|
|
Username: username,
|
|
Email: username + "@example.com",
|
|
PasswordHash: string(hash),
|
|
Role: role,
|
|
}
|
|
if err := st.CreateUser(context.Background(), u); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func sessionValue(cookies []*http.Cookie) string {
|
|
for _, c := range cookies {
|
|
if c.Name == "plumber_session" {
|
|
return c.Value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func loginUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
|
pre := rec.Result().Cookies()
|
|
preToken := sessionValue(pre)
|
|
csrf := csrfFrom(rec.Body.String())
|
|
form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password)
|
|
req := httptest.NewRequest(http.MethodPost, "/login", form)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
for _, c := range pre {
|
|
req.AddCookie(c)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("login %s: %d %s", username, rec.Code, rec.Body.String())
|
|
}
|
|
post := mergeCookies(pre, rec.Result().Cookies())
|
|
postToken := sessionValue(post)
|
|
if preToken == "" || postToken == "" || preToken == postToken {
|
|
t.Fatalf("expected session token rotation on login; pre=%q post=%q", preToken, postToken)
|
|
}
|
|
// Old anonymous token must not unlock authenticated routes.
|
|
req = httptest.NewRequest(http.MethodGet, "/profile", nil)
|
|
for _, c := range pre {
|
|
req.AddCookie(c)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("pre-auth cookie should not access profile, got %d", rec.Code)
|
|
}
|
|
return post
|
|
}
|
|
|
|
func registerUser(t *testing.T, h http.Handler, username, password string, setupSecret ...string) []*http.Cookie {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
|
pre := rec.Result().Cookies()
|
|
preToken := sessionValue(pre)
|
|
csrf := csrfFrom(rec.Body.String())
|
|
form := "_csrf=" + csrf + "&username=" + username + "&email=" + username + "%40example.com&password=" + password
|
|
if len(setupSecret) > 0 && setupSecret[0] != "" {
|
|
form += "&setup_secret=" + setupSecret[0]
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(form))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
for _, c := range pre {
|
|
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())
|
|
}
|
|
post := mergeCookies(pre, rec.Result().Cookies())
|
|
postToken := sessionValue(post)
|
|
if preToken == "" || postToken == "" || preToken == postToken {
|
|
t.Fatalf("expected session token rotation on register; pre=%q post=%q", preToken, postToken)
|
|
}
|
|
return post
|
|
}
|
|
|
|
func TestHomeEmptyAndViewport(t *testing.T) {
|
|
srv, _ := newTestServer(t, Config{})
|
|
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, "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, Config{})
|
|
h := srv.Handler()
|
|
name := uniq("ask")
|
|
session := registerUser(t, h, name, "hunter22")
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
|
for _, c := range session {
|
|
req.AddCookie(c)
|
|
}
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("submit form %d", rec.Code)
|
|
}
|
|
for _, want := range []string{
|
|
`src="/static/app.js"`,
|
|
`id="submit-progress"`,
|
|
`data-submit-once`,
|
|
`data-submit-button`,
|
|
} {
|
|
if !strings.Contains(rec.Body.String(), want) {
|
|
t.Fatalf("submit form missing %q: %s", want, rec.Body.String())
|
|
}
|
|
}
|
|
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 TestAdminSetupSecretOnlyWhenNoAdmins(t *testing.T) {
|
|
mem := store.NewMemory()
|
|
secret := "one-time-admin-setup-secret"
|
|
adminName := uniq("seed")
|
|
srv := newTestServerStore(t, mem, Config{AdminSetupSecret: secret})
|
|
h := srv.Handler()
|
|
|
|
plain := uniq("plain")
|
|
registerUser(t, h, plain, "hunter22")
|
|
uPlain, err := mem.UserByUsername(context.Background(), plain)
|
|
if err != nil || uPlain.Admin() {
|
|
t.Fatalf("register without setup secret must stay user: %+v %v", uPlain, err)
|
|
}
|
|
|
|
registerUser(t, h, adminName, "hunter22", secret)
|
|
u, err := mem.UserByUsername(context.Background(), adminName)
|
|
if err != nil || !u.Admin() {
|
|
t.Fatalf("setup secret registrant should be admin: %+v %v", u, err)
|
|
}
|
|
|
|
later := uniq("later")
|
|
srv2 := newTestServerStore(t, mem, Config{AdminSetupSecret: secret})
|
|
registerUser(t, srv2.Handler(), later, "hunter22", secret)
|
|
u2, err := mem.UserByUsername(context.Background(), later)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u2.Admin() {
|
|
t.Fatal("setup secret must not grant admin once an admin already exists")
|
|
}
|
|
}
|
|
|
|
func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
|
srv, mem := newTestServer(t, Config{})
|
|
h := srv.Handler()
|
|
hubName := uniq("hub")
|
|
bobName := uniq("bob")
|
|
carolName := uniq("carol")
|
|
seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
|
|
adminCookies := loginUser(t, h, hubName, "hunter22")
|
|
registerUser(t, h, bobName, "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(), bobName) {
|
|
t.Fatal("missing bob on admin page")
|
|
}
|
|
|
|
bob, err := mem.UserByUsername(context.Background(), bobName)
|
|
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, _ = mem.UserByUsername(context.Background(), bobName)
|
|
if !bob.Admin() {
|
|
t.Fatal("bob should be admin")
|
|
}
|
|
|
|
carolCookies := registerUser(t, h, carolName, "hunter22")
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
|
for _, c := range carolCookies {
|
|
req.AddCookie(c)
|
|
}
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("non-admin expected 403, got %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/"+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)
|
|
}
|
|
|
|
hub, err := mem.UserByUsername(context.Background(), hubName)
|
|
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/"+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, _ = mem.UserByUsername(context.Background(), hubName)
|
|
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.FileUpload) (string, error) {
|
|
f.calls++
|
|
f.last = obj.Key
|
|
return "https://cdn.example.com/" + obj.Key, nil
|
|
}
|
|
|
|
func (f *fakeBlob) Delete(_ context.Context, _ string) error { return nil }
|
|
|
|
func TestProfilePageAndState(t *testing.T) {
|
|
srv, mem := newTestServer(t, Config{})
|
|
h := srv.Handler()
|
|
name := uniq("alice")
|
|
cookies := registerUser(t, h, name, "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("email", name+"@example.com")
|
|
_ = 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 := mem.UserByUsername(context.Background(), name)
|
|
if err != nil || u.State != "CA" {
|
|
t.Fatalf("state not saved: %+v %v", u, err)
|
|
}
|
|
|
|
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("email", name+"@example.com")
|
|
_ = 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) {
|
|
fb := &fakeBlob{}
|
|
srv, mem := newTestServer(t, Config{Blob: fb})
|
|
h := srv.Handler()
|
|
hubName := uniq("hub")
|
|
aliceName := uniq("alice")
|
|
hub := seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
|
|
alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
|
|
adminCookies := loginUser(t, h, hubName, "hunter22")
|
|
|
|
root := &store.Post{
|
|
AuthorID: alice.ID,
|
|
Title: "Drip",
|
|
Body: "Under sink",
|
|
City: "Oakland",
|
|
}
|
|
if err := mem.CreatePost(context.Background(), root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := &store.Post{
|
|
ParentID: &root.ID,
|
|
AuthorID: hub.ID,
|
|
Body: "Replace the cartridge.",
|
|
}
|
|
if err := mem.CreatePost(context.Background(), reply); 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("email", hubName+"@example.com")
|
|
_ = w.WriteField("state", "OR")
|
|
part, err := w.CreateFormFile("avatar", "pic.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
|
if err := png.Encode(part, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = 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 fb.calls != 1 {
|
|
t.Fatalf("expected 1 upload, got %d", fb.calls)
|
|
}
|
|
hub, _ = mem.UserByUsername(context.Background(), hubName)
|
|
if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") {
|
|
t.Fatalf("avatar url %q", hub.AvatarURL)
|
|
}
|
|
}
|
|
|
|
func TestMutationsVoteHideAndCSRF(t *testing.T) {
|
|
srv, mem := newTestServer(t, Config{})
|
|
h := srv.Handler()
|
|
adminName := uniq("admin")
|
|
userName := uniq("user")
|
|
seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
|
|
user := seedUser(t, mem, userName, "hunter22", store.RoleUser)
|
|
adminCookies := loginUser(t, h, adminName, "hunter22")
|
|
userCookies := loginUser(t, h, userName, "hunter22")
|
|
|
|
q := &store.Post{
|
|
AuthorID: user.ID,
|
|
Title: "Pipe noise",
|
|
Body: "Clanking",
|
|
City: "SF",
|
|
PostDate: pacific.Today(),
|
|
}
|
|
if err := mem.CreatePost(context.Background(), q); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Missing CSRF
|
|
form := strings.NewReader("value=1&view=question")
|
|
req := httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
for _, c := range userCookies {
|
|
req.AddCookie(c)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("missing csrf want 403, got %d", rec.Code)
|
|
}
|
|
|
|
// Anonymous HTMX vote → sign-in prompt
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
h.ServeHTTP(rec, req)
|
|
anon := rec.Result().Cookies()
|
|
csrf := csrfFrom(rec.Body.String())
|
|
form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question")
|
|
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("HX-Request", "true")
|
|
for _, c := range anon {
|
|
req.AddCookie(c)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Sign in") {
|
|
t.Fatalf("anon htmx vote: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// User vote + HTMX fragment
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
|
for _, c := range userCookies {
|
|
req.AddCookie(c)
|
|
}
|
|
h.ServeHTTP(rec, req)
|
|
csrf = csrfFrom(rec.Body.String())
|
|
form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question")
|
|
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("HX-Request", "true")
|
|
for _, c := range userCookies {
|
|
req.AddCookie(c)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
got, err := mem.GetPostThreadForViewer(context.Background(), q.ID, user.ID)
|
|
if err != nil || got.UserVote != 1 || got.Score != 1 {
|
|
t.Fatalf("vote not applied: %+v %v", got, err)
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", nil)
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("removed answer endpoint want 404, got %d", rec.Code)
|
|
}
|
|
|
|
// Hide invalid id
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
|
for _, c := range adminCookies {
|
|
req.AddCookie(c)
|
|
}
|
|
h.ServeHTTP(rec, req)
|
|
csrf = csrfFrom(rec.Body.String())
|
|
form = strings.NewReader("_csrf=" + csrf)
|
|
req = httptest.NewRequest(http.MethodPost, "/questions/does-not-exist/hide", 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.StatusNotFound {
|
|
t.Fatalf("hide missing want 404, got %d", rec.Code)
|
|
}
|
|
|
|
// Admin hide success
|
|
form = strings.NewReader("_csrf=" + csrf)
|
|
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/hide", 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("hide %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
hidden, err := mem.GetPost(context.Background(), q.ID)
|
|
if err != nil || hidden.PostState != store.PostStateHidden {
|
|
t.Fatalf("question not hidden: %+v %v", hidden, err)
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
func TestRegisterRequiresEmail(t *testing.T) {
|
|
srv, _ := newTestServer(t, Config{})
|
|
h := srv.Handler()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
|
csrf := csrfFrom(rec.Body.String())
|
|
cookies := rec.Result().Cookies()
|
|
form := strings.NewReader("_csrf=" + csrf + "&username=" + uniq("noem") + "&email=bad&password=hunter22")
|
|
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 != 200 || !strings.Contains(rec.Body.String(), "valid email") {
|
|
t.Fatalf("want email validation error, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For
|
|
// cannot bypass rate limits when the direct peer is outside TrustedProxies.
|
|
// This must go through Handler() so middleware ordering bugs are caught.
|
|
func TestRegisterThrottleUsesTCPPeerThroughRouter(t *testing.T) {
|
|
_, proxyNet, err := net.ParseCIDR("10.0.0.0/8")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv, _ := newTestServer(t, Config{TrustedProxies: []*net.IPNet{proxyNet}})
|
|
// Tight window so the test stays fast.
|
|
srv.registerIP = newThrottle(3, time.Minute, 100)
|
|
h := srv.Handler()
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
|
cookies := rec.Result().Cookies()
|
|
csrf := csrfFrom(rec.Body.String())
|
|
if csrf == "" {
|
|
t.Fatal("missing csrf")
|
|
}
|
|
|
|
post := func(xff string) int {
|
|
form := strings.NewReader("_csrf=" + csrf + "&username=ab&password=hunter22")
|
|
req := httptest.NewRequest(http.MethodPost, "/register", form)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.RemoteAddr = "203.0.113.50:9"
|
|
req.Header.Set("X-Forwarded-For", xff)
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
return w.Code
|
|
}
|
|
|
|
if code := post("198.51.100.1"); code != http.StatusOK {
|
|
t.Fatalf("attempt 1: got %d want 200 (validation error page)", code)
|
|
}
|
|
if code := post("198.51.100.2"); code != http.StatusOK {
|
|
t.Fatalf("attempt 2: got %d want 200", code)
|
|
}
|
|
if code := post("198.51.100.3"); code != http.StatusOK {
|
|
t.Fatalf("attempt 3: got %d want 200", code)
|
|
}
|
|
if code := post("198.51.100.4"); code != http.StatusTooManyRequests {
|
|
t.Fatalf("forged XFF must not bypass peer throttle, got %d want 429", code)
|
|
}
|
|
}
|