Files
summercms/modules/cabana/auth_test.go

633 lines
22 KiB
Go

package cabana_test
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gorm.io/gorm"
)
const (
adminTestSecret = "summercms-test-only-admin-hs256-secret"
adminTestPassword = "lifecycle-secret"
)
func TestAdminAuthLifecycle(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "life",
"password": adminTestPassword,
})
if login.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", login.Code, login.Body.String())
}
token := accessToken(t, login.Body.Bytes())
if aud := jwtAudience(t, token); aud != "backend" {
t.Fatalf("aud=%q", aud)
}
if strings.Contains(login.Body.String(), adminTestPassword) {
t.Fatal("login body contains the password")
}
var stamped sql.NullTime
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE id = ?`, user.ID).Scan(&stamped).Error; err != nil {
t.Fatal(err)
}
if !stamped.Valid {
t.Fatal("successful login did not stamp last_login")
}
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"email": "life@example.test",
"password": adminTestPassword,
})
if byEmail.Code != http.StatusOK {
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
}
emailToken := accessToken(t, byEmail.Body.Bytes())
me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
if me.Code != http.StatusOK {
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
}
assertSafeProfile(t, me.Body.Bytes(), user)
refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
if refreshed.Code != http.StatusOK {
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
}
next := accessToken(t, refreshed.Body.Bytes())
if next == emailToken {
t.Fatal("refresh returned the same token")
}
if jwtAudience(t, next) != "backend" {
t.Fatal("refreshed token lost the backend audience")
}
oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
if oldMe.Code != http.StatusUnauthorized {
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
}
var blacklisted int
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, emailToken)).Scan(&blacklisted).Error; err != nil {
t.Fatal(err)
}
if blacklisted != 1 {
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
}
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
if out.Code != http.StatusOK {
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
}
if strings.Contains(out.Body.String(), next) {
t.Fatal("logout body contains the token")
}
after := getAuth(t, h, adminAPI("/auth/me"), next)
if after.Code != http.StatusUnauthorized {
t.Fatalf("me after logout status=%d", after.Code)
}
cutoff := time.Now().Add(time.Minute)
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
t.Fatal(err)
}
fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "life", "password": adminTestPassword,
}).Body.Bytes())
// Login mints after the cutoff, so this token is current. Move the cutoff
// past it to prove a stale principal fails closed.
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
t.Fatal(err)
}
stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
}
}
func TestAdminInactive(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
assertSameOpaque(t, unknown, wrong, right)
var stamped sql.NullTime
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
t.Fatal(err)
}
if stamped.Valid {
t.Fatal("inactive login stamped last_login")
}
}
func TestAdminDeleted(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
assertSameOpaque(t, unknown, deleted)
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
}
}
func TestAdminBlacklist(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "revoke", "password": adminTestPassword,
}).Body.Bytes())
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
if out.Code != http.StatusOK {
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
}
again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
if again.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
}
var n int
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, token)).Scan(&n).Error; err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("admin blacklist rows=%d, want the revoked jti in backend_jwt_blacklist", n)
}
}
func TestAdminLoginThrottle(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, func(cfg *compass.Config) {
if err := cfg.Set("admin.login.max_attempts", 2); err != nil {
t.Fatal(err)
}
if err := cfg.Set("admin.login.decay_minutes", 1); err != nil {
t.Fatal(err)
}
})
var last *httptest.ResponseRecorder
for i := 0; i < 3; i++ {
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "throttle-user", "password": adminTestPassword,
})
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("third login status=%d body=%s, want 429 from the fixed-window limiter", last.Code, last.Body.String())
}
}
func TestAdminAuthLogging(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false)
var publisherID uint
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 {
t.Fatalf("publisher role: id=%d err=%v", publisherID, err)
}
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("role_id", publisherID).Error; err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
token := accessToken(t, ok.Body.Bytes())
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
buf.Reset()
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
buf.Reset()
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
failedUnknown := buf.String()
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
t.Fatalf("unknown login log = %s", failedUnknown)
}
if strings.Contains(failedUnknown, `"admin_id"`) || strings.Contains(failedUnknown, "not-the-password") {
t.Fatalf("unknown login log disclosed an id or password: %s", failedUnknown)
}
buf.Reset()
denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
if denied.Code != http.StatusForbidden {
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
}
assertLog(t, &buf, "denied", user.ID, adminTestPassword, token)
}
func assertLog(t *testing.T, buf *bytes.Buffer, outcome string, id uint, secret, token string) {
t.Helper()
text := buf.String()
if !strings.Contains(text, `"outcome":"`+outcome+`"`) || !strings.Contains(text, `"admin_id":`+itoa(id)) {
t.Fatalf("log %q missing outcome %s admin %d", text, outcome, id)
}
if secret != "" && strings.Contains(text, secret) {
t.Fatalf("log contains a credential: %s", text)
}
if token != "" && strings.Contains(text, token) {
t.Fatalf("log contains a bearer token: %s", text)
}
if strings.Contains(text, "$2a$") || strings.Contains(text, adminTestSecret) {
t.Fatalf("log contains a hash or signing secret: %s", text)
}
}
func assertSameOpaque(t *testing.T, recs ...*httptest.ResponseRecorder) {
t.Helper()
if len(recs) == 0 {
t.Fatal("no responses")
}
want := recs[0].Body.String()
for _, rec := range recs {
if rec.Code != http.StatusUnauthorized || rec.Body.String() != want {
t.Fatalf("opaque mismatch %d %s vs %s", rec.Code, rec.Body.String(), want)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Code != "unauthenticated" || body.Error.Message == "" {
t.Fatalf("error = %+v", body.Error)
}
}
}
func assertSafeProfile(t *testing.T, raw []byte, user cabana.BackendUser) {
t.Helper()
if strings.Contains(string(raw), adminTestPassword) || strings.Contains(string(raw), "permissions") || strings.Contains(string(raw), "$2") {
t.Fatalf("profile leaked sensitive data: %s", raw)
}
var body struct {
Data struct {
ID uint `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsSuperuser bool `json:"is_superuser"`
Role struct {
Code string `json:"code"`
Name string `json:"name"`
} `json:"role"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
if body.Data.ID != user.ID || body.Data.Login != user.Login || body.Data.Email != user.Email || body.Data.FirstName != "Ada" || body.Data.LastName != "Admin" || body.Data.IsSuperuser || body.Data.Role.Code != "developer" || body.Data.Role.Name != "Developer" {
t.Fatalf("profile = %+v", body.Data)
}
}
var (
adminOnce sync.Once
adminSQL *sql.DB
adminGDB *gorm.DB
adminErr error
)
func adminGorm(t *testing.T) *gorm.DB {
t.Helper()
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
adminOnce.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("cabana"),
postgres.WithUsername("cabana"),
postgres.WithPassword("cabana"),
postgres.BasicWaitStrategies(),
)
if err != nil {
adminErr = err
return
}
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
adminErr = err
return
}
db, err := sql.Open("pgx", dsn)
if err != nil {
adminErr = err
return
}
gdb, err := lagoon.Use(ctx, db)
if err != nil {
adminErr = err
return
}
if err := lagoon.Migrate(gdb, nil); err != nil {
adminErr = err
return
}
adminSQL = db
adminGDB = gdb
})
if adminErr != nil {
t.Fatalf("postgres: %v", adminErr)
}
return adminGDB
}
func adminHandler(t *testing.T, gdb *gorm.DB, mutate func(*compass.Config)) http.Handler {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-auth\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{
"SUMMER_ENV=development",
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
},
})
if err != nil {
t.Fatal(err)
}
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
t.Fatal(err)
}
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
t.Fatal(err)
}
if mutate != nil {
mutate(cfg)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
if err != nil {
t.Fatal(err)
}
return h
}
func insertAdmin(t *testing.T, gdb *gorm.DB, login, email, password string, activated, deleted bool) cabana.BackendUser {
t.Helper()
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
var roleID uint
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("developer role: %v id=%d", err, roleID)
}
user := cabana.BackendUser{
FirstName: "Ada",
LastName: "Admin",
Login: login,
Email: email,
Password: hash,
IsActivated: activated,
RoleID: &roleID,
}
if err := gdb.Create(&user).Error; err != nil {
t.Fatal(err)
}
if deleted {
if err := gdb.Delete(&user).Error; err != nil {
t.Fatal(err)
}
}
return user
}
type demoPlugin struct{ fsys fs.FS }
func (p demoPlugin) ID() string { return "acme.demo" }
func (p demoPlugin) Requires() []string { return nil }
func (p demoPlugin) Register(*backpack.App) error { return nil }
func (p demoPlugin) Boot(*backpack.App) error { return nil }
func (p demoPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{demoController{}}
}
func (p demoPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}}
}
func (p demoPlugin) AdminFS() fs.FS { return p.fsys }
type demoController struct{}
func (demoController) ID() string { return "acme.demo.widgets" }
func (demoController) ModelName() string { return "Widget" }
func (demoController) ConfigDir() string { return "controllers/widgets" }
func (demoController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
func demoFS() fs.FS {
return fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
}
}
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return postAuth(t, h, http.MethodPost, path, "", raw)
}
func postAuth(t *testing.T, h http.Handler, method, path, token string, body []byte) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func getAuth(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
return postAuth(t, h, http.MethodGet, path, token, nil)
}
func accessToken(t *testing.T, raw []byte) string {
t.Helper()
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("token json: %v body=%s", err, raw)
}
if body.Data.AccessToken == "" {
t.Fatalf("missing access_token: %s", raw)
}
return body.Data.AccessToken
}
func jwtAudience(t *testing.T, token string) string {
t.Helper()
claims := jwtClaims(t, token)
switch v := claims["aud"].(type) {
case string:
return v
case []any:
if len(v) == 1 {
s, _ := v[0].(string)
return s
}
}
t.Fatalf("aud=%v", claims["aud"])
return ""
}
func jwtID(t *testing.T, token string) string {
t.Helper()
id, _ := jwtClaims(t, token)["jti"].(string)
if id == "" {
t.Fatal("token has no jti")
}
return id
}
func jwtClaims(t *testing.T, token string) map[string]any {
t.Helper()
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token segments=%d", len(parts))
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatal(err)
}
var claims map[string]any
if err := json.Unmarshal(raw, &claims); err != nil {
t.Fatal(err)
}
return claims
}
func itoa(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}
// adminAPI mirrors the internal helper in admin_paths_test.go for this
// external test package: a full admin API path under the default prefix.
func adminAPI(rel string) string {
return cabana.DefaultAdminPrefix + "/api/v1" + rel
}
// TestLoginAmbiguousIdentifier pins WR-11: an identifier that is one admin's
// login and another's email resolves to nobody (a plain 401), so neither admin
// is silently locked out by the other; each can still sign in by the
// unambiguous field.
func TestLoginAmbiguousIdentifier(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "ambig@amb.test", "owner-a@amb.test", "password-of-a", true, false)
insertAdmin(t, gdb, "owner-b", "ambig@amb.test", "password-of-b", true, false)
for _, password := range []string{"password-of-a", "password-of-b"} {
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "ambig@amb.test", "password": password})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ambiguous identifier with %q = %d %s, want 401", password, rec.Code, rec.Body.String())
}
}
if rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "owner-a@amb.test", "password": "password-of-a"}); rec.Code != http.StatusOK {
t.Fatalf("admin A by email = %d %s", rec.Code, rec.Body.String())
}
if rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "owner-b", "password": "password-of-b"}); rec.Code != http.StatusOK {
t.Fatalf("admin B by login = %d %s", rec.Code, rec.Body.String())
}
}
// TestAdminLogoutRevokesExpiredRefreshableToken pins WR-14: a token whose
// access lifetime has passed but whose refresh window is open can still be
// revoked through logout (the guard would reject it with 401 before any
// handler), so a leaked copy cannot be refreshed afterwards. An unusable token
// is a 401 that still clears the session cookie.
func TestAdminLogoutRevokesExpiredRefreshableToken(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "stale", "stale@example.test", adminTestPassword, true, false)
token, jti, err := bouncer.MintAudience(adminTestSecret, strconv.FormatUint(uint64(user.ID), 10), "https://app.test/backend/api/v1/auth/login", -time.Minute, bouncer.AudienceBackend)
if err != nil {
t.Fatal(err)
}
if _, _, _, _, err := bouncer.VerifyClaimsAudience(token, adminTestSecret, bouncer.AudienceBackend); err == nil {
t.Fatal("fixture token is not expired")
}
if me := postAuth(t, h, http.MethodGet, adminAPI("/auth/me"), token, nil); me.Code != http.StatusUnauthorized {
t.Fatalf("expired token on a guarded route = %d, want 401", me.Code)
}
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
if out.Code != http.StatusOK {
t.Fatalf("logout of an expired refreshable token = %d %s", out.Code, out.Body.String())
}
var n int
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jti).Scan(&n).Error; err != nil || n != 1 {
t.Fatalf("blacklist rows for the expired token = %d, %v; want 1", n, err)
}
if again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil); again.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout = %d %s, want 401", again.Code, again.Body.String())
}
// Not a token at all is refused, and the cookie is cleared either way.
cookie := &http.Cookie{Name: cabana.AdminCookieName, Value: "not-a-token"}
for name, rec := range map[string]*httptest.ResponseRecorder{
"garbage bearer": postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), "not-a-token", nil),
"garbage cookie": phase10Send(t, h, http.MethodPost, adminAPI("/auth/logout"), nil, cookie, true),
"no token": phase10Send(t, h, http.MethodPost, adminAPI("/auth/logout"), nil, nil, true),
} {
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s logout = %d, want 401", name, rec.Code)
}
cleared := false
for _, c := range rec.Result().Cookies() {
if c.Name == cabana.AdminCookieName && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatalf("%s logout did not clear the session cookie", name)
}
}
}