package test import ( "RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/persistence/database" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "errors" "testing" "time" "gorm.io/gorm" ) func uaPersistedUser(t *testing.T, db *gorm.DB, id uint32, roleID *uint32, verified bool) { t.Helper() row := models.User{ID: id, Username: "member" + string(rune('a'+id)), Email: "member" + string(rune('a'+id)) + "@x.example", Password: "x", IsVerified: verified, RoleID: roleID} if err := db.Create(&row).Error; err != nil { t.Fatalf("user %d: %v", id, err) } } func uaPersistedInvite(t *testing.T, db *gorm.DB, email string, roleID *uint32, accepted bool, expired bool) models.Invite { t.Helper() now := time.Now() row := models.Invite{Email: email, HashedCode: "h", InvitedBy: 1, RoleID: roleID, ExpiresAt: now.Add(time.Hour), CreatedAt: now} if expired { row.ExpiresAt = now.Add(-time.Hour) } if accepted { row.AcceptedAt = &now } if err := db.Create(&row).Error; err != nil { t.Fatalf("invite %s: %v", email, err) } return row } func uaRoleIDPtr(id uint32) *uint32 { return &id } func TestUsersAPIUserRepository(t *testing.T) { t.Run("Create writes role_id and NULL for zero", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) owner := storedRole(t, db, "Owner").ID created, err := repo.Create(user.User{Username: "a", Email: "a@x.example", Password: "x", RoleID: owner}) if err != nil || created.RoleID != owner || roleIDOf(userRow(t, db, created.ID)) != owner { t.Fatalf("unexpected %+v, %v", created, err) } bare, err := repo.Create(user.User{Username: "b", Email: "b@x.example", Password: "x"}) if err != nil || userRow(t, db, bare.ID).RoleID != nil { t.Fatalf("expected a NULL role_id, got %+v, %v", userRow(t, db, bare.ID).RoleID, err) } }) t.Run("UpdateRole is a compare-and-swap", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) admin, writer, owner := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID, storedRole(t, db, "Owner").ID uaPersistedUser(t, db, 5, &admin, true) updated, err := repo.UpdateRole(5, admin, writer) if err != nil || updated.RoleID != writer || updated.TokenVersion != 0 { t.Fatalf("unexpected %+v, %v", updated, err) } if _, err := repo.UpdateRole(5, admin, owner); !errors.Is(err, user.ErrRoleChanged) { t.Fatalf("the second call with the same from must lose, got %v", err) } if roleIDOf(userRow(t, db, 5)) != writer { t.Errorf("the winner must stand") } if _, err := repo.UpdateRole(999, admin, writer); !errors.Is(err, user.ErrUserNotFound) { t.Errorf("expected ErrUserNotFound, got %v", err) } if _, err := repo.UpdateRole(5, writer, 999); !errors.Is(err, role.ErrRoleNotFound) { t.Errorf("expected ErrRoleNotFound, got %v", err) } if roleIDOf(userRow(t, db, 5)) != writer { t.Errorf("a missing role must not be stored") } }) t.Run("UpdateRole swaps a NULL role_id when from is zero", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) writer := storedRole(t, db, "Writer").ID uaPersistedUser(t, db, 5, nil, true) if _, err := repo.UpdateRole(5, 0, writer); err != nil || roleIDOf(userRow(t, db, 5)) != writer { t.Fatalf("unexpected %v", err) } }) t.Run("UpdateRole fails when the database is closed", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) sqlDB, _ := db.DB() _ = sqlDB.Close() if _, err := repo.UpdateRole(5, 1, 2); err == nil { t.Errorf("expected an error") } }) t.Run("TransferOwnership updates both rows", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID uaPersistedUser(t, db, 3, &owner, true) uaPersistedUser(t, db, 5, &admin, true) if err := repo.TransferOwnership(3, 5, owner, admin); err != nil { t.Fatal(err) } if roleIDOf(userRow(t, db, 3)) != admin || roleIDOf(userRow(t, db, 5)) != owner { t.Errorf("roles not swapped") } if err := repo.TransferOwnership(3, 5, owner, admin); !errors.Is(err, user.ErrRoleChanged) { t.Errorf("the second transfer from the same owner must lose, got %v", err) } if roleIDOf(userRow(t, db, 5)) != owner { t.Errorf("exactly one owner must remain") } }) t.Run("TransferOwnership rolls back for an unverified or missing target", func(t *testing.T) { for name, target := range map[string]uint32{"unverified": 12, "missing": 99} { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID uaPersistedUser(t, db, 3, &owner, true) uaPersistedUser(t, db, 12, &admin, false) if err := repo.TransferOwnership(3, target, owner, admin); !errors.Is(err, user.ErrInvalidTransfer) { t.Fatalf("%s: expected ErrInvalidTransfer, got %v", name, err) } if roleIDOf(userRow(t, db, 3)) != owner { t.Errorf("%s: the owner must stay", name) } } }) t.Run("TransferOwnership fails when the database is closed", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) sqlDB, _ := db.DB() _ = sqlDB.Close() if err := repo.TransferOwnership(3, 5, 1, 2); err == nil { t.Errorf("expected an error") } }) } func TestUsersAPIInviteRepository(t *testing.T) { t.Run("invites have a role_id column and keep it through Create and Renew", func(t *testing.T) { db := openMigratedRolesDB(t) if !db.Migrator().HasColumn(&models.Invite{}, "role_id") { t.Fatal("invites.role_id is missing") } repo := persistence.NewInviteRepository(db) writer := storedRole(t, db, "Writer").ID now := time.Now() created, err := repo.Create(invite.Invite{Email: "a@x.example", HashedCode: "h", InvitedBy: 1, RoleID: writer, ExpiresAt: now.Add(time.Hour), CreatedAt: now}) if err != nil || created.RoleID != writer { t.Fatalf("unexpected %+v, %v", created, err) } renewed, err := repo.Renew(created.ID, "h2", now.Add(2*time.Hour), now) if err != nil || renewed.RoleID != writer { t.Fatalf("unexpected %+v, %v", renewed, err) } if all := repo.GetAll(); len(all) != 1 || all[0].RoleID != writer { t.Errorf("unexpected %+v", all) } }) t.Run("AcceptWithUser stores the user's role_id and marks the invite accepted", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewInviteRepository(db) contributor := storedRole(t, db, "Admin").ID row := uaPersistedInvite(t, db, "kim@x.example", &contributor, false, false) created, err := repo.AcceptWithUser(row.ID, time.Now(), user.User{Username: "kim", Email: "kim@x.example", Password: "x", IsVerified: true, RoleID: contributor}) if err != nil || created.RoleID != contributor || roleIDOf(userRow(t, db, created.ID)) != contributor { t.Fatalf("unexpected %+v, %v", created, err) } var stored models.Invite _ = db.First(&stored, row.ID).Error if stored.AcceptedAt == nil { t.Errorf("the invite must be accepted") } }) } type statementCounter struct { statements int } func countStatements(db *gorm.DB) *statementCounter { counter := &statementCounter{} increment := func(*gorm.DB) { counter.statements++ } _ = db.Callback().Query().Before("gorm:query").Register("users_api:count_query", increment) _ = db.Callback().Row().Before("gorm:row").Register("users_api:count_row", increment) _ = db.Callback().Raw().Before("gorm:raw").Register("users_api:count_raw", increment) return counter } func TestUsersAPIRoleRepositoryMembers(t *testing.T) { t.Run("MemberCounts counts users and open invites in two statements", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) writer, admin := storedRole(t, db, "Writer").ID, storedRole(t, db, "Admin").ID uaPersistedUser(t, db, 1, &admin, true) uaPersistedUser(t, db, 2, &admin, false) uaPersistedUser(t, db, 3, &writer, true) uaPersistedUser(t, db, 4, nil, true) uaPersistedInvite(t, db, "p@x.example", &writer, false, false) uaPersistedInvite(t, db, "e@x.example", &writer, false, true) uaPersistedInvite(t, db, "a@x.example", &writer, true, false) uaPersistedInvite(t, db, "n@x.example", nil, false, false) counter := countStatements(db) counts, err := repo.MemberCounts() if err != nil { t.Fatal(err) } if counts[admin] != 2 || counts[writer] != 3 || counts[0] != 0 || len(counts) != 2 { t.Errorf("unexpected counts %v", counts) } if counter.statements > 2 { t.Errorf("expected at most 2 statements, got %d", counter.statements) } }) t.Run("MemberCounts fails when the database is closed", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) sqlDB, _ := db.DB() _ = sqlDB.Close() if _, err := repo.MemberCounts(); err == nil { t.Errorf("expected an error") } }) t.Run("Delete refuses a role with a user or an open invite and accepts an accepted one", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) withUser, _ := repo.Create(role.Role{Name: "With user", Version: 1}) withInvite, _ := repo.Create(role.Role{Name: "With invite", Version: 1}) withHistory, _ := repo.Create(role.Role{Name: "With history", Version: 1}) uaPersistedUser(t, db, 1, &withUser.ID, true) uaPersistedInvite(t, db, "p@x.example", &withInvite.ID, false, true) uaPersistedInvite(t, db, "a@x.example", &withHistory.ID, true, false) if err := repo.Delete(withUser.ID); !errors.Is(err, role.ErrRoleHasMembers) { t.Errorf("a user must block, got %v", err) } if err := repo.Delete(withInvite.ID); !errors.Is(err, role.ErrRoleHasMembers) { t.Errorf("an open invite must block, got %v", err) } if err := repo.Delete(withHistory.ID); err != nil { t.Errorf("an accepted invite must not block, got %v", err) } }) t.Run("ReassignMembers moves users and open invites and returns the sum", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewRoleRepository(db) source, _ := repo.Create(role.Role{Name: "Source", Version: 1}) target, _ := repo.Create(role.Role{Name: "Target", Version: 1}) uaPersistedUser(t, db, 1, &source.ID, true) uaPersistedUser(t, db, 2, &source.ID, true) open := uaPersistedInvite(t, db, "p@x.example", &source.ID, false, false) done := uaPersistedInvite(t, db, "a@x.example", &source.ID, true, false) moved, err := repo.ReassignMembers(source.ID, target.ID) if err != nil || moved != 3 { t.Fatalf("expected 3, got %d, %v", moved, err) } var movedInvite, keptInvite models.Invite _ = db.First(&movedInvite, open.ID).Error _ = db.First(&keptInvite, done.ID).Error if *movedInvite.RoleID != target.ID || *keptInvite.RoleID != source.ID { t.Errorf("only the open invite moves") } if _, err := repo.ReassignMembers(source.ID, 999); !errors.Is(err, role.ErrInvalidReassign) { t.Errorf("expected ErrInvalidReassign, got %v", err) } }) } func addLegacyRoleColumn(t *testing.T, db *gorm.DB) { t.Helper() if err := db.Exec("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT ''").Error; err != nil { t.Fatal(err) } } func setLegacyRole(t *testing.T, db *gorm.DB, id uint32, legacy string) { t.Helper() if err := db.Exec("UPDATE users SET role = ? WHERE id = ?", legacy, id).Error; err != nil { t.Fatal(err) } } func legacyUsersDB(t *testing.T, rows map[uint32]string) *gorm.DB { t.Helper() db := openMigratedRolesDB(t) addLegacyRoleColumn(t, db) for id, legacy := range rows { uaPersistedUser(t, db, id, nil, true) setLegacyRole(t, db, id, legacy) } return db } func TestUsersAPIMigration(t *testing.T) { rows := []struct { name string users map[uint32]string expect map[uint32]string }{ {"owner", map[uint32]string{3: "owner"}, map[uint32]string{3: "Owner"}}, {"admin", map[uint32]string{5: "admin"}, map[uint32]string{5: "Admin"}}, {"empty string, lowest id and any other id", map[uint32]string{2: "", 4: ""}, map[uint32]string{2: "Owner", 4: "Admin"}}, {"something else", map[uint32]string{7: "something-else"}, map[uint32]string{7: "Writer"}}, } for _, row := range rows { t.Run("legacy "+row.name, func(t *testing.T) { db := legacyUsersDB(t, row.users) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } for id, name := range row.expect { if roleIDOf(userRow(t, db, id)) != storedRole(t, db, name).ID { t.Errorf("user %d: expected %s, got %d", id, name, roleIDOf(userRow(t, db, id))) } } if !db.Migrator().HasColumn("users", "role") { t.Errorf("the legacy column must stay") } before := map[uint32]uint32{} for id := range row.users { before[id] = roleIDOf(userRow(t, db, id)) } if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } for id, was := range before { if roleIDOf(userRow(t, db, id)) != was { t.Errorf("user %d changed on the second Migrate", id) } } }) } t.Run("a user that already has a role_id keeps it", func(t *testing.T) { db := legacyUsersDB(t, nil) custom, _ := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1}) uaPersistedUser(t, db, 5, &custom.ID, true) setLegacyRole(t, db, 5, "admin") if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } if roleIDOf(userRow(t, db, 5)) != custom.ID { t.Errorf("the role_id was replaced") } }) t.Run("existing invites without a role get Admin, accepted ones included", func(t *testing.T) { db := openMigratedRolesDB(t) addLegacyRoleColumn(t, db) pending := uaPersistedInvite(t, db, "p@x.example", nil, false, false) done := uaPersistedInvite(t, db, "a@x.example", nil, true, false) writer := storedRole(t, db, "Writer").ID kept := uaPersistedInvite(t, db, "k@x.example", &writer, false, false) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } admin := storedRole(t, db, "Admin").ID for _, id := range []uint32{pending.ID, done.ID} { var stored models.Invite _ = db.First(&stored, id).Error if stored.RoleID == nil || *stored.RoleID != admin { t.Errorf("invite %d must be for Admin", id) } } var stored models.Invite _ = db.First(&stored, kept.ID).Error if *stored.RoleID != writer { t.Errorf("an invite with a role keeps it") } }) t.Run("created_by is attributed to the lowest user holding the Owner role", func(t *testing.T) { db := openMigratedRolesDB(t) owner, admin := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID uaPersistedUser(t, db, 2, &admin, true) uaPersistedUser(t, db, 6, &owner, true) uaPersistedUser(t, db, 8, &owner, true) createArticleRow(t, db, 10, 0) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } if createdByOf(t, db, "articles", 10) != 6 { t.Errorf("expected user 6, got %d", createdByOf(t, db, "articles", 10)) } }) t.Run("a fresh database never has a users.role column and Migrate succeeds with no users", func(t *testing.T) { db := openMigratedRolesDB(t) if db.Migrator().HasColumn("users", "role") { t.Fatal("a fresh database must not have users.role") } if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } }) t.Run("a legacy column with no users migrates", func(t *testing.T) { db := legacyUsersDB(t, nil) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } }) }