package user import ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/sm-user-plugin/updates" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/postcard" "git.golem15.com/golem15/summercms/modules/surf" "github.com/go-gormigrate/gormigrate/v2" "github.com/golang-jwt/jwt/v5" _ "github.com/jackc/pgx/v5/stdlib" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "gorm.io/gorm" ) const ( sessionSecret = "summercms-test-only-hs256-secret" // adminHarnessSecret signs backend tokens in tests that mount the admin API. adminHarnessSecret = "summercms-test-only-admin-hs256-secret" invalidLoginBody = `{"error":true,"message":"Invalid email or password"}` prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883" ) var ( sessionOnce sync.Once sessionGDB *gorm.DB sessionSQL *sql.DB // sessionDSN is the container's connection string; the admin harness // opens its own database in the same container with it. sessionDSN string sessionErr error ) func TestLogin(t *testing.T) { app, gdb := sessionApp(t, "0s") hash, err := bouncer.HashPassword(10, "correct-horse") if err != nil { t.Fatal(err) } user := &models.User{Email: "ada-login@example.test", Password: hash, IsActivated: true} if err := gdb.Create(user).Error; err != nil { t.Fatal(err) } rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"correct-horse"}`) if rec.Code != http.StatusOK { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } token, _ := body["token"].(string) claims := parseToken(t, token) if claims["sub"] != strconv.FormatUint(uint64(user.ID), 10) { t.Fatalf("sub = %v", claims["sub"]) } if claims["prv"] != prvHash { t.Fatalf("prv = %v", claims["prv"]) } if claims["iss"] != "http://example.com/_user/api/v1/login" { t.Fatalf("iss = %v", claims["iss"]) } userBody, _ := body["user"].(map[string]any) if userBody["email"] != user.Email || userBody["feedback_widget_hidden"] != false { t.Fatalf("user payload = %#v", userBody) } if userBody["permissions"] != nil { t.Fatalf("permissions = %#v", userBody["permissions"]) } wrong := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"nope"}`) unknown := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"nobody@example.test","password":"nope"}`) if wrong.Code != http.StatusUnauthorized || wrong.Body.String() != invalidLoginBody { t.Fatalf("wrong = %d %s", wrong.Code, wrong.Body.String()) } if unknown.Body.String() != wrong.Body.String() { t.Fatalf("unknown body %s", unknown.Body.String()) } var before int64 if err := gdb.Model(&models.Throttle{}).Count(&before).Error; err != nil { t.Fatal(err) } again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ghost@example.test","password":"nope"}`) if again.Body.String() != invalidLoginBody { t.Fatal(again.Body.String()) } var after int64 if err := gdb.Model(&models.Throttle{}).Count(&after).Error; err != nil { t.Fatal(err) } if after != before { t.Fatalf("unknown email created a throttle row (%d -> %d)", before, after) } now := time.Now() ip := "192.0.2.1" throttled := &models.User{Email: "throttled@example.test", Password: hash, IsActivated: true} if err := gdb.Create(throttled).Error; err != nil { t.Fatal(err) } row := models.Throttle{UserID: throttled.ID, IPAddress: &ip, Attempts: 5, IsSuspended: true, SuspendedAt: &now} if err := gdb.Create(&row).Error; err != nil { t.Fatal(err) } blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"throttled@example.test","password":"correct-horse"}`) if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody { t.Fatalf("throttled = %d %s", blocked.Code, blocked.Body.String()) } loc := "en" soft := &models.User{Email: "soft@example.test", Password: hash, IsActivated: true, PreferredLocale: &loc} if err := gdb.Create(soft).Error; err != nil { t.Fatal(err) } if err := gdb.Model(soft).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } mail := &captureMail{} if err := app.Publish[postcard.Mailer](mail); err != nil { t.Fatal(err) } restored := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"soft@example.test","password":"correct-horse"}`) if restored.Code != http.StatusOK { t.Fatalf("restore %d %s", restored.Code, restored.Body.String()) } var againUser models.User if err := gdb.Unscoped().First(&againUser, soft.ID).Error; err != nil { t.Fatal(err) } if againUser.DeletedAt.Valid { t.Fatal("soft-deleted user was not restored") } if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" { t.Fatalf("reactivate template = %#v", mail.got) } } func TestLoginSixthAttempt(t *testing.T) { app, gdb := sessionApp(t, "0s") hash, err := bouncer.HashPassword(10, "correct-horse") if err != nil { t.Fatal(err) } user := &models.User{Email: "sixth@example.test", Password: hash, IsActivated: true} if err := gdb.Create(user).Error; err != nil { t.Fatal(err) } for i := 1; i <= 5; i++ { rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"wrong"}`) if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody { t.Fatalf("attempt %d: %d %s", i, rec.Code, rec.Body.String()) } } var row models.Throttle if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil { t.Fatal(err) } if row.Attempts != 5 || !row.IsSuspended { t.Fatalf("after 5 logins: %+v", row) } blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"correct-horse"}`) if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody { t.Fatalf("6th = %d %s", blocked.Code, blocked.Body.String()) } if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil { t.Fatal(err) } if row.Attempts != 5 { t.Fatalf("6th login recorded another attempt: %+v", row) } } // TestLoginRequiresActivation pins the activation gate of POST /login: // with golem15.user.activation.require_activation on (the default) the // correct password of a not-activated account gets the same 401 // invalid-login answer as a wrong password and counts as a failed attempt, // and an account that is also deactivated stays deactivated. With the key // off, and for activated accounts, login and the deactivation restore work // as before. func TestLoginRequiresActivation(t *testing.T) { hash, err := bouncer.HashPassword(10, "correct-horse") if err != nil { t.Fatal(err) } newApp := func(t *testing.T, require *bool) (*backpack.App, *gorm.DB) { t.Helper() app, gdb := sessionApp(t, "0s") if require != nil { if err := app.Config.Set("golem15.user.activation.require_activation", *require); err != nil { t.Fatal(err) } } return app, gdb } on, off := true, false create := func(t *testing.T, gdb *gorm.DB, email string, activated, deleted bool) *models.User { t.Helper() loc := "en" user := &models.User{Email: email, Password: hash, IsActivated: activated, PreferredLocale: &loc} if err := gdb.Create(user).Error; err != nil { t.Fatal(err) } if deleted { if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil { t.Fatal(err) } } return user } login := func(app *backpack.App, email, password string) *httptest.ResponseRecorder { return postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"`+password+`"}`) } reload := func(t *testing.T, gdb *gorm.DB, id uint) models.User { t.Helper() var u models.User if err := gdb.Unscoped().First(&u, id).Error; err != nil { t.Fatal(err) } return u } attempts := func(t *testing.T, gdb *gorm.DB, id uint) models.Throttle { t.Helper() var row models.Throttle if err := gdb.Where("user_id = ?", id).Take(&row).Error; err != nil { t.Fatal(err) } return row } refused := func(t *testing.T, rec *httptest.ResponseRecorder) { t.Helper() if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody { t.Fatalf("want the invalid-login 401, got %d %s", rec.Code, rec.Body.String()) } } signedIn := func(t *testing.T, rec *httptest.ResponseRecorder, user *models.User) { t.Helper() if rec.Code != http.StatusOK { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } token, _ := body["token"].(string) if token == "" { t.Fatalf("no token in %s", rec.Body.String()) } if sub := parseToken(t, token)["sub"]; sub != strconv.FormatUint(uint64(user.ID), 10) { t.Fatalf("sub = %v", sub) } } mailer := func(t *testing.T, app *backpack.App) *captureMail { t.Helper() mail := &captureMail{} if err := app.Publish[postcard.Mailer](mail); err != nil { t.Fatal(err) } return mail } t.Run("default_refuses", func(t *testing.T) { app, gdb := newApp(t, nil) user := create(t, gdb, "act-default@example.test", false, false) rec := login(app, user.Email, "correct-horse") refused(t, rec) if strings.Contains(rec.Body.String(), "token") { t.Fatalf("refusal carries a token: %s", rec.Body.String()) } if got := reload(t, gdb, user.ID); got.LastSeen != nil { t.Fatalf("refusal stamped last_seen %v", got.LastSeen) } if row := attempts(t, gdb, user.ID); row.Attempts != 1 { t.Fatalf("throttle after refusal: %+v", row) } }) t.Run("same_as_wrong_password", func(t *testing.T) { app, gdb := newApp(t, nil) user := create(t, gdb, "act-same@example.test", false, false) right := login(app, user.Email, "correct-horse") if row := attempts(t, gdb, user.ID); row.Attempts != 1 { t.Fatalf("throttle after the correct password: %+v", row) } wrong := login(app, user.Email, "wrong-horse") if row := attempts(t, gdb, user.ID); row.Attempts != 2 { t.Fatalf("throttle after the wrong password: %+v", row) } refused(t, right) if right.Code != wrong.Code || right.Body.String() != wrong.Body.String() { t.Fatalf("correct %d %s, wrong %d %s", right.Code, right.Body.String(), wrong.Code, wrong.Body.String()) } for _, h := range []string{"Content-Type", "Cache-Control"} { if a, b := right.Header().Get(h), wrong.Header().Get(h); a != b || a == "" { t.Fatalf("%s: correct %q, wrong %q", h, a, b) } } }) t.Run("explicit_true_refuses", func(t *testing.T) { app, gdb := newApp(t, &on) user := create(t, gdb, "act-true@example.test", false, false) refused(t, login(app, user.Email, "correct-horse")) }) t.Run("refusals_suspend", func(t *testing.T) { app, gdb := newApp(t, nil) user := create(t, gdb, "act-suspend@example.test", false, false) for i := 1; i <= 5; i++ { refused(t, login(app, user.Email, "correct-horse")) } if row := attempts(t, gdb, user.ID); row.Attempts != 5 || !row.IsSuspended { t.Fatalf("after 5 refusals: %+v", row) } }) t.Run("off_allows", func(t *testing.T) { app, gdb := newApp(t, &off) user := create(t, gdb, "act-off@example.test", false, false) signedIn(t, login(app, user.Email, "correct-horse"), user) }) t.Run("activated_allows", func(t *testing.T) { app, gdb := newApp(t, nil) user := create(t, gdb, "act-yes@example.test", true, false) signedIn(t, login(app, user.Email, "correct-horse"), user) }) t.Run("deactivated_activated_restores", func(t *testing.T) { app, gdb := newApp(t, nil) user := create(t, gdb, "act-restore@example.test", true, true) mail := mailer(t, app) signedIn(t, login(app, user.Email, "correct-horse"), user) if got := reload(t, gdb, user.ID); got.DeletedAt.Valid { t.Fatal("deactivated activated account was not restored") } if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" { t.Fatalf("reactivate template = %#v", mail.got) } }) t.Run("deactivated_inactive_refused", func(t *testing.T) { app, gdb := newApp(t, nil) user := create(t, gdb, "act-both@example.test", false, true) mail := mailer(t, app) refused(t, login(app, user.Email, "correct-horse")) if got := reload(t, gdb, user.ID); !got.DeletedAt.Valid { t.Fatal("refused account was restored") } if len(mail.got) != 0 { t.Fatalf("refusal sent mail %#v", mail.got) } }) t.Run("deactivated_inactive_off_restores", func(t *testing.T) { app, gdb := newApp(t, &off) user := create(t, gdb, "act-both-off@example.test", false, true) mail := mailer(t, app) signedIn(t, login(app, user.Email, "correct-horse"), user) if got := reload(t, gdb, user.ID); got.DeletedAt.Valid { t.Fatal("account was not restored with require_activation off") } if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" { t.Fatalf("reactivate template = %#v", mail.got) } }) } func TestLogout(t *testing.T) { app, gdb := sessionApp(t, "0s") token := loginToken(t, app, gdb, "logout@example.test") rec := bearer(t, controllers.Logout(app), http.MethodPost, "/_user/api/v1/logout", token) if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Logged out"}` { t.Fatalf("logout %d %s", rec.Code, rec.Body.String()) } fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token) if fetch.Code != http.StatusUnauthorized || fetch.Body.String() != `{"error":true,"message":"Unauthorized"}` { t.Fatalf("fetch after logout %d %s", fetch.Code, fetch.Body.String()) } } func TestFetch(t *testing.T) { app, gdb := sessionApp(t, "0s") token := loginToken(t, app, gdb, "fetch@example.test") ok := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token) if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), `"email":"fetch@example.test"`) { t.Fatalf("fetch %d %s", ok.Code, ok.Body.String()) } missing := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "") if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":true,"message":"Unauthorized"}` { t.Fatalf("missing %d %s", missing.Code, missing.Body.String()) } bad := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "not-a-token") if bad.Body.String() != missing.Body.String() { t.Fatalf("invalid %s", bad.Body.String()) } } func TestRefresh(t *testing.T) { app, gdb := sessionApp(t, "0s") user := insertUser(t, gdb, "refresh@example.test", "correct-horse") sub := strconv.FormatUint(uint64(user.ID), 10) missing := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", "") if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":"Token not found"}` { t.Fatalf("missing %d %s", missing.Code, missing.Body.String()) } iat := time.Now().Add(-2 * time.Hour) old := craftToken(t, sub, "refresh-old", iat, iat.Add(time.Minute)) rec := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", old) if rec.Code != http.StatusOK { t.Fatalf("refresh %d %s", rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } next, _ := body["token"].(string) if next == "" || next == old { t.Fatalf("new token = %q", next) } if parseToken(t, next)["jti"] == "refresh-old" { t.Fatal("refresh reused jti") } bl, ok := app.Lookup[bouncer.BlacklistStore]() if !ok || bl == nil { t.Fatal("blacklist was not published") } blocked, err := bl.IsBlacklisted(t.Context(), "refresh-old") if err != nil || !blocked { t.Fatalf("old jti blacklisted=%v err=%v", blocked, err) } fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", old) if fetch.Code != http.StatusUnauthorized { t.Fatalf("old token still fetches: %d %s", fetch.Code, fetch.Body.String()) } stale := craftToken(t, sub, "refresh-stale", time.Now().Add(-40*24*time.Hour), time.Now().Add(-39*24*time.Hour)) denied := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", stale) if denied.Code != http.StatusUnauthorized || !strings.Contains(denied.Body.String(), `"error":"Could not refresh token"`) || !strings.Contains(denied.Body.String(), `"msg":`) { t.Fatalf("stale %d %s", denied.Code, denied.Body.String()) } } func TestLoginRouteGroup(t *testing.T) { cfg := sessionConfig(t) 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) } app := backpack.New(cfg) p := &Plugin{} if err := p.Boot(app); err != nil { t.Fatal(err) } rt, err := surf.BuildRouter(app, []party.Plugin{p}) if err != nil { t.Fatal(err) } want := map[string]bool{ "POST /_user/api/v1/login": false, "POST /_user/api/v1/logout": false, "GET /_user/api/v1/fetch": false, "POST /_user/api/v1/refresh": false, "POST /_user/api/v1/register": false, "GET /_user/api/v1/oauth-providers": false, } for _, info := range rt.Routes() { key := info.Method + " " + info.Pattern if _, ok := want[key]; !ok { continue } want[key] = true if len(info.Middleware) != 1 || info.Middleware[0] != "throttle:user-api" { t.Fatalf("%s middleware = %#v", key, info.Middleware) } } for key, seen := range want { if !seen { t.Fatalf("missing route %s", key) } } } func TestLoginBlacklistSweep(t *testing.T) { gdb, sqlDB := sessionDB(t) app := newSessionApp(t, gdb, sqlDB) _, stop := bootPlugin(t, app, "50ms") past := time.Now().Add(-time.Minute) if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-me", past, past).Error; err != nil { t.Fatal(err) } deadline := time.Now().Add(2 * time.Second) for { var n int64 if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-me").Scan(&n).Error; err != nil { t.Fatal(err) } if n == 0 { break } if time.Now().After(deadline) { t.Fatal("expired jwt_blacklist row was not swept") } time.Sleep(20 * time.Millisecond) } stop() time.Sleep(150 * time.Millisecond) quiet := newSessionApp(t, gdb, sqlDB) bootPlugin(t, quiet, "0s") later := time.Now().Add(-time.Minute) if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-keep", later, later).Error; err != nil { t.Fatal(err) } time.Sleep(200 * time.Millisecond) var n int64 if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-keep").Scan(&n).Error; err != nil { t.Fatal(err) } if n != 1 { t.Fatal("non-positive sweep interval removed a row") } } type captureMail struct { got []string } func (c *captureMail) Send(_ context.Context, msg postcard.Message) error { c.got = append(c.got, msg.Template) return nil } // loginToken inserts an activated account with the password "correct-horse" // (insertUser) and signs it in through POST /login, returning the token. func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string { t.Helper() insertUser(t, gdb, email, "correct-horse") rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"correct-horse"}`) if rec.Code != http.StatusOK { t.Fatalf("login %s: %d %s", email, rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } token, _ := body["token"].(string) if token == "" { t.Fatal("empty token") } return token } // insertUser creates an activated account with password, an ordinary // account that can sign in through POST /login. func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User { t.Helper() return createUser(t, gdb, email, password, true) } // insertInactiveUser creates a not-activated account with password. While // golem15.user.activation.require_activation is on (the default) POST /login // refuses it. func insertInactiveUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User { t.Helper() return createUser(t, gdb, email, password, false) } func createUser(t *testing.T, gdb *gorm.DB, email, password string, activated bool) *models.User { t.Helper() hash, err := bouncer.HashPassword(10, password) if err != nil { t.Fatal(err) } user := &models.User{Email: email, Password: hash, IsActivated: activated} if err := gdb.Create(user).Error; err != nil { t.Fatal(err) } return user } // inactiveSessionToken inserts a not-activated account (insertInactiveUser) // and mints its session with controllers.IssueToken, the session such an // account gets from /register when require_activation is off. func inactiveSessionToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string { t.Helper() user := insertInactiveUser(t, gdb, email, "correct-horse") req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/login", nil) req.Host = "example.com" token, err := controllers.IssueToken(app, user, req) if err != nil { t.Fatal(err) } return token } func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Host = "example.com" req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h(rec, req) return rec } func bearer(t *testing.T, h http.HandlerFunc, method, path, token string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, nil) req.Host = "example.com" if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() h(rec, req) return rec } func parseToken(t *testing.T, raw string) jwt.MapClaims { t.Helper() claims := jwt.MapClaims{} if _, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) { return []byte(sessionSecret), nil }); err != nil { t.Fatal(err) } return claims } func craftToken(t *testing.T, sub, jti string, iat, exp time.Time) string { t.Helper() claims := jwt.MapClaims{ "sub": sub, "iss": "http://example.com/_user/api/v1/login", "iat": iat.Unix(), "nbf": iat.Unix(), "exp": exp.Unix(), "jti": jti, "prv": prvHash, } signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret)) if err != nil { t.Fatal(err) } return signed } func sessionApp(t *testing.T, sweep string) (*backpack.App, *gorm.DB) { t.Helper() gdb, sqlDB := sessionDB(t) app := backpack.New(sessionConfig(t)) if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil { t.Fatal(err) } if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil { t.Fatal(err) } if sweep != "" { if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil { t.Fatal(err) } } if err := lagoon.Publish(app, sqlDB, gdb); err != nil { t.Fatal(err) } bootPlugin(t, app, sweep) return app, gdb } func newSessionApp(t *testing.T, gdb *gorm.DB, sqlDB *sql.DB) *backpack.App { t.Helper() app := backpack.New(sessionConfig(t)) if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil { t.Fatal(err) } if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil { t.Fatal(err) } if err := lagoon.Publish(app, sqlDB, gdb); err != nil { t.Fatal(err) } return app } func bootPlugin(t *testing.T, app *backpack.App, sweep string) (*Plugin, func()) { t.Helper() if sweep != "" { if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil { t.Fatal(err) } } p := &Plugin{} if err := p.Boot(app); err != nil { t.Fatal(err) } stop := func() {} if p.sweepStop != nil { ch := p.sweepStop var once sync.Once stop = func() { once.Do(func() { close(ch) }) } t.Cleanup(stop) } return p, stop } func sessionConfig(t *testing.T) *compass.Config { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-session\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{ Dir: dir, Environ: []string{ "SUMMER_ENV=development", "SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret, // The plugin registers admin controllers, so assembling its // routes mounts the admin API, which needs its own secret. "SUMMER_ADMIN__JWT__SECRET=" + adminHarnessSecret, }, }) if err != nil { t.Fatal(err) } return cfg } func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) { t.Helper() sessionOnce.Do(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() ctr, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("user_session"), postgres.WithUsername("user_session"), postgres.WithPassword("user_session"), postgres.BasicWaitStrategies(), testcontainers.WithEnv(map[string]string{ "POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8", }), ) if err != nil { sessionErr = err return } dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") if err != nil { sessionErr = err return } sessionDSN = dsn sqlDB, err := sql.Open("pgx", dsn) if err != nil { sessionErr = err return } gdb, err := lagoon.Use(ctx, sqlDB) if err != nil { sessionErr = err return } m := gormigrate.New(gdb, &gormigrate.Options{ TableName: "summer_migrations_golem15_user", UseTransaction: true, }, updates.All()) if err := m.Migrate(); err != nil { sessionErr = err return } files := gormigrate.New(gdb, &gormigrate.Options{ TableName: "summer_migrations_system_files", UseTransaction: true, }, attach.Migrations) if err := files.Migrate(); err != nil { sessionErr = err return } sessionGDB = gdb sessionSQL = sqlDB }) if sessionErr != nil { t.Fatal(sessionErr) } return sessionGDB, sessionSQL }