Files
summercms/modules/lagoon/backend_admin_migrations_test.go
Jakub Zych 2da8112dbb fix(09): WR-11 enforce case-insensitive unique backend user emails
Add a backend admin migration that creates a unique index on
lower(backend_users.email). Rows copied from WinterCMS may hold emails
that differ only in case, so the migration refuses to run and names the
clashing logins instead of choosing an account to drop.
2026-10-01 23:12:29 +02:00

510 lines
17 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)
}
rollbackBackendAdmin(t, admin)
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)
}
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)
}
if err := m.RollbackLast(); 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")
}
}