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} 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} 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, 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} 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) } } 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 } 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 } func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User { t.Helper() hash, err := bouncer.HashPassword(10, password) if err != nil { t.Fatal(err) } user := &models.User{Email: email, Password: hash} if err := gdb.Create(user).Error; err != nil { t.Fatal(err) } return user } 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 }