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" "RenewCMS/internal/infrastructure/useCases" "errors" "sync" "testing" "time" ) func TestUsersAPIOwnerVerificationUseCase(t *testing.T) { newRepo := func() *mockUserRepo { repo := newMockUserRepo() for _, id := range []uint32{1, 2} { repo.users[id] = user.User{ID: id, RoleID: ownerRoleID, VerificationCode: "hashed:code", VerificationExpiration: time.Now().Add(time.Hour)} } return repo } t.Run("the second Owner cannot be verified while a verified Owner exists", func(t *testing.T) { repo := newRepo() verifier := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder) if _, err := verifier.Verify(testCtx, 1, "code"); err != nil { t.Fatal(err) } if _, err := verifier.Verify(testCtx, 2, "code"); !errors.Is(err, user.ErrInvalidVerificationCode) { t.Fatalf("expected ErrInvalidVerificationCode, got %v", err) } if repo.users[2].IsVerified || !repo.users[1].IsVerified { t.Errorf("exactly the first Owner must be verified") } }) t.Run("a rival verified in between wins and the late one is refused", func(t *testing.T) { repo := newRepo() verifier := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder) repo.beforeClearVerification = func() { rival := repo.users[2] rival.IsVerified = true repo.users[2] = rival } if _, err := verifier.Verify(testCtx, 1, "code"); !errors.Is(err, user.ErrInvalidVerificationCode) { t.Fatalf("expected ErrInvalidVerificationCode, got %v", err) } if repo.users[1].IsVerified { t.Errorf("the late verification must not stand") } }) } func TestUsersAPIInviteRoleLockedUseCases(t *testing.T) { t.Run("accepting an invite that points at the Owner role is invalid", func(t *testing.T) { w := newUsersAPIWorld(t) pending := w.invites.invites[3] pending.RoleID = w.id("Owner") w.invites.invites[3] = pending users := len(w.users.users) _, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) if !errors.Is(err, invite.ErrInviteInvalid) { t.Fatalf("expected ErrInviteInvalid, got %v", err) } if len(w.users.users) != users || w.invites.invites[3].AcceptedAt != nil { t.Errorf("no user may be created and the invite stays open") } }) t.Run("renewing an invite that points at the Owner role is refused", func(t *testing.T) { w := newUsersAPIWorld(t) pending := w.invites.invites[3] pending.RoleID = w.id("Owner") w.invites.invites[3] = pending _, err := w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(uaOwner), ID: 3, RenewedBy: uaOwner}) if !errors.Is(err, role.ErrRoleLocked) { t.Fatalf("expected ErrRoleLocked, got %v", err) } if w.invites.invites[3].HashedCode != pending.HashedCode || len(w.mail.SentEmails) != 0 { t.Errorf("nothing may be renewed or mailed") } }) t.Run("renewing an invite whose role was deleted is an unknown role", func(t *testing.T) { w := newUsersAPIWorld(t) pending := w.invites.invites[3] pending.RoleID = 99 w.invites.invites[3] = pending _, err := w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(uaAdmin), ID: 3, RenewedBy: uaAdmin}) if !errors.Is(err, role.ErrUnknownRole) { t.Fatalf("expected ErrUnknownRole, got %v", err) } }) } func TestUsersAPIOwnerVerificationRepository(t *testing.T) { t.Run("the second Owner is refused and stays unverified", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) owner := storedRole(t, db, "Owner").ID uaPersistedUser(t, db, 1, &owner, false) uaPersistedUser(t, db, 2, &owner, false) if _, err := repo.ClearVerificationCode(1); err != nil { t.Fatal(err) } if _, err := repo.ClearVerificationCode(2); !errors.Is(err, user.ErrInvalidVerificationCode) { t.Fatalf("expected ErrInvalidVerificationCode, got %v", err) } if userRow(t, db, 2).IsVerified || !userRow(t, db, 1).IsVerified { t.Errorf("exactly one Owner must be verified") } }) t.Run("other roles verify freely and a missing user is not found", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) admin := storedRole(t, db, "Admin").ID uaPersistedUser(t, db, 1, &admin, true) uaPersistedUser(t, db, 2, &admin, false) uaPersistedUser(t, db, 3, nil, false) for _, id := range []uint32{2, 3} { if _, err := repo.ClearVerificationCode(id); err != nil || !userRow(t, db, id).IsVerified { t.Fatalf("user %d: %v", id, err) } } if _, err := repo.ClearVerificationCode(99); !errors.Is(err, user.ErrUserNotFound) { t.Errorf("expected ErrUserNotFound, got %v", err) } }) t.Run("a verified Owner can be verified again", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) owner := storedRole(t, db, "Owner").ID uaPersistedUser(t, db, 1, &owner, true) if _, err := repo.ClearVerificationCode(1); err != nil { t.Fatal(err) } }) t.Run("two concurrent verifications leave one winner", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) owner := storedRole(t, db, "Owner").ID uaPersistedUser(t, db, 1, &owner, false) uaPersistedUser(t, db, 2, &owner, false) var group sync.WaitGroup for _, id := range []uint32{1, 2} { group.Add(1) go func() { defer group.Done() _, _ = repo.ClearVerificationCode(id) }() } group.Wait() var verified int64 if err := db.Model(&models.User{}).Where("role_id = ? AND is_verified = ?", owner, true).Count(&verified).Error; err != nil { t.Fatal(err) } if verified != 1 { t.Errorf("expected exactly one verified Owner, got %d", verified) } }) t.Run("a closed database fails", func(t *testing.T) { db := openMigratedRolesDB(t) repo := persistence.NewUserRepository(db) sqlDB, _ := db.DB() _ = sqlDB.Close() if _, err := repo.ClearVerificationCode(1); err == nil { t.Errorf("expected an error") } }) } func TestUsersAPITransferToSelf(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) if err := repo.TransferOwnership(3, 3, owner, admin); !errors.Is(err, user.ErrInvalidTransfer) { t.Fatalf("expected ErrInvalidTransfer, got %v", err) } if roleIDOf(userRow(t, db, 3)) != owner { t.Errorf("the owner must stay") } } func TestUsersAPIMigrationRerun(t *testing.T) { t.Run("several legacy owners keep the lowest id as Owner and the rest become Admin", func(t *testing.T) { db := legacyUsersDB(t, map[uint32]string{4: "owner", 2: "owner", 9: "owner", 3: "admin"}) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } expect := map[uint32]string{2: "Owner", 4: "Admin", 9: "Admin", 3: "Admin"} for id, name := range expect { if roleIDOf(userRow(t, db, id)) != storedRole(t, db, name).ID { t.Errorf("user %d: expected %s", id, name) } } }) t.Run("a legacy owner is not displaced by a lower empty-string user", func(t *testing.T) { db := legacyUsersDB(t, map[uint32]string{1: "", 5: "owner"}) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } if roleIDOf(userRow(t, db, 5)) != storedRole(t, db, "Owner").ID || roleIDOf(userRow(t, db, 1)) != storedRole(t, db, "Admin").ID { t.Errorf("exactly one Owner is expected, user 5") } }) t.Run("users and invites created after the upgrade are never promoted by a later Migrate", func(t *testing.T) { db := legacyUsersDB(t, map[uint32]string{2: "owner"}) pending := uaPersistedInvite(t, db, "legacy@x.example", nil, false, false) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } uaPersistedUser(t, db, 20, nil, true) late := uaPersistedInvite(t, db, "late@x.example", nil, false, false) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } if userRow(t, db, 20).RoleID != nil { t.Errorf("the post-upgrade user must stay unassigned") } var stored models.Invite if err := db.First(&stored, late.ID).Error; err != nil || stored.RoleID != nil { t.Errorf("the post-upgrade invite must stay unassigned, got %v %v", stored.RoleID, err) } var legacy models.Invite if err := db.First(&legacy, pending.ID).Error; err != nil || legacy.RoleID == nil || *legacy.RoleID != storedRole(t, db, "Admin").ID { t.Errorf("the legacy invite must be Admin, got %v %v", legacy.RoleID, err) } }) t.Run("without the legacy column nothing is assigned", func(t *testing.T) { db := openMigratedRolesDB(t) uaPersistedUser(t, db, 20, nil, true) uaPersistedInvite(t, db, "late@x.example", nil, false, false) if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatal(err) } if userRow(t, db, 20).RoleID != nil { t.Errorf("the user must stay unassigned") } var stored models.Invite _ = db.Where("email = ?", "late@x.example").First(&stored).Error if stored.RoleID != nil { t.Errorf("the invite must stay unassigned") } }) } type usersAPIGetAllHookRepo struct { *mockUserRepo hook func() } func (r *usersAPIGetAllHookRepo) GetAll() ([]user.User, error) { if r.hook != nil { hook := r.hook r.hook = nil hook() } return r.mockUserRepo.GetAll() } func TestUsersAPIAcceptInviteRace(t *testing.T) { w := newUsersAPIWorld(t) pending := w.invites.invites[3] w.users.users[200] = user.User{ID: 200, Username: "taken", Email: pending.Email, IsVerified: true, RoleID: writerRoleID} hooked := &usersAPIGetAllHookRepo{mockUserRepo: w.users} hooked.hook = func() { accepted := time.Now() raced := w.invites.invites[3] raced.AcceptedAt = &accepted w.invites.invites[3] = raced } accept := useCases.NewAcceptInviteUseCase(hooked, w.invites, w.roles, &stubHasher{}, w.recorder) _, err := accept.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) if !errors.Is(err, invite.ErrInviteInvalid) { t.Fatalf("a concurrently accepted invite must surface its own refusal, got %v", err) } } func TestUsersAPIRepositoryFailuresUseCases(t *testing.T) { t.Run("a failing successor lookup is not an invalid transfer", func(t *testing.T) { w := newUsersAPIWorld(t) w.users.fail.failNext("Get") _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, useCases.TransferOwnershipCommand{Caller: w.caller(uaOwner), UserID: uaAdmin, ConfirmEmail: "jonas@renew.example"}) if !errors.Is(err, errRepositoryFailure) { t.Fatalf("expected the repository failure, got %v", err) } }) t.Run("a failing content type listing stops a role change", func(t *testing.T) { w := newUsersAPIWorld(t) w.types.fail.failNext("List") _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: uaWriter, RoleID: w.id("Contributor")}) if !errors.Is(err, errRepositoryFailure) { t.Fatalf("expected the repository failure, got %v", err) } if w.users.users[uaWriter].RoleID != writerRoleID { t.Errorf("the role must not change") } }) }