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