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
This commit is contained in:
343
lagoon/backend_admin_migrations_test.go
Normal file
343
lagoon/backend_admin_migrations_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user