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);
}
}