Make web tests database-free and finish review hardening.
Introduce a Store interface with Postgres and in-memory backends, cover mutations/CSRF/session rotation without Postgres, bound avatar decode dimensions, add truncate/prepareAvatar unit tests, and run go test -race in CI.
This commit is contained in:
@@ -28,7 +28,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.requireAdmin(w, r) == nil {
|
||||
return
|
||||
}
|
||||
users, err := store.ListUsers(r.Context(), s.db)
|
||||
users, err := s.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "could not load users", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -48,14 +48,14 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
role := store.Role(r.PostFormValue("role"))
|
||||
u, err := store.UserByID(r.Context(), s.db, id)
|
||||
_, err := s.store.UserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not update role", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = u.SetRole(r.Context(), role)
|
||||
err = s.store.SetUserRole(r.Context(), id, role)
|
||||
if errors.Is(err, store.ErrLastAdmin) {
|
||||
users, listErr := store.ListUsers(r.Context(), s.db)
|
||||
users, listErr := s.store.ListUsers(r.Context())
|
||||
if listErr != nil {
|
||||
http.Error(w, "could not demote last admin", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -43,7 +43,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
next := safeNext(r.PostFormValue("next"))
|
||||
u, err := store.UserByUsername(r.Context(), s.db, username)
|
||||
u, err := s.store.UserByUsername(r.Context(), username)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
s.exec(w, "login", authPage{
|
||||
@@ -94,7 +94,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
role := store.RoleUser
|
||||
if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) {
|
||||
n, err := store.CountAdmins(r.Context(), s.db)
|
||||
n, err := s.store.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "could not create account", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -103,11 +103,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
role = store.RoleAdmin
|
||||
}
|
||||
}
|
||||
u := store.NewUser(s.db)
|
||||
u.Username = username
|
||||
u.PasswordHash = string(hash)
|
||||
u.Role = role
|
||||
if err := u.Create(r.Context()); err != nil {
|
||||
u := &store.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||
p.Error = "That username is taken."
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateRunes(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"abc", 10, "abc"},
|
||||
{"abcdef", 3, "abc"},
|
||||
{"héllo", 3, "hél"},
|
||||
{"🙂🙂🙂", 2, "🙂🙂"},
|
||||
{"世界和平", 2, "世界"},
|
||||
{"abc", 0, ""},
|
||||
{"abc", -1, ""},
|
||||
{"", 5, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := truncateRunes(tc.in, tc.max); got != tc.want {
|
||||
t.Fatalf("truncateRunes(%q, %d)=%q want %q", tc.in, tc.max, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAvatar(t *testing.T) {
|
||||
var pngBuf bytes.Buffer
|
||||
if err := png.Encode(&pngBuf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var jpegBuf bytes.Buffer
|
||||
if err := jpeg.Encode(&jpegBuf, image.NewRGBA(image.Rect(0, 0, 2, 2)), &jpeg.Options{Quality: 90}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oversized := bytes.Repeat([]byte{0x89}, (2<<20)+2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []byte
|
||||
max int64
|
||||
wantExt string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "png", in: pngBuf.Bytes(), max: 2 << 20, wantExt: ".png"},
|
||||
{name: "jpeg", in: jpegBuf.Bytes(), max: 2 << 20, wantExt: ".jpg"},
|
||||
{name: "empty", in: nil, max: 2 << 20, wantErr: "empty"},
|
||||
{name: "invalid", in: []byte("not-an-image"), max: 2 << 20, wantErr: "unsupported"},
|
||||
{name: "oversized", in: oversized, max: 2 << 20, wantErr: "too large"},
|
||||
{name: "huge dims", in: pngWithDims(100000, 100000), max: 2 << 20, wantErr: "dimensions"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, ext, ct, err := prepareAvatar(bytes.NewReader(tc.in), tc.max)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err=%v want substring %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ext != tc.wantExt {
|
||||
t.Fatalf("ext=%q want %q", ext, tc.wantExt)
|
||||
}
|
||||
if len(body) == 0 || ct == "" {
|
||||
t.Fatalf("empty output body/ct")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func pngWithDims(w, h int) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a})
|
||||
var ihdr bytes.Buffer
|
||||
_ = binary.Write(&ihdr, binary.BigEndian, uint32(w))
|
||||
_ = binary.Write(&ihdr, binary.BigEndian, uint32(h))
|
||||
ihdr.Write([]byte{8, 2, 0, 0, 0}) // bit depth, color type, compression, filter, interlace
|
||||
writePNGChunk(&buf, "IHDR", ihdr.Bytes())
|
||||
writePNGChunk(&buf, "IDAT", []byte{0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01})
|
||||
writePNGChunk(&buf, "IEND", nil)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func writePNGChunk(buf *bytes.Buffer, name string, data []byte) {
|
||||
_ = binary.Write(buf, binary.BigEndian, uint32(len(data)))
|
||||
buf.WriteString(name)
|
||||
buf.Write(data)
|
||||
crc := crc32.NewIEEE()
|
||||
_, _ = crc.Write([]byte(name))
|
||||
_, _ = crc.Write(data)
|
||||
_ = binary.Write(buf, binary.BigEndian, crc.Sum32())
|
||||
}
|
||||
+23
-7
@@ -99,7 +99,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if avatarURL != "" {
|
||||
u.AvatarURL = avatarURL
|
||||
}
|
||||
if err := u.SaveProfile(r.Context()); err != nil {
|
||||
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
|
||||
http.Error(w, "could not save profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -131,13 +131,29 @@ func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType s
|
||||
return nil, "", "", fmt.Errorf("unsupported type %s", sniff)
|
||||
}
|
||||
|
||||
img, format, err := image.Decode(bytes.NewReader(raw))
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
const maxDim = 4096
|
||||
const maxPixels = 4096 * 4096
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim {
|
||||
return nil, "", "", fmt.Errorf("image dimensions out of range")
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > maxPixels {
|
||||
return nil, "", "", fmt.Errorf("image too many pixels")
|
||||
}
|
||||
|
||||
img, decodedFormat, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if format != "" {
|
||||
decodedFormat = format
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
switch format {
|
||||
switch decodedFormat {
|
||||
case "jpeg":
|
||||
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||
return nil, "", "", err
|
||||
@@ -149,7 +165,7 @@ func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType s
|
||||
}
|
||||
return out.Bytes(), ".png", "image/png", nil
|
||||
default:
|
||||
return nil, "", "", fmt.Errorf("unsupported format %s", format)
|
||||
return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,16 +177,16 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
|
||||
)
|
||||
if u.Admin() {
|
||||
label = "Questions you answered"
|
||||
questions, err = store.ListQuestionsAnsweredBy(r.Context(), s.db, u.ID)
|
||||
questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
|
||||
} else {
|
||||
label = "Your questions"
|
||||
questions, err = store.ListQuestionsByAuthor(r.Context(), s.db, u.ID)
|
||||
questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if fresh, e := store.UserByID(r.Context(), s.db, u.ID); e == nil {
|
||||
if fresh, e := s.store.UserByID(r.Context(), u.ID); e == nil {
|
||||
u = fresh
|
||||
}
|
||||
p := s.basePage(r, "Profile")
|
||||
|
||||
+26
-25
@@ -3,7 +3,6 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -31,7 +30,7 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
store store.Store
|
||||
sessions *scs.SessionManager
|
||||
tmpl *template.Template
|
||||
cfg Config
|
||||
@@ -85,7 +84,7 @@ type voteCtx struct {
|
||||
Question store.RankedQuestion
|
||||
}
|
||||
|
||||
func New(db *sql.DB, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
|
||||
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
|
||||
if cfg.Blob == nil {
|
||||
cfg.Blob = blob.Disabled{}
|
||||
}
|
||||
@@ -125,7 +124,7 @@ func New(db *sql.DB, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, c
|
||||
}
|
||||
|
||||
return &Server{
|
||||
db: db,
|
||||
store: st,
|
||||
sessions: sessions,
|
||||
tmpl: tmpl,
|
||||
cfg: cfg,
|
||||
@@ -181,7 +180,7 @@ func (s *Server) withUser(next http.Handler) http.Handler {
|
||||
}
|
||||
id := s.sessions.GetString(r.Context(), "user_id")
|
||||
if id != "" {
|
||||
u, err := store.UserByID(r.Context(), s.db, id)
|
||||
u, err := s.store.UserByID(r.Context(), id)
|
||||
if err == nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), userKey, u))
|
||||
}
|
||||
@@ -259,7 +258,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := store.ListHunt(r.Context(), s.db, date, viewer)
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -319,12 +318,13 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if len(city) > 80 {
|
||||
city = truncateRunes(city, 80)
|
||||
}
|
||||
q := store.NewQuestion(s.db)
|
||||
q.AuthorID = u.ID
|
||||
q.Title = title
|
||||
q.Body = body
|
||||
q.City = city
|
||||
if err := q.Create(r.Context()); err != nil {
|
||||
q := &store.RankedQuestion{
|
||||
AuthorID: u.ID,
|
||||
Title: title,
|
||||
Body: body,
|
||||
City: city,
|
||||
}
|
||||
if err := s.store.CreateQuestion(r.Context(), q); err != nil {
|
||||
http.Error(w, "could not save question", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -337,14 +337,14 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
q, err := store.GetQuestion(r.Context(), s.db, id, viewer)
|
||||
q, err := s.store.GetQuestion(r.Context(), id, viewer)
|
||||
if err != nil || (q.Hidden && !currentUser(r).Admin()) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var ans *store.Answer
|
||||
if q.Answered {
|
||||
ans, _ = store.GetAnswer(r.Context(), s.db, q.ID)
|
||||
ans, _ = s.store.GetAnswer(r.Context(), q.ID)
|
||||
}
|
||||
s.exec(w, "question", questionPage{
|
||||
page: s.basePage(r, q.Title),
|
||||
@@ -377,7 +377,7 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid vote", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.Vote(r.Context(), s.db, u.ID, id, value); err != nil {
|
||||
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
|
||||
http.Error(w, "could not vote", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -388,7 +388,7 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderLeaderboard(w, r, date)
|
||||
return
|
||||
}
|
||||
q, err := store.GetQuestion(r.Context(), s.db, id, u.ID)
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -421,7 +421,7 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := store.ListHunt(r.Context(), s.db, date, viewer)
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -451,15 +451,16 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
if len(body) > 12000 {
|
||||
body = truncateRunes(body, 12000)
|
||||
}
|
||||
ans := store.NewAnswer(s.db)
|
||||
ans.QuestionID = id
|
||||
ans.AuthorID = u.ID
|
||||
ans.Body = body
|
||||
if err := ans.Upsert(r.Context()); err != nil {
|
||||
ans := &store.Answer{
|
||||
QuestionID: id,
|
||||
AuthorID: u.ID,
|
||||
Body: body,
|
||||
}
|
||||
if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
|
||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
saved, err := store.GetAnswer(r.Context(), s.db, id)
|
||||
saved, err := s.store.GetAnswer(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -481,12 +482,12 @@ func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
q, err := store.GetQuestion(r.Context(), s.db, id, u.ID)
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := q.Hide(r.Context()); err != nil {
|
||||
if err := s.store.HideQuestion(r.Context(), id); err != nil {
|
||||
http.Error(w, "could not hide", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
+266
-179
@@ -3,85 +3,83 @@ package web
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/alexedwards/scs/v2/memstore"
|
||||
"github.com/google/uuid"
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
func testDBURL() string {
|
||||
_ = godotenv.Load()
|
||||
return strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
||||
func newTestServer(t *testing.T, cfg Config) (*Server, *store.Memory) {
|
||||
t.Helper()
|
||||
mem := store.NewMemory()
|
||||
return newTestServerStore(t, mem, cfg), mem
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, cfg Config) (*Server, *sql.DB) {
|
||||
func newTestServerStore(t *testing.T, st store.Store, cfg Config) *Server {
|
||||
t.Helper()
|
||||
url := testDBURL()
|
||||
if url == "" {
|
||||
t.Skip("set TEST_DATABASE_URL for web tests")
|
||||
}
|
||||
db, sessions, err := store.OpenPostgres(url, plumber.SchemaSQL)
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sessions.Close()
|
||||
_ = db.Close()
|
||||
})
|
||||
if cfg.Blob == nil {
|
||||
cfg.Blob = blob.Disabled{}
|
||||
}
|
||||
srv, err := New(db, sessions.Store(), plumber.TemplateFS, plumber.StaticFS, cfg)
|
||||
srv, err := New(st, memstore.New(), plumber.TemplateFS, plumber.StaticFS, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv, db
|
||||
return srv
|
||||
}
|
||||
|
||||
func uniq(prefix string) string {
|
||||
return prefix + "_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db *sql.DB, username, password string, role store.Role) *store.User {
|
||||
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.NewUser(db)
|
||||
u.Username = username
|
||||
u.PasswordHash = string(hash)
|
||||
u.Role = role
|
||||
if err := u.Create(context.Background()); err != nil {
|
||||
u := &store.User{
|
||||
Username: username,
|
||||
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))
|
||||
cookies := rec.Result().Cookies()
|
||||
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 cookies {
|
||||
for _, c := range pre {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
@@ -89,7 +87,48 @@ func loginUser(t *testing.T, h http.Handler, username, password string) []*http.
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login %s: %d %s", username, rec.Code, rec.Body.String())
|
||||
}
|
||||
return mergeCookies(cookies, rec.Result().Cookies())
|
||||
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) []*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 := 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 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) {
|
||||
@@ -113,36 +152,18 @@ func TestRegisterLoginAsk(t *testing.T) {
|
||||
srv, _ := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
name := uniq("ask")
|
||||
session := registerUser(t, h, name, "hunter22")
|
||||
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=" + name + "&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)
|
||||
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")
|
||||
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 {
|
||||
@@ -158,107 +179,20 @@ func TestRegisterLoginAsk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSurvivesServerRestart(t *testing.T) {
|
||||
url := testDBURL()
|
||||
if url == "" {
|
||||
t.Skip("set TEST_DATABASE_URL for web tests")
|
||||
}
|
||||
db, sessions, err := store.OpenPostgres(url, plumber.SchemaSQL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sessions.Close()
|
||||
_ = db.Close()
|
||||
})
|
||||
sessionStore := sessions.Store()
|
||||
|
||||
srv1, err := New(db, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h1 := srv1.Handler()
|
||||
name := uniq("sess")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h1.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
preCookies := rec.Result().Cookies()
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&username=" + name + "&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(db, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{})
|
||||
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, db := newTestServer(t, Config{})
|
||||
n, err := store.CountAdmins(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n > 0 {
|
||||
t.Skip("admin already exists in database; bootstrap seed not exercised")
|
||||
}
|
||||
mem := store.NewMemory()
|
||||
adminName := uniq("seed")
|
||||
srv.cfg.AdminUsername = adminName
|
||||
srv := newTestServerStore(t, mem, Config{AdminUsername: adminName})
|
||||
h := srv.Handler()
|
||||
registerUser(t, h, adminName, "hunter22")
|
||||
u, err := store.UserByUsername(context.Background(), db, adminName)
|
||||
u, err := mem.UserByUsername(context.Background(), adminName)
|
||||
if err != nil || !u.Admin() {
|
||||
t.Fatalf("first matching registrant should be admin: %+v %v", u, err)
|
||||
}
|
||||
later := uniq("later")
|
||||
srv2, err := New(db, scs.New().Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: later})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv2 := newTestServerStore(t, mem, Config{AdminUsername: later})
|
||||
registerUser(t, srv2.Handler(), later, "hunter22")
|
||||
u2, err := store.UserByUsername(context.Background(), db, later)
|
||||
u2, err := mem.UserByUsername(context.Background(), later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -268,12 +202,12 @@ func TestAdminSeedOnlyWhenNoAdmins(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
srv, db := newTestServer(t, Config{})
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
hubName := uniq("hub")
|
||||
bobName := uniq("bob")
|
||||
carolName := uniq("carol")
|
||||
seedUser(t, db, hubName, "hunter22", store.RoleAdmin)
|
||||
seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
|
||||
adminCookies := loginUser(t, h, hubName, "hunter22")
|
||||
registerUser(t, h, bobName, "hunter22")
|
||||
|
||||
@@ -290,7 +224,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
t.Fatal("missing bob on admin page")
|
||||
}
|
||||
|
||||
bob, err := store.UserByUsername(context.Background(), db, bobName)
|
||||
bob, err := mem.UserByUsername(context.Background(), bobName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -306,15 +240,15 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("promote %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
bob, _ = store.UserByUsername(context.Background(), db, bobName)
|
||||
bob, _ = mem.UserByUsername(context.Background(), bobName)
|
||||
if !bob.Admin() {
|
||||
t.Fatal("bob should be admin")
|
||||
}
|
||||
|
||||
bobCookies := registerUser(t, h, carolName, "hunter22")
|
||||
carolCookies := registerUser(t, h, carolName, "hunter22")
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range bobCookies {
|
||||
for _, c := range carolCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
@@ -322,7 +256,6 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
t.Fatalf("non-admin expected 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Demote bob back to user
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range adminCookies {
|
||||
@@ -342,15 +275,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
t.Fatalf("demote bob %d", rec.Code)
|
||||
}
|
||||
|
||||
admins, err := store.CountAdmins(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if admins != 1 {
|
||||
t.Skip("shared database has other admins; last-admin demote not isolated")
|
||||
}
|
||||
|
||||
hub, err := store.UserByUsername(context.Background(), db, hubName)
|
||||
hub, err := mem.UserByUsername(context.Background(), hubName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -375,7 +300,7 @@ func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") {
|
||||
t.Fatalf("missing last-admin error: %s", rec.Body.String())
|
||||
}
|
||||
hub, _ = store.UserByUsername(context.Background(), db, hubName)
|
||||
hub, _ = mem.UserByUsername(context.Background(), hubName)
|
||||
if !hub.Admin() {
|
||||
t.Fatal("hub must remain admin")
|
||||
}
|
||||
@@ -395,7 +320,7 @@ func (f *fakeBlob) Upload(_ context.Context, obj blob.FileUpload) (string, error
|
||||
}
|
||||
|
||||
func TestProfilePageAndState(t *testing.T) {
|
||||
srv, db := newTestServer(t, Config{})
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
name := uniq("alice")
|
||||
cookies := registerUser(t, h, name, "hunter22")
|
||||
@@ -432,7 +357,7 @@ func TestProfilePageAndState(t *testing.T) {
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save profile %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
u, err := store.UserByUsername(context.Background(), db, name)
|
||||
u, err := mem.UserByUsername(context.Background(), name)
|
||||
if err != nil || u.State != "CA" {
|
||||
t.Fatalf("state not saved: %+v %v", u, err)
|
||||
}
|
||||
@@ -463,27 +388,29 @@ func TestProfilePageAndState(t *testing.T) {
|
||||
|
||||
func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
fb := &fakeBlob{}
|
||||
srv, db := newTestServer(t, Config{Blob: fb})
|
||||
srv, mem := newTestServer(t, Config{Blob: fb})
|
||||
h := srv.Handler()
|
||||
hubName := uniq("hub")
|
||||
aliceName := uniq("alice")
|
||||
hub := seedUser(t, db, hubName, "hunter22", store.RoleAdmin)
|
||||
alice := seedUser(t, db, aliceName, "hunter22", store.RoleUser)
|
||||
hub := seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
|
||||
alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
|
||||
adminCookies := loginUser(t, h, hubName, "hunter22")
|
||||
|
||||
q := store.NewQuestion(db)
|
||||
q.AuthorID = alice.ID
|
||||
q.Title = "Drip"
|
||||
q.Body = "Under sink"
|
||||
q.City = "Oakland"
|
||||
if err := q.Create(context.Background()); err != nil {
|
||||
q := &store.RankedQuestion{
|
||||
AuthorID: alice.ID,
|
||||
Title: "Drip",
|
||||
Body: "Under sink",
|
||||
City: "Oakland",
|
||||
}
|
||||
if err := mem.CreateQuestion(context.Background(), q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ans := store.NewAnswer(db)
|
||||
ans.QuestionID = q.ID
|
||||
ans.AuthorID = hub.ID
|
||||
ans.Body = "Replace the cartridge."
|
||||
if err := ans.Upsert(context.Background()); err != nil {
|
||||
ans := &store.Answer{
|
||||
QuestionID: q.ID,
|
||||
AuthorID: hub.ID,
|
||||
Body: "Replace the cartridge.",
|
||||
}
|
||||
if err := mem.UpsertAnswer(context.Background(), ans); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -528,12 +455,172 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
if fb.calls != 1 {
|
||||
t.Fatalf("expected 1 upload, got %d", fb.calls)
|
||||
}
|
||||
hub, _ = store.UserByUsername(context.Background(), db, hubName)
|
||||
hub, _ = mem.UserByUsername(context.Background(), hubName)
|
||||
if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") {
|
||||
t.Fatalf("avatar url %q", hub.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
adminName := uniq("admin")
|
||||
userName := uniq("user")
|
||||
admin := 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.RankedQuestion{
|
||||
AuthorID: user.ID,
|
||||
Title: "Pipe noise",
|
||||
Body: "Clanking",
|
||||
City: "SF",
|
||||
HuntDate: pacific.Today(),
|
||||
}
|
||||
if err := mem.CreateQuestion(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.GetQuestion(context.Background(), q.ID, user.ID)
|
||||
if err != nil || got.UserVote != 1 || got.Score != 1 {
|
||||
t.Fatalf("vote not applied: %+v %v", got, err)
|
||||
}
|
||||
|
||||
// Non-admin answer rejected
|
||||
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 + "&body=Nope")
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", 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("non-admin answer want 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Admin answer success (HTMX)
|
||||
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 + "&body=Tighten+the+nuts.")
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
|
||||
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 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.GetQuestion(context.Background(), q.ID, admin.ID)
|
||||
if err != nil || !hidden.Hidden {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user