Pin bouncer Scope serialization, party Activated order, lagoon migration, compile/overlap/mint/expiry/validation, guard/debounce/resolver, interactive-only, account token API and CLI on Postgres. Co-authored-by: Cursor <cursoragent@cursor.com>
712 lines
24 KiB
Go
712 lines
24 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", "backend_api_tokens"} {
|
|
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] != "202610110001_backend_api_tokens" {
|
|
t.Fatalf("cabana history = %v", ids)
|
|
}
|
|
foundMeta := false
|
|
for _, id := range ids {
|
|
if id == "202610090001_backend_users_metadata" {
|
|
foundMeta = true
|
|
break
|
|
}
|
|
}
|
|
if !foundMeta {
|
|
t.Fatalf("cabana history missing metadata migration: %v", ids)
|
|
}
|
|
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610110001_backend_api_tokens" {
|
|
t.Fatalf("last BackendAdminMigrations entry = %s", last)
|
|
}
|
|
|
|
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Newest is backend_api_tokens; roll it back, then metadata, so the
|
|
// metadata column is gone while the email index remains.
|
|
if err := m.RollbackLast(); 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)
|
|
}
|
|
}
|
|
|
|
func TestBackendAPITokensMigration(t *testing.T) {
|
|
db, _ := dedicatedDB(t, "lagoon_api_tokens")
|
|
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("backend_api_tokens") {
|
|
t.Fatal("backend_api_tokens missing after migrate")
|
|
}
|
|
// Unique token_hash.
|
|
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at)
|
|
VALUES ('tokuser', 'tokuser@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var userID int
|
|
if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'tokuser'`).Scan(&userID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at)
|
|
VALUES (?, 'a', ?, 'abcd', '["*"]', NOW(), NOW())`, userID, hash).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dup := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at)
|
|
VALUES (?, 'b', ?, 'efgh', '["*"]', NOW(), NOW())`, userID, hash)
|
|
if dup.Error == nil {
|
|
t.Fatal("duplicate token_hash must fail")
|
|
}
|
|
// FK cascade on hard user delete; revoked_by SET NULL when revoker deleted.
|
|
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at)
|
|
VALUES ('revoker', 'revoker@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var revokerID int
|
|
if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'revoker'`).Scan(&revokerID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash2 := "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, revoked_by, created_at, updated_at)
|
|
VALUES (?, 'c', ?, 'zzzz', '["*"]', ?, NOW(), NOW())`, userID, hash2, revokerID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, revokerID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var revokedBy *int
|
|
if err := gdb.Raw(`SELECT revoked_by FROM backend_api_tokens WHERE token_hash = ?`, hash2).Scan(&revokedBy).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if revokedBy != nil {
|
|
t.Fatalf("revoked_by = %v after revoker delete, want NULL", *revokedBy)
|
|
}
|
|
if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, userID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var n int64
|
|
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_api_tokens`).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("cascade left %d tokens", n)
|
|
}
|
|
if !gdb.Migrator().HasTable("backend_users") {
|
|
t.Fatal("backend_users dropped unexpectedly")
|
|
}
|
|
|
|
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.RollbackLast(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gdb.Migrator().HasTable("backend_api_tokens") {
|
|
t.Fatal("rollback did not drop backend_api_tokens")
|
|
}
|
|
if !gdb.Migrator().HasTable("backend_users") {
|
|
t.Fatal("rollback dropped backend_users")
|
|
}
|
|
// Re-migrate the rolled-back tokens migration.
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("re-migrate: %v", err)
|
|
}
|
|
if !gdb.Migrator().HasTable("backend_api_tokens") {
|
|
t.Fatal("re-migrate missing backend_api_tokens")
|
|
}
|
|
}
|