Files
summercms/modules/lagoon/backend_admin_migrations_test.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

446 lines
15 KiB
Go

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)
}
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)
}
}
}