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.
This commit is contained in:
Jakub Zych
2026-10-01 23:12:29 +02:00
parent 5d79f7d0bb
commit 2da8112dbb
4 changed files with 108 additions and 12 deletions

View File

@@ -51,9 +51,7 @@ func TestPhase09MigrationsFreshRollback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := admin.RollbackLast(); 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)
@@ -205,9 +203,7 @@ func TestBackendAdminRollback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); 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)
@@ -443,3 +439,71 @@ func assertNoAutoMigrate(t *testing.T) {
}
}
}
// 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")
}
}