using System.Data.Common; using DXPBLL.Auth; using DXPDAL.Auth; using DXPDAL.Party; using Dapr.Client; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GB5Library.Qualifier; using GB5Shared.GenerateAutoNumber; using GB5Shared.PubSub.OutBox; using GB5Shared.QueryExecutor; using GB5Shared.WorkFlow.WorkFlowEngine; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace DXPTests; /// /// Covers DXPBLL.Auth.AuthBLL.RefreshAsync/IssueTokensAsync AFTER the JWT-consolidation pass — /// regression tests for the two bugs this project's original characterization tests (see git /// history / the plan tracker's Thread 7) documented before the fix: /// /// 1. AccessTokenExpiresOn/RefreshTokenExpiresOn now come straight from whatever /// IDXPJwtService.IssueTokenPairAsync returned — AuthBLL no longer independently computes or /// hardcodes either value. /// 2. RefreshAsync's rotation now genuinely populates ReplacedByTokenId, and a replayed/reused /// token cascades-revokes its whole forward chain via CascadeRevokeChainAsync — mirroring /// EntitlementBLL.Auth.ClientAuthBLL.CascadeRevokeChainAsync exactly. /// public class AuthBLLTests { private static string HashOf(string rawToken) => $"HASH-{rawToken}"; private static (AuthBLL Svc, Mock AuthDal, Mock PartyDal, Mock JwtService) BuildService() { var authDal = new Mock(); var partyDal = new Mock(); var jwtService = new Mock(); jwtService.Setup(j => j.HashRefreshToken(It.IsAny())) .Returns((string raw) => HashOf(raw)); jwtService.Setup(j => j.GenerateRefreshToken()) .Returns(("new-raw-refresh-token", HashOf("new-raw-refresh-token"))); jwtService.Setup(j => j.IssueAccessTokenAsync(It.IsAny(), It.IsAny())) .ReturnsAsync("new-access-token"); jwtService.Setup(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new DXPTokenPair { AccessToken = "new-access-token", AccessTokenExpiresOn = DateTime.UtcNow.AddMinutes(7), // deliberately NOT 15 — RefreshToken = "new-raw-refresh-token", // proves AuthBLL doesn't RefreshTokenExpiresOn = DateTime.UtcNow.AddDays(45) // hardcode/re-derive either value }); authDal.Setup(d => d.SaveRefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); authDal.Setup(d => d.RevokeRefreshTokenAsync(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 ClientAuthBLLTests' identical 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 { 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); // Not exercised by RefreshAsync/LogoutAsync (only RegisterAsync/GrantUserPartyRoleAsync // call ExecuteSaveAsync) — real instances with trivial/mocked dependencies, same // "not exercised, trivial mock" pattern ClientAuthBLLTests uses for EventLogPublish. var qualifierFacade = new Mock(); var workflowEngine = new Mock(); var outbox = new Mock(); var dapr = new DaprClientBuilder().Build(); var baseEntityAppServiceUser = new BaseEntityAppService( qualifierFacade.Object, workflowEngine.Object, outbox.Object, queryExecutor.Object, dapr); var baseEntityAppServiceRole = new BaseEntityAppService( qualifierFacade.Object, workflowEngine.Object, outbox.Object, queryExecutor.Object, dapr); var svc = new AuthBLL( authDal.Object, partyDal.Object, jwtService.Object, autoNumber, queryExecutor.Object, baseEntityAppServiceUser, baseEntityAppServiceRole); return (svc, authDal, partyDal, jwtService); } private static LoginDTO TestLogin() => new() { ClientId = 42, UserId = -1 }; private static UserPartyRoleDTO TestRole(int dxpUserId, int dxpPartyId) => new() { DxpUserPartyRoleId = 1, DxpUserId = dxpUserId, DxpPartyId = dxpPartyId, RoleCode = "VendorGM", Status = 1 }; private static PartyLinkDTO TestLink(int dxpPartyLinkId) => new() { DxpPartyLinkId = dxpPartyLinkId, TenantId = 100, DatabaseName = "GB5_TestClient", DatabaseType = 0, LocalPartyId = 55, RelationshipType = 1, Status = 1 }; [Fact] public async Task Test_RefreshAsync_ValidUnusedToken_Rotates_AndPopulatesReplacedByTokenId() { var (svc, authDal, partyDal, _) = BuildService(); const string rawToken = "raw-token-A"; var tokenHash = HashOf(rawToken); const int dxpUserId = 5, dxpPartyId = 7, dxpUserPartyRoleId = 1, dxpPartyLinkId = 9; authDal.Setup(d => d.GetRefreshTokenByHashAsync(tokenHash, It.IsAny(), It.IsAny())) .ReturnsAsync(new RefreshTokenDTO { DxpRefreshTokenId = 10, DxpUserId = dxpUserId, DxpUserPartyRoleId = dxpUserPartyRoleId, DxpPartyLinkId = dxpPartyLinkId, TokenHash = tokenHash, IssuedOn = DateTime.UtcNow.AddDays(-1), ExpiresOn = DateTime.UtcNow.AddDays(29), RevokedOn = null }); authDal.Setup(d => d.GetUserPartyRoleAsync(dxpUserPartyRoleId, dxpUserId, It.IsAny(), It.IsAny())) .ReturnsAsync(TestRole(dxpUserId, dxpPartyId)); partyDal.Setup(d => d.GetPartyLinksAsync(dxpPartyId, It.IsAny(), It.IsAny())) .ReturnsAsync(new List { TestLink(dxpPartyLinkId) }); int? capturedReplacedByTokenId = -999; // sentinel distinct from both null and any real id authDal.Setup(d => d.RevokeRefreshTokenAsync(10, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, replacedBy, _, _, _) => capturedReplacedByTokenId = replacedBy) .Returns(Task.CompletedTask); var result = await svc.RefreshAsync(rawToken, deviceInfo: null, TestLogin(), CancellationToken.None); Assert.Equal("new-access-token", result.AccessToken); Assert.Equal("new-raw-refresh-token", result.RefreshToken); // The actual fix: unlike before, ReplacedByTokenId is genuinely populated with the new // token's real DB-assigned id (whatever AutoNumber produced), not left null. Assert.NotNull(capturedReplacedByTokenId); authDal.Verify(d => d.SaveRefreshTokenAsync( It.Is(t => t.DxpRefreshTokenId == capturedReplacedByTokenId && t.DxpUserId == dxpUserId), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Test_IssueTokensAsync_AccessAndRefreshExpiresOn_ComeFromJwtServiceDirectly_NoLongerHardcoded() { // Regression test for the fixed drift bug: AuthBLL now returns EXACTLY what // IDXPJwtService.IssueTokenPairAsync computed (mocked here to 7 minutes / 45 days — // deliberately not the old hardcoded 15/30), proving there is no second, independent // computation left anywhere in AuthBLL. var (svc, authDal, partyDal, _) = BuildService(); const string rawToken = "raw-token-B"; var tokenHash = HashOf(rawToken); const int dxpUserId = 5, dxpPartyId = 7, dxpUserPartyRoleId = 1, dxpPartyLinkId = 9; authDal.Setup(d => d.GetRefreshTokenByHashAsync(tokenHash, It.IsAny(), It.IsAny())) .ReturnsAsync(new RefreshTokenDTO { DxpRefreshTokenId = 10, DxpUserId = dxpUserId, DxpUserPartyRoleId = dxpUserPartyRoleId, DxpPartyLinkId = dxpPartyLinkId, TokenHash = tokenHash, ExpiresOn = DateTime.UtcNow.AddDays(29), RevokedOn = null }); authDal.Setup(d => d.GetUserPartyRoleAsync(dxpUserPartyRoleId, dxpUserId, It.IsAny(), It.IsAny())) .ReturnsAsync(TestRole(dxpUserId, dxpPartyId)); partyDal.Setup(d => d.GetPartyLinksAsync(dxpPartyId, It.IsAny(), It.IsAny())) .ReturnsAsync(new List { TestLink(dxpPartyLinkId) }); var result = await svc.RefreshAsync(rawToken, deviceInfo: null, TestLogin(), CancellationToken.None); Assert.True(Math.Abs((result.AccessTokenExpiresOn - DateTime.UtcNow.AddMinutes(7)).TotalSeconds) < 5); Assert.True(Math.Abs((result.RefreshTokenExpiresOn - DateTime.UtcNow.AddDays(45)).TotalSeconds) < 5); } [Fact] public async Task Test_RefreshAsync_ReplayOfRevokedToken_IsReuseDetected_AndCascadesRevocation() { var (svc, authDal, _, jwtService) = BuildService(); const string rawToken = "raw-token-STOLEN"; var tokenHash = HashOf(rawToken); authDal.Setup(d => d.GetRefreshTokenByHashAsync(tokenHash, It.IsAny(), It.IsAny())) .ReturnsAsync(new RefreshTokenDTO { DxpRefreshTokenId = 10, DxpUserId = 5, DxpUserPartyRoleId = 1, DxpPartyLinkId = 9, TokenHash = tokenHash, ExpiresOn = DateTime.UtcNow.AddDays(28), RevokedOn = DateTime.UtcNow.AddMinutes(-5), // already exchanged once ReplacedByTokenId = 11 }); // Forward chain: 11 -> 12 (both still active), plus 13, an unrelated already-revoked // descendant that must be left alone. var descendants = new List { new() { DxpRefreshTokenId = 11, DxpUserId = 5, RevokedOn = null, ReplacedByTokenId = 12 }, new() { DxpRefreshTokenId = 12, DxpUserId = 5, RevokedOn = null, ReplacedByTokenId = null }, new() { DxpRefreshTokenId = 13, DxpUserId = 5, RevokedOn = DateTime.UtcNow.AddMinutes(-1), ReplacedByTokenId = null } }; authDal.Setup(d => d.GetChainDescendantsAsync(10, It.IsAny(), It.IsAny())) .ReturnsAsync(descendants); await Assert.ThrowsAsync( () => svc.RefreshAsync(rawToken, deviceInfo: null, TestLogin(), CancellationToken.None)); // Every still-active descendant (11, 12) gets cascade-revoked with no ReplacedByTokenId // (killed, not rotated) — the already-revoked one (13) and the presented token itself (10) // are left untouched. authDal.Verify(d => d.RevokeRefreshTokenAsync(11, null, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); authDal.Verify(d => d.RevokeRefreshTokenAsync(12, null, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); authDal.Verify(d => d.RevokeRefreshTokenAsync(13, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); authDal.Verify(d => d.RevokeRefreshTokenAsync(10, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); // No new session handed out on a reuse-detected rejection. jwtService.Verify(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny()), Times.Never); authDal.Verify(d => d.SaveRefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_RefreshAsync_ExpiredToken_ThrowsInvalidOperation() { var (svc, authDal, _, _) = BuildService(); const string rawToken = "raw-token-EXPIRED"; var tokenHash = HashOf(rawToken); authDal.Setup(d => d.GetRefreshTokenByHashAsync(tokenHash, It.IsAny(), It.IsAny())) .ReturnsAsync(new RefreshTokenDTO { DxpRefreshTokenId = 20, DxpUserId = 5, DxpUserPartyRoleId = 1, DxpPartyLinkId = 9, TokenHash = tokenHash, ExpiresOn = DateTime.UtcNow.AddDays(-1), RevokedOn = null }); await Assert.ThrowsAsync( () => svc.RefreshAsync(rawToken, deviceInfo: null, TestLogin(), CancellationToken.None)); } [Fact] public async Task Test_RefreshAsync_UnknownTokenHash_ThrowsInvalidOperation() { var (svc, authDal, _, _) = BuildService(); authDal.Setup(d => d.GetRefreshTokenByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RefreshTokenDTO?)null); await Assert.ThrowsAsync( () => svc.RefreshAsync("never-issued-token", deviceInfo: null, TestLogin(), CancellationToken.None)); } [Fact] public async Task Test_LogoutAsync_UnknownOrAlreadyRevokedToken_IsIdempotent_NoThrow() { var (svc, authDal, _, _) = BuildService(); authDal.Setup(d => d.GetRefreshTokenByHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RefreshTokenDTO?)null); await svc.LogoutAsync("never-issued-token", TestLogin(), CancellationToken.None); authDal.Verify(d => d.RevokeRefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } }