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