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.LoginAsync's account-lockout wiring and /// SetClientUserStatusAsync's admin-unlock path — the real fix for the gap DXP shipped with: /// DXP's MDXPUSER.FAILEDLOGINCOUNT increments on every wrong password and a Status==2 check /// exists in code, but no threshold is ever read or enforced, so that check never fires. Here, /// FailedLoginCount increments on a wrong password against an Active account, and once it reaches /// ClientAccountLockoutOptions.FailedLoginThreshold, STATUS flips to Locked in the SAME update /// (verified by capturing the exact (status, count) pair passed to /// UpdateStatusAndFailedLoginCountAsync). /// public class ClientLoginLockoutTests { private const int ClientId = 42; private const string Email = "user@example.com"; private const string CorrectPassword = "Correct-Horse-Battery-Staple"; private static (ClientAuthBLL Svc, Mock ClientUserDal) BuildService(int failedLoginThreshold = 5) { 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(); jwtService.Setup(j => j.HashRefreshToken(It.IsAny())).Returns((string raw) => $"HASH-{raw}"); jwtService.Setup(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new ClientTokenPair { AccessToken = "access-token", AccessTokenExpiresOn = DateTime.UtcNow.AddMinutes(15), RefreshToken = "raw-refresh-token", RefreshTokenHash = "HASH-raw-refresh-token", RefreshTokenExpiresOn = DateTime.UtcNow.AddDays(30) }); refreshTokenDal .Setup(d => d.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); clientUserDal .Setup(d => d.UpdateFailedLoginCountAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); clientUserDal .Setup(d => d.UpdateLastLoginOnAsync(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); clientUserDal .Setup(d => d.UpdateStatusAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); // Real AutoNumber wired to a mocked IQueryExecutor — only exercised on a successful login // (IssueAndPersistTokenPairAsync's refresh-token id allocation). var queryExecutor = new Mock(); queryExecutor .Setup(q => q.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List { 101 }); 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 { FailedLoginThreshold = failedLoginThreshold }); var passwordResetOptions = Options.Create(new ClientPasswordResetOptions()); // Not exercised by LoginAsync/SetClientUserStatusAsync — trivial mocks, no setups needed. var tokenService = new Mock(); var directActionTokenDal = new Mock(); var eventActionRunDal = new Mock(); var actionOutboxDal = new Mock(); // 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); } private static ClientUserDTO ActiveUser(short failedLoginCount = 0) => new() { ClientUserId = 1, ClientId = ClientId, Email = Email, PasswordHash = PasswordHasher.Hash(CorrectPassword), Role = (byte)ClientUserRoleEnum.ClientUser, FailedLoginCount = failedLoginCount, Status = (byte)ClientUserStatusEnum.Active }; private static LoginDTO TestLogin() => new() { ClientId = ClientId, UserId = -1 }; [Fact] public async Task Test_LoginAsync_WrongPassword_BelowThreshold_IncrementsCount_StaysActive() { var (svc, clientUserDal) = BuildService(failedLoginThreshold: 5); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(failedLoginCount: 2)); // this will be the 3rd attempt 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.LoginAsync(ClientId, Email, "wrong-password", TestLogin(), CancellationToken.None); Assert.Equal(ClientLoginResultStatus.Invalid, result.Status); Assert.Equal((short)3, capturedCount); Assert.Equal((byte)ClientUserStatusEnum.Active, capturedStatus); // 3 < threshold(5) — not locked yet } [Fact] public async Task Test_LoginAsync_WrongPassword_ReachesThreshold_SetsLocked_InSameUpdate() { var (svc, clientUserDal) = BuildService(failedLoginThreshold: 5); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(failedLoginCount: 4)); // this will be the 5th attempt — hits the threshold 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.LoginAsync(ClientId, Email, "wrong-password", TestLogin(), CancellationToken.None); Assert.Equal(ClientLoginResultStatus.Invalid, result.Status); Assert.Equal((short)5, capturedCount); // The actual point of this item: STATUS is genuinely flipped to Locked in the SAME UPDATE // as the incremented count — unlike DXP, where nothing ever set Status from this counter. Assert.Equal((byte)ClientUserStatusEnum.Locked, capturedStatus); } [Fact] public async Task Test_LoginAsync_LockedAccount_RejectsEvenWithCorrectPassword_NoCounterChange() { var (svc, clientUserDal) = BuildService(); var lockedUser = ActiveUser(failedLoginCount: 5); lockedUser.Status = (byte)ClientUserStatusEnum.Locked; clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(lockedUser); var result = await svc.LoginAsync(ClientId, Email, CorrectPassword, TestLogin(), CancellationToken.None); Assert.Equal(ClientLoginResultStatus.Invalid, result.Status); Assert.Null(result.TokenPair); // Already locked — nothing to gain from counting this attempt further. clientUserDal.Verify(d => d.UpdateStatusAndFailedLoginCountAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); clientUserDal.Verify(d => d.UpdateFailedLoginCountAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_LoginAsync_SuccessfulLogin_ResetsFailedLoginCountToZero() { var (svc, clientUserDal) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync(ActiveUser(failedLoginCount: 3)); // some prior failures, then a correct login var result = await svc.LoginAsync(ClientId, Email, CorrectPassword, TestLogin(), CancellationToken.None); Assert.Equal(ClientLoginResultStatus.Success, result.Status); Assert.NotNull(result.TokenPair); clientUserDal.Verify(d => d.UpdateFailedLoginCountAsync( 1, 0, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Test_LoginAsync_UnknownEmail_RejectsWithoutAnyCounterUpdate() { var (svc, clientUserDal) = BuildService(); clientUserDal.Setup(d => d.GetByClientAndEmailAsync(ClientId, Email, It.IsAny(), It.IsAny())) .ReturnsAsync((ClientUserDTO?)null); var result = await svc.LoginAsync(ClientId, Email, "whatever", TestLogin(), CancellationToken.None); Assert.Equal(ClientLoginResultStatus.Invalid, result.Status); 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_ReactivatingAccount_ResetsFailedLoginCountToZero() { var (svc, clientUserDal) = BuildService(); byte? capturedStatus = null; short? capturedCount = null; clientUserDal .Setup(d => d.UpdateStatusAndFailedLoginCountAsync(5, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, status, count, _, _, _) => { capturedStatus = status; capturedCount = count; }) .Returns(Task.CompletedTask); await svc.SetClientUserStatusAsync(5, (byte)ClientUserStatusEnum.Active, TestLogin(), CancellationToken.None); Assert.Equal((byte)ClientUserStatusEnum.Active, capturedStatus); Assert.Equal((short)0, capturedCount); } [Fact] public async Task Test_SetClientUserStatusAsync_InvalidStatusValue_Throws() { var (svc, _) = BuildService(); await Assert.ThrowsAsync(() => svc.SetClientUserStatusAsync(5, 99, TestLogin(), CancellationToken.None)); } }