- 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
606 lines
21 KiB
Go
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)
|
|
}
|
|
}
|