From 9335e8034216c35c017d4debf4b61c8afbbb2e4a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 19 Oct 2026 21:40:41 +0200 Subject: [PATCH] test: RequestUser, EndRequestSession, AvatarURL and user.Deactivating - TestRequestUser: bearer, token and auth_token cookies in guard order; expired, foreign, blacklisted, revoked by tokens_valid_after and deactivated tokens are anonymous; only infrastructure failures error - TestEndRequestSession (no database): jti blacklisted until the token expires, forget cookie with the configured Domain and Path, best effort - TestAvatarURL: thumbnail, broken-image and storage-failure fallbacks, gravatar with the size, the user payload's avatar_url unchanged - TestDeactivatingEvent: one event per newly deactivated user inside the bulk action's transaction, a listener error rolls the action back --- avatar_url_test.go | 136 +++++++++++++++++ deactivating_event_test.go | 110 ++++++++++++++ session_user_test.go | 297 +++++++++++++++++++++++++++++++++++++ 3 files changed, 543 insertions(+) create mode 100644 avatar_url_test.go create mode 100644 deactivating_event_test.go create mode 100644 session_user_test.go diff --git a/avatar_url_test.go b/avatar_url_test.go new file mode 100644 index 0000000..c363533 --- /dev/null +++ b/avatar_url_test.go @@ -0,0 +1,136 @@ +package user + +import ( + "context" + "crypto/md5" + "fmt" + "strconv" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gocloud.dev/blob/memblob" +) + +// jpegAvatar stores a real JPEG avatar for user (a system_files row and its +// blob) and returns the row. +func (e *adminEnv) jpegAvatar(t *testing.T, user *models.User) attach.File { + t.Helper() + disk := "face" + adminStamp() + ".jpg" + data := jpegBytes(t) + if err := e.bucket.WriteAll(context.Background(), attach.BlobKey(disk), data, nil); err != nil { + t.Fatal(err) + } + row := attach.File{ + DiskName: disk, FileName: "face.jpg", FileSize: int64(len(data)), ContentType: "image/jpeg", + Field: models.AvatarField, AttachmentID: strconv.FormatUint(uint64(user.ID), 10), AttachmentType: user.MorphName(), + } + if err := e.gdb.Create(&row).Error; err != nil { + t.Fatal(err) + } + return row +} + +func gravatarFor(email string, size int) string { + return fmt.Sprintf("//www.gravatar.com/avatar/%x?s=%d&d=mm", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))), size) +} + +// TestAvatarURL pins classes.AvatarURL (D-05): the avatar thumbnail at the +// asked size, the file path when the thumbnail fails, the gravatar +// fallback with the size, errors only for a failing lookup, and the user +// API payload (applyAvatar) unchanged: its avatar_url is AvatarURL at 128. +func TestAvatarURL(t *testing.T) { + env := newAdminEnv(t) + ctx := t.Context() + + if _, err := classes.AvatarURL(ctx, env.app, nil, 128); err == nil { + t.Fatal("a nil user must be refused") + } + + // Gravatar: no app, an unsaved user, no attachment; the email is + // trimmed and lower-cased, the size is the one asked for. + plain := env.seedUser(t, "plain", func(u *models.User) { u.Email = " Mixed.Case-" + adminStamp() + "@Example.TEST " }) + for _, size := range []int{32, 128, 256} { + for name, call := range map[string]func() (string, error){ + "no app": func() (string, error) { return classes.AvatarURL(ctx, nil, plain, size) }, + "unsaved user": func() (string, error) { return classes.AvatarURL(ctx, env.app, &models.User{Email: plain.Email}, size) }, + "no attachment": func() (string, error) { return classes.AvatarURL(ctx, env.app, plain, size) }, + } { + got, err := call() + if err != nil || got != gravatarFor(plain.Email, size) { + t.Fatalf("%s at %d = %q %v, want %q", name, size, got, err, gravatarFor(plain.Email, size)) + } + } + } + if gravatarFor(plain.Email, 1) != gravatarFor(strings.ToLower(strings.TrimSpace(plain.Email)), 1) { + t.Fatal("the gravatar hash is not of the normalised email") + } + + // A file avatar: the thumbnail at size by size, mode auto. + withFile := env.seedUser(t, "withfile", nil) + row := env.jpegAvatar(t, withFile) + thumb, err := classes.AvatarURL(ctx, env.app, withFile, 64) + want := attach.ThumbFilename(row.ID, 64, 64, 0, 0, "auto", "jpg") + if err != nil || !strings.HasSuffix(thumb, want) || strings.HasPrefix(thumb, "//") { + t.Fatalf("thumbnail = %q %v, want a path ending in %s", thumb, err, want) + } + // Without a published bucket the gravatar is used even with a file. + noBucket := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret) + if err := noBucket.Publish(env.gdb); err != nil { + t.Fatal(err) + } + if got, err := classes.AvatarURL(ctx, noBucket, withFile, 64); err != nil || got != gravatarFor(withFile.Email, 64) { + t.Fatalf("no bucket = %q %v", got, err) + } + + // An original that does not decode gets WinterCMS's broken-image + // thumbnail, still a thumbnail path. + notImage := env.seedUser(t, "notimage", nil) + env.seedAvatar(t, notImage) // its blob is not an image + if got, err := classes.AvatarURL(ctx, env.app, notImage, 128); err != nil || !strings.Contains(got, "thumb_") { + t.Fatalf("non-image avatar = %q %v", got, err) + } + // A thumbnail that cannot be made (here: the storage fails) falls back + // to the file path. + closed := memblob.OpenBucket(nil) + _ = closed.Close() + failing := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret) + if err := failing.Publish(env.gdb); err != nil { + t.Fatal(err) + } + if err := attach.Publish(failing, closed); err != nil { + t.Fatal(err) + } + if got, err := classes.AvatarURL(ctx, failing, withFile, 128); err != nil || got != "/"+attach.BlobKey(row.DiskName) { + t.Fatalf("failing thumbnail = %q %v, want /%s", got, err, attach.BlobKey(row.DiskName)) + } + + // A failing lookup is the only error. + canceled, cancel := context.WithCancel(ctx) + cancel() + if got, err := classes.AvatarURL(canceled, env.app, withFile, 128); err == nil { + t.Fatalf("failing lookup = %q, want an error", got) + } + + // The user API payload is unchanged: avatar_url is AvatarURL at 128, + // next to has_avatar and the disk name. + payload, err := controllers.APIArray(ctx, env.app, withFile) + if err != nil { + t.Fatal(err) + } + at128, _ := classes.AvatarURL(ctx, env.app, withFile, 128) + if payload["has_avatar"] != true || payload["avatar"] != row.DiskName || payload["avatar_url"] != at128 { + t.Fatalf("payload avatar fields = %v %v %v, want true %s %s", payload["has_avatar"], payload["avatar"], payload["avatar_url"], row.DiskName, at128) + } + without, err := controllers.APIArray(ctx, env.app, plain) + if err != nil { + t.Fatal(err) + } + // Without one the payload keeps its gravatar default (size 25). + if without["has_avatar"] != false || without["avatar"] != gravatarFor(plain.Email, 25) { + t.Fatalf("payload without an avatar = %v %v", without["has_avatar"], without["avatar"]) + } +} diff --git a/deactivating_event_test.go b/deactivating_event_test.go new file mode 100644 index 0000000..a02ba9f --- /dev/null +++ b/deactivating_event_test.go @@ -0,0 +1,110 @@ +package user + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "gorm.io/gorm" +) + +// TestDeactivatingEvent pins user.Deactivating (D-05): the admin deactivate +// bulk action fires one event per newly deactivated user inside the +// action's transaction, already deactivated users fire none, a listener +// error rolls the whole action back, and the event name is the constant. +func TestDeactivatingEvent(t *testing.T) { + if classes.DeactivatingEventName != "user.Deactivating" { + t.Fatalf("DeactivatingEventName = %q", classes.DeactivatingEventName) + } + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + + var mu sync.Mutex + var fired []uint + var failFor uint + var txSawDeactivation = map[uint]bool{} + env.app.Events.Listen[*classes.DeactivatingEvent]("test.deactivating", func(ctx context.Context, e *classes.DeactivatingEvent) error { + mu.Lock() + defer mu.Unlock() + if e.User == nil || e.Tx == nil { + return errors.New("event without user or transaction") + } + fired = append(fired, e.User.ID) + // Inside the action's transaction the row is already deactivated. + var deleted int64 + if err := e.Tx.Session(&gorm.Session{NewDB: true}).Unscoped().Model(&models.User{}). + Where("id = ? AND deleted_at IS NOT NULL", e.User.ID).Count(&deleted).Error; err != nil { + return err + } + txSawDeactivation[e.User.ID] = deleted == 1 + if e.User.ID == failFor { + return errors.New("listener refuses") + } + return nil + }) + reset := func() { + mu.Lock() + defer mu.Unlock() + fired, txSawDeactivation = nil, map[uint]bool{} + } + + a, b := env.seedUser(t, "deact-a", nil), env.seedUser(t, "deact-b", nil) + if rec := env.bulk(t, token, "deactivate", a, b); rec.Code != http.StatusOK { + t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String()) + } + mu.Lock() + if len(fired) != 2 || !txSawDeactivation[a.ID] || !txSawDeactivation[b.ID] { + t.Fatalf("events %v, seen in the transaction %v", fired, txSawDeactivation) + } + mu.Unlock() + for _, u := range []*models.User{a, b} { + if row := env.reload(t, u.ID); row == nil || !row.DeletedAt.Valid { + t.Fatalf("user %d not deactivated", u.ID) + } + } + + // Already deactivated users fire nothing; a mixed selection fires only + // for the newly deactivated one. + reset() + c := env.seedUser(t, "deact-c", nil) + if rec := env.bulk(t, token, "deactivate", a, c); rec.Code != http.StatusOK { + t.Fatalf("mixed deactivate: %d %s", rec.Code, rec.Body.String()) + } + mu.Lock() + if len(fired) != 1 || fired[0] != c.ID { + t.Fatalf("mixed selection fired %v, want only %d", fired, c.ID) + } + mu.Unlock() + + // A listener error rolls the whole action back. + reset() + d, e := env.seedUser(t, "deact-d", nil), env.seedUser(t, "deact-e", nil) + failFor = e.ID + if rec := env.bulk(t, token, "deactivate", d, e); rec.Code < 400 { + t.Fatalf("deactivate with a refusing listener: %d %s", rec.Code, rec.Body.String()) + } + failFor = 0 + for _, u := range []*models.User{d, e} { + if row := env.reload(t, u.ID); row == nil || row.DeletedAt.Valid { + t.Fatalf("user %d deactivated although a listener refused", u.ID) + } + } + + // Other bulk actions fire nothing. + reset() + if rec := env.bulk(t, token, "restore", a); rec.Code != http.StatusOK { + t.Fatalf("restore: %d %s", rec.Code, rec.Body.String()) + } + if rec := env.bulk(t, token, "activate", d); rec.Code != http.StatusOK { + t.Fatalf("activate: %d %s", rec.Code, rec.Body.String()) + } + mu.Lock() + if len(fired) != 0 { + t.Fatalf("restore and activate fired %v", fired) + } + mu.Unlock() +} diff --git a/session_user_test.go b/session_user_test.go new file mode 100644 index 0000000..5deb4f0 --- /dev/null +++ b/session_user_test.go @@ -0,0 +1,297 @@ +package user + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/compass" + "gorm.io/gorm" +) + +// failingBlacklist fails every call, as an unreachable blacklist table does. +type failingBlacklist struct{} + +var errBlacklistDown = errors.New("blacklist down") + +func (failingBlacklist) Add(context.Context, string, time.Time, time.Time) error { + return errBlacklistDown +} +func (failingBlacklist) IsBlacklisted(context.Context, string) (bool, error) { + return false, errBlacklistDown +} +func (failingBlacklist) Sweep(context.Context, time.Time) error { return errBlacklistDown } + +// sessionUserApp is an application without a database: the plugin's JWT secret +// and, when cookie is set, the 19.3 cookie settings. +func sessionUserApp(t *testing.T, environ ...string) *backpack.App { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: session-user\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development"}, environ...)}) + if err != nil { + t.Fatal(err) + } + return backpack.New(cfg) +} + +// sessionRequest is a GET carrying the session token the way kind says: +// "bearer", "token" (cookie) or "auth_token" (cookie). +func sessionRequest(kind, token string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil) + switch kind { + case "bearer": + r.Header.Set("Authorization", "Bearer "+token) + case "token", "auth_token": + r.AddCookie(&http.Cookie{Name: kind, Value: token}) + } + return r +} + +// TestRequestUser pins classes.RequestUser (D-05): the session JWT from the +// bearer header, then the token and auth_token cookies, checked like the +// plugin's jwt guard; every rejected token is anonymous and only an +// infrastructure failure is an error. +func TestRequestUser(t *testing.T) { + env := newAdminEnv(t) + ctx := t.Context() + issue := func(u *models.User) string { + t.Helper() + token, err := controllers.IssueToken(env.app, u, httptest.NewRequest(http.MethodPost, "https://app.example.test/login", nil)) + if err != nil { + t.Fatal(err) + } + return token + } + expectUser := func(name string, r *http.Request, want *models.User) { + t.Helper() + got, err := classes.RequestUser(ctx, env.app, r) + if err != nil { + t.Fatalf("%s: error %v", name, err) + } + switch { + case want == nil && got != nil: + t.Fatalf("%s: signed in user %d, want anonymous", name, got.ID) + case want != nil && (got == nil || got.ID != want.ID || got.Email != want.Email): + t.Fatalf("%s: user %+v, want %d", name, got, want.ID) + } + } + + ada, bea := env.seedUser(t, "ada", nil), env.seedUser(t, "bea", nil) + adaToken, beaToken := issue(ada), issue(bea) + + expectUser("bearer", sessionRequest("bearer", adaToken), ada) + expectUser("token cookie", sessionRequest("token", adaToken), ada) + expectUser("auth_token cookie", sessionRequest("auth_token", adaToken), ada) + both := sessionRequest("bearer", adaToken) + both.AddCookie(&http.Cookie{Name: "token", Value: beaToken}) + expectUser("bearer before cookie", both, ada) + cookies := sessionRequest("token", beaToken) + cookies.AddCookie(&http.Cookie{Name: "auth_token", Value: adaToken}) + expectUser("token before auth_token", cookies, bea) + blankBearer := sessionRequest("token", beaToken) + blankBearer.Header.Set("Authorization", "Bearer ") + expectUser("blank bearer falls back to the cookie", blankBearer, bea) + + expectUser("anonymous", httptest.NewRequest(http.MethodGet, "/", nil), nil) + if u, err := classes.RequestUser(ctx, env.app, nil); u != nil || err != nil { + t.Fatalf("nil request: %v %v", u, err) + } + + sub := strconv.FormatUint(uint64(ada.ID), 10) + expired, _, _ := bouncer.Mint(sessionSecret, sub, "https://app.example.test", -time.Minute) + expectUser("expired", sessionRequest("bearer", expired), nil) + forged, _, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", sub, "https://app.example.test", time.Hour) + expectUser("another secret", sessionRequest("bearer", forged), nil) + expectUser("garbage", sessionRequest("bearer", "not-a-jwt"), nil) + for _, s := range []string{"abc", "0", "-1"} { + odd, _, _ := bouncer.Mint(sessionSecret, s, "https://app.example.test", time.Hour) + expectUser("sub "+s, sessionRequest("bearer", odd), nil) + } + gone, _, _ := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(bea.ID+1_000_000), 10), "https://app.example.test", time.Hour) + expectUser("unknown user", sessionRequest("bearer", gone), nil) + + // Blacklisted: the jti EndRequestSession revoked signs nobody in. + carl := env.seedUser(t, "carl", nil) + carlToken := issue(carl) + expectUser("carl before sign-out", sessionRequest("bearer", carlToken), carl) + if err := classes.EndRequestSession(ctx, env.app, httptest.NewRecorder(), sessionRequest("bearer", carlToken)); err != nil { + t.Fatal(err) + } + expectUser("blacklisted", sessionRequest("bearer", carlToken), nil) + + // tokens_valid_after after the token's iat (a password change). + dora := env.seedUser(t, "dora", nil) + doraToken := issue(dora) + cutoff := time.Now().Add(time.Minute) + if err := env.gdb.Model(&models.User{}).Where("id = ?", dora.ID).Update("tokens_valid_after", cutoff).Error; err != nil { + t.Fatal(err) + } + expectUser("tokens_valid_after", sessionRequest("bearer", doraToken), nil) + + // A soft-deleted (deactivated) user. + eve := env.seedUser(t, "eve", nil) + eveToken := issue(eve) + if err := env.gdb.Model(&models.User{}).Where("id = ?", eve.ID).Update("deleted_at", time.Now()).Error; err != nil { + t.Fatal(err) + } + expectUser("soft-deleted", sessionRequest("bearer", eveToken), nil) + + // Only infrastructure failures are errors. + noDB := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret) + if u, err := classes.RequestUser(ctx, noDB, sessionRequest("bearer", adaToken)); u != nil || err == nil { + t.Fatalf("no database handle: %v %v", u, err) + } + noSecret := sessionUserApp(t) + if u, err := classes.RequestUser(ctx, noSecret, sessionRequest("bearer", adaToken)); u != nil || err == nil { + t.Fatalf("no JWT secret: %v %v", u, err) + } + if u, err := classes.RequestUser(ctx, noSecret, httptest.NewRequest(http.MethodGet, "/", nil)); u != nil || err != nil { + t.Fatalf("anonymous without a secret: %v %v", u, err) + } + broken := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret) + if err := broken.Publish(env.gdb); err != nil { + t.Fatal(err) + } + if err := broken.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil { + t.Fatal(err) + } + if u, err := classes.RequestUser(ctx, broken, sessionRequest("bearer", adaToken)); u != nil || !errors.Is(err, errBlacklistDown) { + t.Fatalf("failing blacklist: %v %v", u, err) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + if u, err := classes.RequestUser(canceled, env.app, sessionRequest("bearer", adaToken)); u != nil || err == nil { + t.Fatalf("failing query: %v %v", u, err) + } + // Without a published blacklist the token is accepted on its own checks. + plain := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret) + if err := plain.Publish[*gorm.DB](env.gdb); err != nil { + t.Fatal(err) + } + if u, err := classes.RequestUser(ctx, plain, sessionRequest("bearer", adaToken)); err != nil || u == nil || u.ID != ada.ID { + t.Fatalf("without a blacklist: %v %v", u, err) + } +} + +// TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without +// a database: the session's jti is blacklisted until the token expires, the +// forget cookie carries the configured Domain and Path, a request without a +// token still forgets the cookie, and a blacklist failure never fails the +// call. +func TestEndRequestSession(t *testing.T) { + ctx := t.Context() + app := sessionUserApp(t, + "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret, + "SUMMER_AUTH__COOKIE__DOMAIN=app.example.test", + "SUMMER_AUTH__COOKIE__PATH=/app", + ) + bl := bouncer.NewMemoryBlacklist() + if err := app.Publish[bouncer.BlacklistStore](bl); err != nil { + t.Fatal(err) + } + forgot := func(t *testing.T, rec *httptest.ResponseRecorder, domain, path string) { + t.Helper() + res := rec.Result() + var found *http.Cookie + for _, c := range res.Cookies() { + if c.Name == bouncer.DefaultCookieName { + found = c + } + } + if found == nil || found.Value != "" || found.MaxAge >= 0 || found.Domain != domain || found.Path != path { + t.Fatalf("forget cookie = %+v (Set-Cookie %q)", found, res.Header.Values("Set-Cookie")) + } + if rec.Body.Len() != 0 { + t.Fatalf("EndRequestSession wrote a body: %q", rec.Body.String()) + } + } + + for _, kind := range []string{"bearer", "token", "auth_token"} { + token, jti, err := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + if err := classes.EndRequestSession(ctx, app, rec, sessionRequest(kind, token)); err != nil { + t.Fatalf("%s: %v", kind, err) + } + forgot(t, rec, "app.example.test", "/app") + if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked { + t.Fatalf("%s: the jti is not blacklisted", kind) + } + // Until the token's expiry, and no longer. + _ = bl.Sweep(ctx, time.Now()) + if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked { + t.Fatalf("%s: the entry left before the token expired", kind) + } + _ = bl.Sweep(ctx, time.Now().Add(time.Hour+time.Minute)) + if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked { + t.Fatalf("%s: the entry outlived the token", kind) + } + } + + // No token, a token of another secret, garbage: the cookie is forgotten + // and nothing is blacklisted. + foreign, foreignJTI, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", "7", "https://app.example.test", time.Hour) + for name, r := range map[string]*http.Request{ + "no token": httptest.NewRequest(http.MethodPost, "/", nil), + "another secret": sessionRequest("bearer", foreign), + "garbage": sessionRequest("token", "not-a-jwt"), + } { + rec := httptest.NewRecorder() + if err := classes.EndRequestSession(ctx, app, rec, r); err != nil { + t.Fatalf("%s: %v", name, err) + } + forgot(t, rec, "app.example.test", "/app") + } + if blocked, _ := bl.IsBlacklisted(ctx, foreignJTI); blocked { + t.Fatal("a token of another secret was blacklisted") + } + + // A blacklist failure is logged, never returned; the cookie still goes. + down := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret) + if err := down.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil { + t.Fatal(err) + } + token, _, _ := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour) + rec := httptest.NewRecorder() + if err := classes.EndRequestSession(ctx, down, rec, sessionRequest("bearer", token)); err != nil { + t.Fatalf("failing blacklist: %v", err) + } + forgot(t, rec, "", "/") + + // Without an app or a writer it does what it can. + rec = httptest.NewRecorder() + if err := classes.EndRequestSession(ctx, nil, rec, sessionRequest("bearer", token)); err != nil { + t.Fatal(err) + } + forgot(t, rec, "", "/") + if err := classes.EndRequestSession(ctx, app, nil, nil); err != nil { + t.Fatal(err) + } + // An invalid cookie configuration falls back to the defaults. + badCookie := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret, "SUMMER_AUTH__COOKIE__PATH=relative") + rec = httptest.NewRecorder() + if err := classes.EndRequestSession(ctx, badCookie, rec, nil); err != nil { + t.Fatal(err) + } + forgot(t, rec, "", "/") + if !strings.Contains(strings.Join(rec.Result().Header.Values("Set-Cookie"), ";"), "token=") { + t.Fatal("no Set-Cookie for the token cookie") + } +}