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:
2026-08-22 07:36:13 -07:00
parent afd2476f3c
commit f4cec32afb
12 changed files with 945 additions and 223 deletions
+266 -179
View File
@@ -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 {