From 448faa465f666b7f6f8951b3719e01c921929e2a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 17:38:53 +0200 Subject: [PATCH] test(09-02): add failing tests for backend identity migrations - Fresh migrate is missing tokens_valid_after and the admin blacklist table - Reapplying the seed is not idempotent and role codes reject Winter duplicates --- lagoon/backend_admin_migrations_test.go | 343 ++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 lagoon/backend_admin_migrations_test.go diff --git a/lagoon/backend_admin_migrations_test.go b/lagoon/backend_admin_migrations_test.go new file mode 100644 index 0000000..58d1f16 --- /dev/null +++ b/lagoon/backend_admin_migrations_test.go @@ -0,0 +1,343 @@ +package lagoon + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/cabana" + "git.golem15.com/golem15/summercms/party" + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +func TestBackendAdminMigration(t *testing.T) { + assertNoAutoMigrate(t) + db, _ := dedicatedDB(t, "lagoon_admin_mig") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + if err := Migrate(gdb, nil); err != nil { + t.Fatal(err) + } + if !gdb.Migrator().HasTable("system_files") { + t.Fatal("framework attachment migration must still run before backend admin tables") + } + users := columnNullability(t, gdb, "backend_users") + wantUsers := map[string]bool{ + "id": false, "first_name": true, "last_name": true, "login": false, "email": false, + "password": false, "activation_code": true, "persist_code": true, "reset_password_code": true, + "permissions": true, "is_activated": false, "is_superuser": false, "role_id": true, + "activated_at": true, "last_login": true, "created_at": false, "updated_at": false, + "deleted_at": true, "tokens_valid_after": true, + } + assertColumns(t, "backend_users", users, wantUsers) + roles := columnNullability(t, gdb, "backend_user_roles") + wantRoles := map[string]bool{ + "id": false, "name": false, "code": true, "description": true, "permissions": true, + "is_system": false, "created_at": false, "updated_at": false, + } + assertColumns(t, "backend_user_roles", roles, wantRoles) + bl := columnNullability(t, gdb, "backend_jwt_blacklist") + assertColumns(t, "backend_jwt_blacklist", bl, map[string]bool{ + "jti": false, "expires_at": false, "valid_until": false, + }) + for _, name := range []string{"backend_user_groups", "backend_users_groups", "backend_user_preferences", "backend_access_log"} { + if gdb.Migrator().HasTable(name) { + t.Fatalf("prohibited table %s exists", name) + } + } + for _, col := range []string{"activation_code", "reset_password_code", "role_id", "deleted_at", "login", "email"} { + if !indexOn(indexDefs(t, gdb, "backend_users"), col) { + t.Fatalf("backend_users missing index on %s", col) + } + } + if !indexOn(indexDefs(t, gdb, "backend_user_roles"), "code") { + t.Fatal("backend_user_roles missing index on code") + } + var fk string + if err := gdb.Raw(`SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'backend_users'::regclass AND contype = 'f'`).Scan(&fk).Error; err != nil { + t.Fatal(err) + } + if !strings.Contains(fk, "backend_user_roles") { + t.Fatalf("role relationship = %q", fk) + } + if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, created_at, updated_at) VALUES ('ada', 'ada@example.test', 'x', NOW(), NOW())`).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, created_at, updated_at) VALUES ('ada', 'other@example.test', 'x', NOW(), NOW())`).Error; err == nil { + t.Fatal("duplicate login was accepted") + } + if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, created_at, updated_at) VALUES ('ada-2', 'ada@example.test', 'x', NOW(), NOW())`).Error; err == nil { + t.Fatal("duplicate email was accepted") + } +} + +func TestBackendAdminSeed(t *testing.T) { + db, _ := dedicatedDB(t, "lagoon_admin_seed") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + if err := Migrate(gdb, nil); err != nil { + t.Fatal(err) + } + if err := Migrate(gdb, nil); err != nil { + t.Fatalf("repeated migrate: %v", err) + } + before := systemRoles(t, gdb) + if err := gdb.Exec(`DELETE FROM summer_migrations_summercms_cabana`).Error; err != nil { + t.Fatal(err) + } + if err := Migrate(gdb, nil); err != nil { + t.Fatalf("reapplying the admin migration must leave the developer and publisher seeds idempotent: %v", err) + } + after := systemRoles(t, gdb) + if len(before) != 2 || len(after) != 2 { + t.Fatalf("roles before=%v after=%v", before, after) + } + for code, row := range before { + next, ok := after[code] + if !ok || next.id != row.id || !next.system { + t.Fatalf("role %s changed: before=%+v after=%+v", code, row, next) + } + } +} + +func TestBackendAdminRollback(t *testing.T) { + db, _ := dedicatedDB(t, "lagoon_admin_rollback") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + plugin := migPlugin{ + id: "demo.keep", + migrations: []*gormigrate.Migration{{ + ID: "202609240010_keep", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE lagoon_keep (id BIGSERIAL PRIMARY KEY)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS lagoon_keep`).Error + }, + }}, + } + if err := Migrate(gdb, []party.Plugin{plugin}); err != nil { + t.Fatal(err) + } + if !gdb.Migrator().HasTable("backend_jwt_blacklist") { + t.Fatal("blacklist table missing before rollback") + } + m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) + if err != nil { + t.Fatal(err) + } + if err := m.RollbackLast(); err != nil { + t.Fatal(err) + } + for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist"} { + if gdb.Migrator().HasTable(name) { + t.Fatalf("%s survived admin rollback", name) + } + } + if !gdb.Migrator().HasTable("system_files") || !gdb.Migrator().HasTable("lagoon_keep") { + t.Fatal("admin rollback removed another framework or plugin table") + } + var attachIDs, pluginIDs []string + if err := gdb.Table("summer_migrations_summercms_attach").Pluck("id", &attachIDs).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Table("summer_migrations_demo_keep").Pluck("id", &pluginIDs).Error; err != nil { + t.Fatal(err) + } + if strings.Join(attachIDs, ",") != "202609180001_create_system_files" { + t.Fatalf("attach history = %v", attachIDs) + } + if strings.Join(pluginIDs, ",") != "202609240010_keep" { + t.Fatalf("plugin history = %v", pluginIDs) + } +} + +func TestBackendAdminWinterRow(t *testing.T) { + db, _ := dedicatedDB(t, "lagoon_admin_winter") + gdb, err := Use(t.Context(), db) + if err != nil { + t.Fatal(err) + } + if err := Migrate(gdb, nil); err != nil { + t.Fatal(err) + } + var role cabana.BackendUserRole + if err := gdb.Where("code = ?", "developer").First(&role).Error; err != nil { + t.Fatal(err) + } + if role.Name != "Developer" || !role.IsSystem { + t.Fatalf("developer role = %+v", role) + } + if err := gdb.Exec(`INSERT INTO backend_users + (first_name, last_name, login, email, password, activation_code, persist_code, reset_password_code, permissions, is_activated, is_superuser, role_id, activated_at, last_login, created_at, updated_at) + VALUES ('Ada', 'Lovelace', 'ada', 'Ada@Example.Test', 'winter-hash', 'act', 'persist', 'reset', '{"backend.manage_access":1}', TRUE, FALSE, NULL, NULL, NULL, NOW(), NOW())`).Error; err != nil { + t.Fatal(err) + } + var user cabana.BackendUser + if err := gdb.Preload("Role").Where("login = ?", "ada").First(&user).Error; err != nil { + t.Fatal(err) + } + if user.FirstName != "Ada" || user.LastName != "Lovelace" || user.Email != "Ada@Example.Test" || user.Password != "winter-hash" || user.RoleID != nil || user.LastLogin != nil || !user.IsActivated || user.IsSuperuser { + t.Fatalf("winter row = %+v", user) + } + user.FirstName = "Augusta" + if err := gdb.Save(&user).Error; err != nil { + t.Fatal(err) + } + var perms string + if err := gdb.Raw(`SELECT permissions FROM backend_users WHERE login = 'ada'`).Scan(&perms).Error; err != nil { + t.Fatal(err) + } + if perms != `{"backend.manage_access":1}` { + t.Fatalf("permissions rewritten to %q", perms) + } + if err := gdb.Delete(&user).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Where("login = ?", "ada").First(&cabana.BackendUser{}).Error; err == nil { + t.Fatal("soft-deleted backend user remained visible") + } + if err := gdb.Unscoped().Where("login = ?", "ada").First(&cabana.BackendUser{}).Error; err != nil { + t.Fatalf("unscoped load: %v", err) + } + if err := gdb.Exec(`INSERT INTO backend_user_roles (name, code, is_system, created_at, updated_at) VALUES ('Editor A', 'shared', FALSE, NOW(), NOW())`).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Exec(`INSERT INTO backend_user_roles (name, code, is_system, created_at, updated_at) VALUES ('Editor B', 'shared', FALSE, NOW(), NOW())`).Error; err != nil { + t.Fatalf("winter-shaped roles must allow a repeated code so cutover rows load without a schema transform: %v", err) + } +} + +type systemRole struct { + id int + system bool +} + +func systemRoles(t *testing.T, gdb *gorm.DB) map[string]systemRole { + t.Helper() + type row struct { + ID int + Code string + System bool + Name string + Describe string + } + var rows []row + if err := gdb.Raw(`SELECT id, code, is_system AS system, name, description AS describe FROM backend_user_roles WHERE code IN ('developer', 'publisher') ORDER BY code`).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + out := map[string]systemRole{} + for _, row := range rows { + if !row.System { + t.Fatalf("%s is not a system role", row.Code) + } + switch row.Code { + case "developer": + if row.Name != "Developer" || row.Describe != "Site administrator with access to developer tools." { + t.Fatalf("developer seed = %+v", row) + } + case "publisher": + if row.Name != "Publisher" || row.Describe != "Site editor with access to publishing tools." { + t.Fatalf("publisher seed = %+v", row) + } + default: + t.Fatalf("unexpected role %s", row.Code) + } + out[row.Code] = systemRole{id: row.ID, system: row.System} + } + if _, ok := out["developer"]; !ok { + t.Fatal("developer seed missing") + } + if _, ok := out["publisher"]; !ok { + t.Fatal("publisher seed missing") + } + var n int64 + if err := gdb.Raw(`SELECT COUNT(*) FROM backend_user_roles WHERE is_system`).Scan(&n).Error; err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("system roles = %d", n) + } + return out +} + +func columnNullability(t *testing.T, gdb *gorm.DB, table string) map[string]bool { + t.Helper() + type col struct { + Name string + Nullable string + } + var cols []col + if err := gdb.Raw(`SELECT column_name AS name, is_nullable AS nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?`, table).Scan(&cols).Error; err != nil { + t.Fatal(err) + } + if len(cols) == 0 { + t.Fatalf("table %s has no columns", table) + } + out := make(map[string]bool, len(cols)) + for _, c := range cols { + out[c.Name] = c.Nullable == "YES" + } + return out +} + +func assertColumns(t *testing.T, table string, got, want map[string]bool) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s columns = %v, want %v", table, got, want) + } + for name, nullable := range want { + gotNullable, ok := got[name] + if !ok || gotNullable != nullable { + t.Fatalf("%s.%s nullable=%v present=%t, want nullable=%t", table, name, gotNullable, ok, nullable) + } + } +} + +func indexDefs(t *testing.T, gdb *gorm.DB, table string) []string { + t.Helper() + var defs []string + if err := gdb.Raw(`SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = ?`, table).Scan(&defs).Error; err != nil { + t.Fatal(err) + } + return defs +} + +func indexOn(defs []string, column string) bool { + needle := "(" + column + ")" + for _, def := range defs { + if strings.Contains(def, needle) { + return true + } + } + return false +} + +func assertNoAutoMigrate(t *testing.T) { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + body, err := os.ReadFile(filepath.Join(".", name)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "AutoMigrate") { + t.Fatalf("%s uses AutoMigrate", name) + } + } +}