package lagoon import ( "os" "path/filepath" "strings" "testing" "time" "git.golem15.com/golem15/summercms/party" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) func TestPhase09MigrationsFreshRollback(t *testing.T) { assertNoAutoMigrate(t) db, _ := dedicatedDB(t, "phase09_fresh") 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 phase09_keep (id BIGSERIAL PRIMARY KEY)`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS phase09_keep`).Error }, }}, } if err := Migrate(gdb, []party.Plugin{plugin}); err != nil { t.Fatal(err) } if !gdb.Migrator().HasTable("system_files") || !gdb.Migrator().HasTable("backend_users") || !gdb.Migrator().HasTable("phase09_keep") { t.Fatal("fresh migrate did not keep framework, admin, and plugin tables together") } before := systemRoles(t, gdb) if len(before) != 2 { t.Fatalf("seeded roles = %v", before) } if err := Migrate(gdb, []party.Plugin{plugin}); err != nil { t.Fatalf("repeated migrate: %v", err) } if after := systemRoles(t, gdb); len(after) != 2 || after["developer"].id != before["developer"].id || after["publisher"].id != before["publisher"].id { t.Fatalf("roles changed on repeat: before=%v after=%v", before, after) } admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) if err != nil { t.Fatal(err) } if err := admin.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("phase09_keep") { t.Fatal("admin rollback removed a 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, ",") == "" || strings.Join(pluginIDs, ",") != "202609240010_keep" { t.Fatalf("histories attach=%v plugin=%v", attachIDs, pluginIDs) } if err := Migrate(gdb, []party.Plugin{plugin}); err != nil { t.Fatalf("migrate after rollback: %v", err) } if !gdb.Migrator().HasTable("backend_users") { t.Fatal("admin tables were not recreated") } if again := systemRoles(t, gdb); len(again) != 2 { t.Fatalf("roles after recreate = %v", again) } } 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 winterRole 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 winterUser 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(&winterUser{}).Error; err == nil { t.Fatal("soft-deleted backend user remained visible") } if err := gdb.Unscoped().Where("login = ?", "ada").First(&winterUser{}).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) } } // winterUser mirrors cabana.BackendUser's column contract without importing // cabana. Cabana commands call lagoon.OpenFromApp, so a lagoon test cannot // import cabana. type winterRole struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` Code string `gorm:"column:code"` IsSystem bool `gorm:"column:is_system"` } func (winterRole) TableName() string { return "backend_user_roles" } type winterUser struct { ID uint `gorm:"column:id;primaryKey"` FirstName string `gorm:"column:first_name"` LastName string `gorm:"column:last_name"` Login string `gorm:"column:login"` Email string `gorm:"column:email"` Password string `gorm:"column:password"` Permissions string `gorm:"column:permissions"` IsActivated bool `gorm:"column:is_activated"` IsSuperuser bool `gorm:"column:is_superuser"` RoleID *uint `gorm:"column:role_id"` LastLogin *time.Time `gorm:"column:last_login"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"` Role winterRole } func (winterUser) TableName() string { return "backend_users" } 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) } } }