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 EntitlementBLL.Auth.ClientAuthBLL.ForgotPasswordAsync/ResetPasswordAsync — the /// forgot/reset-password flow built on the repo's existing signed-link-email mechanism /// (IDirectActionTokenService/IDirectActionTokenDAL + TEVENTACTIONRUN/TACTIONOUTBOX direct-insert, /// mirroring CorrespondenceBLL.QueueDeliveryAsync). ForgotPasswordAsync never reveals whether an /// account was found (queue calls happen or don't — the caller never sees a difference). /// ResetPasswordAsync's rejection paths (bad signature, wrong actionCode, already-used token, /// ClientUserId not found/wrong-tenant/deleted) all collapse to the same InvalidOrExpiredToken /// status. A successful reset both updates the password hash and clears/reactivates lockout state. /// public class ClientPasswordResetTests { private const int ClientId = 42; private const string Email = "user@example.com"; private const string RawToken = "signed-token-abc"; private static (ClientAuthBLL Svc, Mock ClientUserDal, Mock TokenService, Mock DirectActionTokenDal, Mock EventActionRunDal, Mock ActionOutboxDal) 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(); eventActionRunDal .Setup(d => d.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(1001); actionOutboxDal .Setup(d => d.InsertAsync(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.UpdateStatusAndFailedLoginCountAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var queryExecutor = new Mock(); 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 { TokenExpiryMinutes = 30, ResetPasswordBaseUrl = "https://app.example.test/public/reset-password" }); // 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, tokenService, directActionTokenDal, eventActionRunDal, actionOutboxDal); } private static LoginDTO TestLogin() => new() { ClientId = ClientId, UserId = -1, DatabaseName = "TestDb" }; private static ClientUserDTO ActiveUser(int clientUserId = 1, byte status = 1) => new() { ClientUserId = clientUserId, ClientId = ClientId, Email = Email, FullName = "Test User", PasswordHash = PasswordHasher.Hash("old-password"), Role = (byte)ClientUserRoleEnum.ClientUser, Status = status }; // ── ForgotPasswordAsync ────────────────────────────────────────────────────────── [Fact] public async Task Test_ForgotPasswordAsync_UnknownEmail_QueuesNothing() { var (svc, clientUserDal, tokenService, _, eventActionRunDal, actionOutboxDal) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync((ClientUserDTO?)null); await svc.ForgotPasswordAsync(ClientId, Email, TestLogin(), CancellationToken.None); tokenService.Verify(t => t.Generate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); eventActionRunDal.Verify(d => d.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); actionOutboxDal.Verify(d => d.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ForgotPasswordAsync_DeletedAccount_QueuesNothing() { var (svc, clientUserDal, tokenService, _, _, _) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(status: (byte)ClientUserStatusEnum.Deleted)); await svc.ForgotPasswordAsync(ClientId, Email, TestLogin(), CancellationToken.None); tokenService.Verify(t => t.Generate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ForgotPasswordAsync_ActiveAccount_QueuesResetEmail() { var (svc, clientUserDal, tokenService, _, eventActionRunDal, actionOutboxDal) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser()); tokenService.Setup(t => t.Generate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(RawToken); await svc.ForgotPasswordAsync(ClientId, Email, TestLogin(), CancellationToken.None); tokenService.Verify(t => t.Generate("ENTITLEMENT_PWRESET", 1, ClientId, 1, It.IsAny(), TimeSpan.FromMinutes(30)), Times.Once); eventActionRunDal.Verify(d => d.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); actionOutboxDal.Verify(d => d.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Test_ForgotPasswordAsync_LockedAccount_StillQueuesResetEmail_SelfServiceUnlockPath() { // Locked is NOT excluded — forgot-password is the self-service unlock path (see // ResetPasswordAsync's reactivation logic), so a locked account must still be able to // request a reset link. var (svc, clientUserDal, tokenService, _, _, _) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(status: (byte)ClientUserStatusEnum.Locked)); tokenService.Setup(t => t.Generate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(RawToken); await svc.ForgotPasswordAsync(ClientId, Email, TestLogin(), CancellationToken.None); tokenService.Verify(t => t.Generate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } // ── ResetPasswordAsync ─────────────────────────────────────────────────────────── private static void SetupValidToken(Mock tokenService, int contextId = 1, int tenantId = ClientId, string actionCode = "ENTITLEMENT_PWRESET", DateTime? expiresAt = null) { var resolvedExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(29); tokenService .Setup(t => t.TryValidate(RawToken, out It.Ref.IsAny, out It.Ref.IsAny, out It.Ref.IsAny, out It.Ref.IsAny, out It.Ref.IsAny, out It.Ref.IsAny)) .Returns(new TryValidateDelegate((string _, out string ac, out int ctxId, out int tid, out int assignee, out string db, out DateTime exp) => { ac = actionCode; ctxId = contextId; tid = tenantId; assignee = contextId; db = "TestDb"; exp = resolvedExpiresAt; return true; })); } private delegate bool TryValidateDelegate(string token, out string actionCode, out int contextId, out int tenantId, out int assigneeUserId, out string databaseName, out DateTime expiresAt); [Fact] public async Task Test_ResetPasswordAsync_BadSignature_IsInvalid_NoDbCalls() { var (svc, clientUserDal, tokenService, directActionTokenDal, _, _) = BuildService(); // Default Mock TryValidate returns false (out params default). var result = await svc.ResetPasswordAsync(RawToken, "New-Password-123", TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.InvalidOrExpiredToken, result.Status); directActionTokenDal.Verify(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); clientUserDal.Verify(d => d.GetByIdAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ResetPasswordAsync_WrongActionCode_IsInvalid() { var (svc, _, tokenService, directActionTokenDal, _, _) = BuildService(); SetupValidToken(tokenService, actionCode: "SOME_OTHER_ACTION"); var result = await svc.ResetPasswordAsync(RawToken, "New-Password-123", TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.InvalidOrExpiredToken, result.Status); directActionTokenDal.Verify(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ResetPasswordAsync_AlreadyUsedToken_IsInvalid_NoPasswordChange() { var (svc, clientUserDal, tokenService, directActionTokenDal, _, _) = BuildService(); SetupValidToken(tokenService); directActionTokenDal.Setup(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new DirectActionTokenDTO { TokenHash = "already-used-hash", Status = 1 }); var result = await svc.ResetPasswordAsync(RawToken, "New-Password-123", TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.InvalidOrExpiredToken, result.Status); directActionTokenDal.Verify(d => d.InsertUsedAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); clientUserDal.Verify(d => d.UpdatePasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ResetPasswordAsync_ClientUserNotFound_IsInvalid() { var (svc, clientUserDal, tokenService, directActionTokenDal, _, _) = BuildService(); SetupValidToken(tokenService, contextId: 999); directActionTokenDal.Setup(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((DirectActionTokenDTO?)null); clientUserDal.Setup(d => d.GetByIdAsync(999, It.IsAny(), It.IsAny())) .ReturnsAsync((ClientUserDTO?)null); var result = await svc.ResetPasswordAsync(RawToken, "New-Password-123", TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.InvalidOrExpiredToken, result.Status); directActionTokenDal.Verify(d => d.InsertUsedAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ResetPasswordAsync_TenantMismatch_IsInvalid() { var (svc, clientUserDal, tokenService, directActionTokenDal, _, _) = BuildService(); // Token names contextId=1/tenantId=ClientId, but the ClientUser row actually belongs to a // DIFFERENT client — should never happen for a genuine token, but defended against anyway. SetupValidToken(tokenService, contextId: 1, tenantId: ClientId); directActionTokenDal.Setup(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((DirectActionTokenDTO?)null); var mismatchedUser = ActiveUser(clientUserId: 1); mismatchedUser.ClientId = ClientId + 1; clientUserDal.Setup(d => d.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(mismatchedUser); var result = await svc.ResetPasswordAsync(RawToken, "New-Password-123", TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.InvalidOrExpiredToken, result.Status); } [Fact] public async Task Test_ResetPasswordAsync_Success_UpdatesPasswordHash_AndConsumesToken() { var (svc, clientUserDal, tokenService, directActionTokenDal, _, _) = BuildService(); SetupValidToken(tokenService, contextId: 1); directActionTokenDal.Setup(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((DirectActionTokenDTO?)null); clientUserDal.Setup(d => d.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(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-456"; var result = await svc.ResetPasswordAsync(RawToken, newPassword, TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.Success, result.Status); Assert.NotNull(capturedHash); Assert.True(PasswordHasher.Verify(newPassword, capturedHash!)); directActionTokenDal.Verify(d => d.InsertUsedAsync( It.Is(t => t.ContextId == 1 && t.ActionCode == "ENTITLEMENT_PWRESET" && t.Status == 1), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Test_ResetPasswordAsync_Success_OnLockedAccount_ReactivatesAndClearsFailedLoginCount() { var (svc, clientUserDal, tokenService, directActionTokenDal, _, _) = BuildService(); SetupValidToken(tokenService, contextId: 1); directActionTokenDal.Setup(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((DirectActionTokenDTO?)null); clientUserDal.Setup(d => d.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(clientUserId: 1, status: (byte)ClientUserStatusEnum.Locked)); byte? capturedStatus = null; short? capturedCount = null; clientUserDal .Setup(d => d.UpdateStatusAndFailedLoginCountAsync(1, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, status, count, _, _, _) => { capturedStatus = status; capturedCount = count; }) .Returns(Task.CompletedTask); var result = await svc.ResetPasswordAsync(RawToken, "Brand-New-Password-456", TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.Success, result.Status); Assert.Equal((byte)ClientUserStatusEnum.Active, capturedStatus); Assert.Equal((short)0, capturedCount); } [Fact] public async Task Test_ResetPasswordAsync_Success_OnActiveAccount_KeepsActive_ClearsFailedLoginCount() { var (svc, clientUserDal, tokenService, directActionTokenDal, _, _) = BuildService(); SetupValidToken(tokenService, contextId: 1); directActionTokenDal.Setup(d => d.GetByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((DirectActionTokenDTO?)null); clientUserDal.Setup(d => d.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(clientUserId: 1, status: (byte)ClientUserStatusEnum.Active)); byte? capturedStatus = null; clientUserDal .Setup(d => d.UpdateStatusAndFailedLoginCountAsync(1, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, status, _, _, _, _) => capturedStatus = status) .Returns(Task.CompletedTask); var result = await svc.ResetPasswordAsync(RawToken, "Brand-New-Password-456", TestLogin(), CancellationToken.None); Assert.Equal(ClientPasswordResetResultStatus.Success, result.Status); Assert.Equal((byte)ClientUserStatusEnum.Active, capturedStatus); } }