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