using System.Data.Common; using Dapr.Client; using EntitlementBLL.Auth; using EntitlementBLL.Legal; using EntitlementBLL.Options; using EntitlementDAL.DTOs; using EntitlementDAL.Enums; using EntitlementDAL.Interfaces; using GB5Shared.DirectAction; using GB5Shared.DTO.DirectAction; using GB5Shared.ActionProcessor; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.DTO.Framework.Login; using GB5Shared.EncryptionHelper; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace EntitlementTests; /// /// Covers the ClientAuthBLL methods built in item 5 that never got dedicated tests at the time /// (item 5 itself only asked for the endpoints; items 4/6/8 added tests for RefreshAsync/ /// LoginAsync-lockout/forgot-reset-password specifically, leaving these four uncovered): /// CreateClientAdminAsync/CreateClientUserAsync (shared CreateClientUserInternalAsync), LogoutAsync, /// ChangePasswordAsync, and SetClientUserStatusAsync's non-reactivation (Locked/Deleted) branch. /// public class ClientAccountManagementTests { private const int ClientId = 42; private const string Email = "newuser@example.com"; private static (ClientAuthBLL Svc, Mock ClientUserDal, Mock RefreshTokenDal) BuildService() { var clientUserDal = new Mock(); var clientUserRoleDal = new Mock(); clientUserRoleDal .Setup(d => d.GetActiveRoleCodesAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); var refreshTokenDal = new Mock(); var jwtService = new Mock(); var tokenService = new Mock(); var directActionTokenDal = new Mock(); var eventActionRunDal = new Mock(); var actionOutboxDal = new Mock(); clientUserDal .Setup(d => d.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); clientUserDal .Setup(d => d.UpdatePasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); clientUserDal .Setup(d => d.UpdateStatusAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); clientUserDal .Setup(d => d.UpdateStatusAndFailedLoginCountAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); refreshTokenDal .Setup(d => d.RevokeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var queryExecutor = new Mock(); queryExecutor .Setup(q => q.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { 201 }); queryExecutor.Setup(q => q.BeginTransactionAsync(It.IsAny())).ReturnsAsync((DbTransaction)null!); queryExecutor.Setup(q => q.CommitAsync(It.IsAny())).Returns(Task.CompletedTask); queryExecutor.Setup(q => q.RollbackAsync(It.IsAny())).Returns(Task.CompletedTask); var dbConfig = new Mock>(); dbConfig.Setup(o => o.Value).Returns(new Gb5SystemDTO { DataBaseType = 0 }); var autoNumber = new AutoNumber(queryExecutor.Object, dbConfig.Object); var lockoutOptions = Options.Create(new ClientAccountLockoutOptions()); var passwordResetOptions = Options.Create(new ClientPasswordResetOptions()); // EventLogPublish.PublishEventLogAsync is not virtual, so Moq can't intercept it — a real // instance is constructed instead. Its own internal try/catch swallows any failure to // reach a Dapr sidecar (never running in this sandbox) as a logged warning, never a thrown // exception, so this is safe to use as-is in every test. var eventLog = new EventLogPublish(new DaprClientBuilder().Build(), NullLogger.Instance); var svc = new ClientAuthBLL( clientUserDal.Object, clientUserRoleDal.Object, refreshTokenDal.Object, jwtService.Object, autoNumber, queryExecutor.Object, lockoutOptions, tokenService.Object, directActionTokenDal.Object, eventActionRunDal.Object, actionOutboxDal.Object, passwordResetOptions, eventLog, new Mock().Object, new Mock().Object, NullLogger.Instance); return (svc, clientUserDal, refreshTokenDal); } private static LoginDTO TestLogin() => new() { ClientId = ClientId, UserId = -1, DatabaseName = "TestDb" }; private static ClientUserDTO ExistingUser(int clientUserId = 1, byte role = 1, byte status = 1) => new() { ClientUserId = clientUserId, ClientId = ClientId, Email = Email, FullName = "Existing User", PasswordHash = PasswordHasher.Hash("current-password"), Role = role, Status = status }; // ── CreateClientAdminAsync / CreateClientUserAsync ────────────────────────────── [Fact] public async Task Test_CreateClientAdminAsync_NewEmail_CreatesActiveClientAdmin_WithVerifiableTempPassword() { var (svc, clientUserDal, _) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync((ClientUserDTO?)null); ClientUserDTO? savedDto = null; clientUserDal .Setup(d => d.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((dto, _, _, _) => savedDto = dto) .Returns(Task.CompletedTask); var result = await svc.CreateClientAdminAsync(ClientId, Email, "New Admin", TestLogin(), CancellationToken.None); Assert.False(result.AlreadyExists); Assert.NotNull(result.TemporaryPassword); Assert.NotNull(savedDto); Assert.Equal(ClientId, savedDto!.ClientId); Assert.Equal(Email, savedDto.Email); Assert.Equal((byte)ClientUserRoleEnum.ClientAdmin, savedDto.Role); Assert.Equal((byte)ClientUserStatusEnum.Active, savedDto.Status); Assert.Equal(0, savedDto.FailedLoginCount); // The saved hash must actually verify against the plaintext temp password returned — // proves the same value that's handed back to the caller is what got hashed and stored. Assert.True(PasswordHasher.Verify(result.TemporaryPassword!, savedDto.PasswordHash)); } [Fact] public async Task Test_CreateClientUserAsync_NewEmail_CreatesActiveClientUser_NotAdmin() { var (svc, clientUserDal, _) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync((ClientUserDTO?)null); ClientUserDTO? savedDto = null; clientUserDal .Setup(d => d.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((dto, _, _, _) => savedDto = dto) .Returns(Task.CompletedTask); var result = await svc.CreateClientUserAsync(ClientId, Email, "New User", TestLogin(), CancellationToken.None); Assert.False(result.AlreadyExists); Assert.NotNull(savedDto); Assert.Equal((byte)ClientUserRoleEnum.ClientUser, savedDto!.Role); } [Fact] public async Task Test_CreateClientAdminAsync_EmailAlreadyExists_ReturnsAlreadyExists_NoSave() { var (svc, clientUserDal, _) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(ExistingUser(clientUserId: 7)); var result = await svc.CreateClientAdminAsync(ClientId, Email, "Duplicate Admin", TestLogin(), CancellationToken.None); Assert.True(result.AlreadyExists); Assert.Equal(7, result.ClientUserId); Assert.Null(result.TemporaryPassword); clientUserDal.Verify(d => d.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ── LogoutAsync ────────────────────────────────────────────────────────────────── [Fact] public async Task Test_LogoutAsync_UnknownToken_IsIdempotentNoOp() { var (svc, _, refreshTokenDal) = BuildService(); refreshTokenDal.Setup(d => d.GetByTokenHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ClientRefreshTokenDTO?)null); await svc.LogoutAsync("never-issued-token", TestLogin(), CancellationToken.None); refreshTokenDal.Verify(d => d.RevokeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_LogoutAsync_AlreadyRevokedToken_IsIdempotentNoOp() { var (svc, _, refreshTokenDal) = BuildService(); refreshTokenDal.Setup(d => d.GetByTokenHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ClientRefreshTokenDTO { ClientRefreshTokenId = 5, RevokedOn = DateTime.UtcNow.AddMinutes(-1) }); await svc.LogoutAsync("already-used-token", TestLogin(), CancellationToken.None); refreshTokenDal.Verify(d => d.RevokeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_LogoutAsync_ValidActiveToken_RevokesWithNoReplacement() { var (svc, _, refreshTokenDal) = BuildService(); refreshTokenDal.Setup(d => d.GetByTokenHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ClientRefreshTokenDTO { ClientRefreshTokenId = 9, ClientUserId = 1, RevokedOn = null }); await svc.LogoutAsync("valid-active-token", TestLogin(), CancellationToken.None); // Plain revoke, no ReplacedByTokenId — a voluntary logout is not a rotation or a // reuse-triggering event. refreshTokenDal.Verify(d => d.RevokeAsync(9, null, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } // ── ChangePasswordAsync ────────────────────────────────────────────────────────── [Fact] public async Task Test_ChangePasswordAsync_WrongCurrentPassword_RejectsWithNoUpdate() { var (svc, clientUserDal, _) = BuildService(); clientUserDal.Setup(d => d.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(ExistingUser(clientUserId: 1)); var result = await svc.ChangePasswordAsync(1, "totally-wrong-password", "New-Password-123", TestLogin(), CancellationToken.None); Assert.Equal(ClientChangePasswordResultStatus.CurrentPasswordInvalid, result.Status); clientUserDal.Verify(d => d.UpdatePasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ChangePasswordAsync_ClientUserNotFound_RejectsGenerically() { var (svc, clientUserDal, _) = BuildService(); clientUserDal.Setup(d => d.GetByIdAsync(99, It.IsAny(), It.IsAny())) .ReturnsAsync((ClientUserDTO?)null); var result = await svc.ChangePasswordAsync(99, "whatever", "New-Password-123", TestLogin(), CancellationToken.None); Assert.Equal(ClientChangePasswordResultStatus.CurrentPasswordInvalid, result.Status); } [Fact] public async Task Test_ChangePasswordAsync_Success_UpdatesToVerifiableNewHash() { var (svc, clientUserDal, _) = BuildService(); clientUserDal.Setup(d => d.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(ExistingUser(clientUserId: 1)); string? capturedHash = null; clientUserDal .Setup(d => d.UpdatePasswordHashAsync(1, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, hash, _, _, _) => capturedHash = hash) .Returns(Task.CompletedTask); const string newPassword = "Brand-New-Password-789"; var result = await svc.ChangePasswordAsync(1, "current-password", newPassword, TestLogin(), CancellationToken.None); Assert.Equal(ClientChangePasswordResultStatus.Success, result.Status); Assert.NotNull(capturedHash); Assert.True(PasswordHasher.Verify(newPassword, capturedHash!)); } [Fact] public async Task Test_ChangePasswordAsync_NewPasswordTooShort_Throws() { var (svc, _, _) = BuildService(); await Assert.ThrowsAsync(() => svc.ChangePasswordAsync(1, "current-password", "short", TestLogin(), CancellationToken.None)); } // ── SetClientUserStatusAsync — non-reactivation branch ──────────────────────────── [Fact] public async Task Test_SetClientUserStatusAsync_SettingLocked_CallsPlainUpdateStatus_NotCountReset() { var (svc, clientUserDal, _) = BuildService(); await svc.SetClientUserStatusAsync(3, (byte)ClientUserStatusEnum.Locked, TestLogin(), CancellationToken.None); // Locked/Deleted go through the plain UpdateStatusAsync path — only reactivating TO Active // also resets FailedLoginCount (see ResetPasswordAsync/SetClientUserStatusAsync's own // reasoning for why only reactivation needs the reset). clientUserDal.Verify(d => d.UpdateStatusAsync(3, (byte)ClientUserStatusEnum.Locked, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); clientUserDal.Verify(d => d.UpdateStatusAndFailedLoginCountAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_SetClientUserStatusAsync_SettingDeleted_CallsPlainUpdateStatus() { var (svc, clientUserDal, _) = BuildService(); await svc.SetClientUserStatusAsync(3, (byte)ClientUserStatusEnum.Deleted, TestLogin(), CancellationToken.None); clientUserDal.Verify(d => d.UpdateStatusAsync(3, (byte)ClientUserStatusEnum.Deleted, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } }