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" "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(), testcontainers.WithEnv(map[string]string{ "POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8", }), ) 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 }