Files
summercms/modules/lagoon/backend_admin_migrations_test.go
Jakub Zych 89611eaaf9 test(cabana): cover My account profile, password and avatar
- account_internal_test: metadata subtitle reader and writer (NULL, blank,
  null, [], invalid JSON, non-objects, key preservation, NULL on the last
  key), the attachOne contract, avatar cap, unique-violation mapping,
  English fallbacks, and 401 for non-backend principals on all three routes
- account_test (Postgres): profile payload incl. copied WinterCMS metadata
  and avatar rows, trimmed profile saves and metadata keys, the forbidden-key
  allow-list with unchanged rows, email rules, password rules, the failed
  attempt log, Bearer and cookie session effects, CSRF, avatar upload,
  refusals (SVG, HTML as PNG, size, part shape, 413, no bucket) with no row
  or blob left, replace and remove with blob cleanup
- lagoon: TestBackendAdminMetadataColumn covers type, Winter row round trip,
  rollback of only the column and re-migrate
- SPA: AccountView, the user menu account item and avatar image,
  setCurrentUser and profileInitials; lang fixture gains myaccount keys
2026-10-09 17:59:27 +02:00

606 lines
21 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, "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] != "202610090001_backend_users_metadata" {
t.Fatalf("cabana history = %v", ids)
}
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610090001_backend_users_metadata" {
t.Fatalf("last BackendAdminMigrations entry = %s", last)
}
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
if 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)
}
}