package lagoon import ( "os" "path/filepath" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/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) } rollbackBackendAdmin(t, admin) for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist", "backend_api_tokens"} { 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, "metadata": 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) } rollbackBackendAdmin(t, m) 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) } } } // rollbackBackendAdmin rolls back every BackendAdminMigrations entry, newest // first, so the identity tables are dropped as a set. func rollbackBackendAdmin(t *testing.T, m *gormigrate.Gormigrate) { t.Helper() for range BackendAdminMigrations { if err := m.RollbackLast(); err != nil { t.Fatal(err) } } } func TestBackendAdminEmailCaseInsensitiveUnique(t *testing.T) { db, _ := dedicatedDB(t, "lagoon_admin_email_ci") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } if err := Migrate(gdb, nil); err != nil { t.Fatal(err) } if err := gdb.Exec(`INSERT INTO backend_users (login, email, password) VALUES ('ada', 'Ada@Example.Test', 'x')`).Error; err != nil { t.Fatal(err) } if err := gdb.Exec(`INSERT INTO backend_users (login, email, password) VALUES ('ada-2', 'ada@example.test', 'x')`).Error; err == nil { t.Fatal("an email differing only in case was accepted") } } func TestBackendAdminEmailIndexRefusesCaseDuplicates(t *testing.T) { db, _ := dedicatedDB(t, "lagoon_admin_email_dupes") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } if err := Migrate(gdb, nil); err != nil { t.Fatal(err) } m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) if err != nil { t.Fatal(err) } // Roll back to the identity tables, which drops the email index and the // later metadata column. if err := m.RollbackTo("202609240001_backend_admin_identity"); err != nil { t.Fatal(err) } // A Winter copy can hold emails that differ only in case. for _, row := range [][2]string{{"ada", "Ada@Example.Test"}, {"ada-old", "ada@example.test"}} { if err := gdb.Exec(`INSERT INTO backend_users (login, email, password) VALUES (?, ?, 'x')`, row[0], row[1]).Error; err != nil { t.Fatal(err) } } err = Migrate(gdb, nil) if err == nil { t.Fatal("migrate created the unique email index over case duplicates") } if !strings.Contains(err.Error(), "ada@example.test (logins: ada, ada-old)") { t.Fatalf("error does not name the duplicates: %v", err) } if err := gdb.Exec(`UPDATE backend_users SET email = 'ada.old@example.test' WHERE login = 'ada-old'`).Error; err != nil { t.Fatal(err) } if err := Migrate(gdb, nil); err != nil { t.Fatalf("migrate after resolving duplicates: %v", err) } if !indexOn(indexDefs(t, gdb, "backend_users"), "lower(email)") { t.Fatal("unique lower(email) index missing") } } // TestBackendAdminMetadataColumn covers 202610090001_backend_users_metadata: // WinterCMS's nullable jsonable metadata column, added last, round-tripping a // Winter-shaped row, and a rollback that drops only that column. func TestBackendAdminMetadataColumn(t *testing.T) { db, _ := dedicatedDB(t, "lagoon_admin_metadata") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } if err := Migrate(gdb, nil); err != nil { t.Fatal(err) } columnType := func() (string, string, bool) { var cols []struct { DataType string Nullable string } if err := gdb.Raw(`SELECT data_type, is_nullable AS nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'backend_users' AND column_name = 'metadata'`).Scan(&cols).Error; err != nil { t.Fatal(err) } if len(cols) == 0 { return "", "", false } return cols[0].DataType, cols[0].Nullable, true } if typ, nullable, ok := columnType(); !ok || typ != "text" || nullable != "YES" { t.Fatalf("metadata column type=%q nullable=%q present=%v", typ, nullable, ok) } if err := gdb.Exec(`INSERT INTO backend_users (first_name, last_name, login, email, password, permissions, metadata, is_activated, is_superuser, created_at, updated_at) VALUES ('Ada', 'Lovelace', 'ada', 'ada@example.test', 'winter-hash', '{}', '{"subtitle":"Editor","x":1}', TRUE, FALSE, NOW(), NOW())`).Error; err != nil { t.Fatal(err) } var stored *string if err := gdb.Raw(`SELECT metadata FROM backend_users WHERE login = 'ada'`).Scan(&stored).Error; err != nil { t.Fatal(err) } if stored == nil || *stored != `{"subtitle":"Editor","x":1}` { t.Fatalf("metadata = %v", stored) } history, err := HistoryTableName("summercms.cabana") if err != nil { t.Fatal(err) } var ids []string if err := gdb.Table(history).Order("id").Pluck("id", &ids).Error; err != nil { t.Fatal(err) } if len(ids) == 0 || ids[len(ids)-1] != "202610110001_backend_api_tokens" { t.Fatalf("cabana history = %v", ids) } foundMeta := false for _, id := range ids { if id == "202610090001_backend_users_metadata" { foundMeta = true break } } if !foundMeta { t.Fatalf("cabana history missing metadata migration: %v", ids) } if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610110001_backend_api_tokens" { t.Fatalf("last BackendAdminMigrations entry = %s", last) } m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) if err != nil { t.Fatal(err) } // Newest is backend_api_tokens; roll it back, then metadata, so the // metadata column is gone while the email index remains. if err := m.RollbackLast(); err != nil { t.Fatal(err) } if err := m.RollbackLast(); err != nil { t.Fatal(err) } if _, _, ok := columnType(); ok { t.Fatal("metadata survived its rollback") } var rows []struct { Login string Email string } if err := gdb.Raw(`SELECT login, email FROM backend_users`).Scan(&rows).Error; err != nil { t.Fatal(err) } if len(rows) != 1 || rows[0].Login != "ada" || rows[0].Email != "ada@example.test" { t.Fatalf("rows after rollback = %+v", rows) } if !indexOn(indexDefs(t, gdb, "backend_users"), "lower(email)") { t.Fatal("rolling back metadata dropped the email index") } if err := Migrate(gdb, nil); err != nil { t.Fatal(err) } if typ, _, ok := columnType(); !ok || typ != "text" { t.Fatalf("metadata was not added back: %q %v", typ, ok) } var restored *string if err := gdb.Raw(`SELECT metadata FROM backend_users WHERE login = 'ada'`).Scan(&restored).Error; err != nil { t.Fatal(err) } if restored != nil { t.Fatalf("re-added metadata = %q, want NULL", *restored) } } func TestBackendAPITokensMigration(t *testing.T) { db, _ := dedicatedDB(t, "lagoon_api_tokens") 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("backend_api_tokens") { t.Fatal("backend_api_tokens missing after migrate") } // Unique token_hash. if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at) VALUES ('tokuser', 'tokuser@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil { t.Fatal(err) } var userID int if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'tokuser'`).Scan(&userID).Error; err != nil { t.Fatal(err) } hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at) VALUES (?, 'a', ?, 'abcd', '["*"]', NOW(), NOW())`, userID, hash).Error; err != nil { t.Fatal(err) } dup := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at) VALUES (?, 'b', ?, 'efgh', '["*"]', NOW(), NOW())`, userID, hash) if dup.Error == nil { t.Fatal("duplicate token_hash must fail") } // FK cascade on hard user delete; revoked_by SET NULL when revoker deleted. if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at) VALUES ('revoker', 'revoker@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil { t.Fatal(err) } var revokerID int if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'revoker'`).Scan(&revokerID).Error; err != nil { t.Fatal(err) } hash2 := "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, revoked_by, created_at, updated_at) VALUES (?, 'c', ?, 'zzzz', '["*"]', ?, NOW(), NOW())`, userID, hash2, revokerID).Error; err != nil { t.Fatal(err) } if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, revokerID).Error; err != nil { t.Fatal(err) } var revokedBy *int if err := gdb.Raw(`SELECT revoked_by FROM backend_api_tokens WHERE token_hash = ?`, hash2).Scan(&revokedBy).Error; err != nil { t.Fatal(err) } if revokedBy != nil { t.Fatalf("revoked_by = %v after revoker delete, want NULL", *revokedBy) } if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, userID).Error; err != nil { t.Fatal(err) } var n int64 if err := gdb.Raw(`SELECT COUNT(*) FROM backend_api_tokens`).Scan(&n).Error; err != nil { t.Fatal(err) } if n != 0 { t.Fatalf("cascade left %d tokens", n) } if !gdb.Migrator().HasTable("backend_users") { t.Fatal("backend_users dropped unexpectedly") } m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations) if err != nil { t.Fatal(err) } if err := m.RollbackLast(); err != nil { t.Fatal(err) } if gdb.Migrator().HasTable("backend_api_tokens") { t.Fatal("rollback did not drop backend_api_tokens") } if !gdb.Migrator().HasTable("backend_users") { t.Fatal("rollback dropped backend_users") } // Re-migrate the rolled-back tokens migration. if err := m.Migrate(); err != nil { t.Fatalf("re-migrate: %v", err) } if !gdb.Migrator().HasTable("backend_api_tokens") { t.Fatal("re-migrate missing backend_api_tokens") } }