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.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.RefreshAsync — the refresh-token rotation flow with
/// REAL reuse detection, unlike DXP's equivalent (MDXPREFRESHTOKEN.ReplacedByTokenId is defined
/// but AuthBLL never populates it and never checks for reuse). Three scenarios per the design:
/// (1) a valid, never-rotated token rotates normally and gets ReplacedByTokenId set; (2) an
/// already-rotated token being presented again (reuse/theft) triggers cascade revocation of every
/// still-active descendant in the ReplacedByTokenId chain and is rejected as ReuseDetected, not a
/// generic Invalid; (3) an expired token that was never rotated is just a plain Invalid rejection
/// — nothing was ever issued from it, so there is no chain to cascade-revoke.
///
public class ClientAuthBLLTests
{
private static string HashOf(string rawToken) => $"HASH-{rawToken}";
private static (ClientAuthBLL Svc, Mock ClientUserDal, Mock RefreshTokenDal,
Mock JwtService) 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();
jwtService.Setup(j => j.HashRefreshToken(It.IsAny()))
.Returns((string raw) => HashOf(raw));
jwtService.Setup(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientTokenPair
{
AccessToken = "new-access-token",
AccessTokenExpiresOn = DateTime.UtcNow.AddMinutes(15),
RefreshToken = "new-raw-refresh-token",
RefreshTokenHash = HashOf("new-raw-refresh-token"),
RefreshTokenExpiresOn = DateTime.UtcNow.AddDays(30)
});
refreshTokenDal
.Setup(d => d.SaveAsync(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);
// Real AutoNumber wired to a mocked IQueryExecutor (AutoNumber.GetNumberAsync isn't
// virtual, so it can't be mocked directly) — mirrors EntitlementBundleTests' pattern.
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());
var passwordResetOptions = Options.Create(new ClientPasswordResetOptions());
// Not exercised by RefreshAsync — 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, refreshTokenDal, jwtService);
}
private static LoginDTO TestLogin() => new() { ClientId = 42, UserId = -1 };
[Fact]
public async Task Test_RefreshAsync_ValidUnusedToken_RotatesAndLinksReplacedByTokenId()
{
var (svc, clientUserDal, refreshTokenDal, jwtService) = BuildService();
const string rawToken = "raw-token-A";
var tokenHash = HashOf(rawToken);
var existingToken = new ClientRefreshTokenDTO
{
ClientRefreshTokenId = 10,
ClientUserId = 1,
TokenHash = tokenHash,
IssuedOn = DateTime.UtcNow.AddDays(-1),
ExpiresOn = DateTime.UtcNow.AddDays(29),
RevokedOn = null,
ReplacedByTokenId = null
};
refreshTokenDal.Setup(d => d.GetByTokenHashAsync(tokenHash, It.IsAny(), It.IsAny()))
.ReturnsAsync(existingToken);
clientUserDal.Setup(d => d.GetByIdAsync(1, It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientUserDTO
{
ClientUserId = 1, ClientId = 42, Role = (byte)ClientUserRoleEnum.ClientAdmin,
Status = (byte)ClientUserStatusEnum.Active
});
int? capturedReplacedByTokenId = null;
refreshTokenDal
.Setup(d => d.RevokeAsync(10, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Callback((_, replacedBy, _, _, _) => capturedReplacedByTokenId = replacedBy)
.Returns(Task.CompletedTask);
var result = await svc.RefreshAsync(rawToken, TestLogin(), CancellationToken.None);
Assert.Equal(ClientRefreshResultStatus.Success, result.Status);
Assert.NotNull(result.TokenPair);
Assert.Equal("new-access-token", result.TokenPair!.AccessToken);
// The actual fix under test: the old token's ReplacedByTokenId is genuinely populated
// with the new token's id — not left null the way DXP's AuthBLL left it.
Assert.NotNull(capturedReplacedByTokenId);
refreshTokenDal.Verify(d => d.RevokeAsync(10, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
refreshTokenDal.Verify(d => d.SaveAsync(
It.Is(t => t.ClientRefreshTokenId == capturedReplacedByTokenId && t.ClientUserId == 1),
It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
refreshTokenDal.Verify(d => d.GetChainDescendantsAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
jwtService.Verify(j => j.IssueTokenPairAsync(
It.Is(c => c.ClientUserId == 1 && c.ClientId == 42 && c.Role == ClientRoleCodes.ClientAdmin),
It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_RefreshAsync_AlreadyRevokedToken_IsReuseDetected_AndCascadesRevocation()
{
var (svc, _, refreshTokenDal, jwtService) = BuildService();
const string rawToken = "raw-token-STOLEN";
var tokenHash = HashOf(rawToken);
// This token was already exchanged once before — it carries a RevokedOn and points
// forward to whatever it was rotated into.
var existingToken = new ClientRefreshTokenDTO
{
ClientRefreshTokenId = 10,
ClientUserId = 1,
TokenHash = tokenHash,
IssuedOn = DateTime.UtcNow.AddDays(-2),
ExpiresOn = DateTime.UtcNow.AddDays(28),
RevokedOn = DateTime.UtcNow.AddMinutes(-5),
ReplacedByTokenId = 11
};
refreshTokenDal.Setup(d => d.GetByTokenHashAsync(tokenHash, It.IsAny(), It.IsAny()))
.ReturnsAsync(existingToken);
// The forward chain: 11 -> 12 (both still active/never revoked), plus 13, an unrelated
// already-revoked descendant that must be left alone (revoking it again is pointless).
var descendants = new List
{
new() { ClientRefreshTokenId = 11, ClientUserId = 1, RevokedOn = null, ReplacedByTokenId = 12 },
new() { ClientRefreshTokenId = 12, ClientUserId = 1, RevokedOn = null, ReplacedByTokenId = null },
new() { ClientRefreshTokenId = 13, ClientUserId = 1, RevokedOn = DateTime.UtcNow.AddMinutes(-1), ReplacedByTokenId = null }
};
refreshTokenDal.Setup(d => d.GetChainDescendantsAsync(10, It.IsAny(), It.IsAny()))
.ReturnsAsync(descendants);
var result = await svc.RefreshAsync(rawToken, TestLogin(), CancellationToken.None);
Assert.Equal(ClientRefreshResultStatus.ReuseDetected, result.Status);
Assert.Null(result.TokenPair);
// Every still-active descendant (11, 12) gets cascade-revoked with no ReplacedByTokenId
// (they were killed, not rotated) — the already-revoked one (13) and the presented token
// itself (10) are left untouched.
refreshTokenDal.Verify(d => d.RevokeAsync(11, null, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
refreshTokenDal.Verify(d => d.RevokeAsync(12, null, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
refreshTokenDal.Verify(d => d.RevokeAsync(13, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
refreshTokenDal.Verify(d => d.RevokeAsync(10, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
// No new session is ever handed out on a reuse-detected rejection.
jwtService.Verify(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny()), Times.Never);
refreshTokenDal.Verify(d => d.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_RefreshAsync_ExpiredNeverRotatedToken_IsPlainRejection_NoCascadeAttempted()
{
var (svc, _, refreshTokenDal, jwtService) = BuildService();
const string rawToken = "raw-token-EXPIRED";
var tokenHash = HashOf(rawToken);
var existingToken = new ClientRefreshTokenDTO
{
ClientRefreshTokenId = 20,
ClientUserId = 1,
TokenHash = tokenHash,
IssuedOn = DateTime.UtcNow.AddDays(-31),
ExpiresOn = DateTime.UtcNow.AddDays(-1), // expired
RevokedOn = null, // never rotated
ReplacedByTokenId = null
};
refreshTokenDal.Setup(d => d.GetByTokenHashAsync(tokenHash, It.IsAny(), It.IsAny()))
.ReturnsAsync(existingToken);
var result = await svc.RefreshAsync(rawToken, TestLogin(), CancellationToken.None);
Assert.Equal(ClientRefreshResultStatus.Invalid, result.Status);
Assert.Null(result.TokenPair);
// Nothing was ever issued from this token, so there is no chain to walk — the cascade
// path must not even be attempted, unlike the reuse-detected scenario.
refreshTokenDal.Verify(d => d.GetChainDescendantsAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
refreshTokenDal.Verify(d => d.RevokeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
jwtService.Verify(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_RefreshAsync_UnknownTokenHash_IsPlainRejection()
{
var (svc, _, refreshTokenDal, _) = BuildService();
refreshTokenDal.Setup(d => d.GetByTokenHashAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync((ClientRefreshTokenDTO?)null);
var result = await svc.RefreshAsync("never-issued-token", TestLogin(), CancellationToken.None);
Assert.Equal(ClientRefreshResultStatus.Invalid, result.Status);
Assert.Null(result.TokenPair);
}
[Fact]
public async Task Test_RefreshAsync_ClientUserNoLongerActive_IsPlainRejection_NoRotation()
{
var (svc, clientUserDal, refreshTokenDal, jwtService) = BuildService();
const string rawToken = "raw-token-LOCKED-USER";
var tokenHash = HashOf(rawToken);
refreshTokenDal.Setup(d => d.GetByTokenHashAsync(tokenHash, It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientRefreshTokenDTO
{
ClientRefreshTokenId = 30, ClientUserId = 2, TokenHash = tokenHash,
ExpiresOn = DateTime.UtcNow.AddDays(29), RevokedOn = null, ReplacedByTokenId = null
});
clientUserDal.Setup(d => d.GetByIdAsync(2, It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientUserDTO { ClientUserId = 2, ClientId = 42, Status = (byte)ClientUserStatusEnum.Locked });
var result = await svc.RefreshAsync(rawToken, TestLogin(), CancellationToken.None);
Assert.Equal(ClientRefreshResultStatus.Invalid, result.Status);
jwtService.Verify(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny()), Times.Never);
refreshTokenDal.Verify(d => d.RevokeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
}