using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography; using System.Text; using DXPBLL.Auth; using DXPBLL.Options; using GB5Shared.Auth.Jwt; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Moq; using Xunit; namespace DXPTests; /// /// Covers DXPBLL.Auth.DXPJwtService — now a thin wrapper over the shared /// GB5Shared.Auth.Jwt.IJwtAccessTokenIssuer, mirroring EntitlementBLL.Auth.ClientJwtService's /// identical shape after the JWT-consolidation pass. Replaces the earlier characterization /// version of this file, which tested the pre-refactor implementation (raw IConfiguration, /// IDXPSecretResolver, no IssueTokenPairAsync). The two bugs that version existed to characterize /// (hardcoded AccessTokenExpiresOn, never-populated ReplacedByTokenId) are now covered as /// regression tests in AuthBLLTests.cs, proven fixed. /// public class DXPJwtServiceTests { private const string SigningKey = "test-signing-key-at-least-32-bytes-long-for-hmacsha256!!"; private const string TestIssuer = "GB5-DXP-Test"; private const string TestAudience = "GB5-DXP-Vendor-Test"; private static DXPJwtService BuildService(int accessMinutes = 15, int refreshDays = 30) { var issuer = new JwtAccessTokenIssuer(FakeResolver()); var options = Options.Create(new DXPJwtOptions { Issuer = TestIssuer, Audience = TestAudience, AccessTokenMinutes = accessMinutes, RefreshTokenDays = refreshDays }); return new DXPJwtService(issuer, options); } private static IJwtSigningKeyResolver FakeResolver() { var resolver = new Mock(); resolver.Setup(r => r.GetSigningKeyAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SigningKey); return resolver.Object; } private static DXPAccessTokenClaims TestClaims() => new() { DxpUserId = 42, DxpPartyId = 7, TenantId = 100, DatabaseName = "GB5_TestClient", DatabaseType = 0, LocalPartyId = 55, RoleCode = "VendorGM" }; [Fact] public async Task Test_IssueTokenPairAsync_ProducesJwt_WithExpectedClaimsIssuerAudience() { var service = BuildService(); var pair = await service.IssueTokenPairAsync(TestClaims(), CancellationToken.None); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(pair.AccessToken); Assert.Equal(TestIssuer, jwt.Issuer); Assert.Contains(TestAudience, jwt.Audiences); Assert.Equal("42", jwt.Claims.Single(c => c.Type == DXPClaimTypes.DxpUserId).Value); Assert.Equal("55", jwt.Claims.Single(c => c.Type == DXPClaimTypes.LocalPartyId).Value); Assert.Equal("VendorGM", jwt.Claims.Single(c => c.Type == DXPClaimTypes.RoleCode).Value); } [Fact] public async Task Test_IssueTokenPairAsync_AccessTokenExpiresOn_MatchesJwtExpClaim_NoDrift() { // Regression test for the fixed drift bug — AccessTokenExpiresOn and the JWT's own `exp` // claim now come from the exact same computed value, and both reflect AccessTokenMinutes. var service = BuildService(accessMinutes: 7); var pair = await service.IssueTokenPairAsync(TestClaims(), CancellationToken.None); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(pair.AccessToken); Assert.True(Math.Abs((jwt.ValidTo - pair.AccessTokenExpiresOn).TotalSeconds) < 2); Assert.True(Math.Abs((pair.AccessTokenExpiresOn - DateTime.UtcNow.AddMinutes(7)).TotalSeconds) < 5); } [Fact] public async Task Test_IssueTokenPairAsync_RefreshTokenExpiresOn_ReflectsConfiguredDays_NoDrift() { var service = BuildService(refreshDays: 45); var pair = await service.IssueTokenPairAsync(TestClaims(), CancellationToken.None); Assert.True(Math.Abs((pair.RefreshTokenExpiresOn - DateTime.UtcNow.AddDays(45)).TotalSeconds) < 5); } [Fact] public async Task Test_IssueTokenPairAsync_SignatureVerifies_WithSameSigningKey() { var service = BuildService(); var pair = await service.IssueTokenPairAsync(TestClaims(), CancellationToken.None); var validationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TestIssuer, ValidateAudience = true, ValidAudience = TestAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)), ClockSkew = TimeSpan.FromSeconds(30) }; var principal = new JwtSecurityTokenHandler().ValidateToken(pair.AccessToken, validationParameters, out var validatedToken); Assert.NotNull(principal); Assert.NotNull(validatedToken); } [Fact] public async Task Test_IssueTokenPairAsync_RefreshTokenHash_MatchesSha256OfRawToken() { var service = BuildService(); var pair = await service.IssueTokenPairAsync(TestClaims(), CancellationToken.None); var expectedHash = service.HashRefreshToken(pair.RefreshToken); var recomputed = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(pair.RefreshToken))); Assert.Equal(recomputed, expectedHash); } [Fact] public void Test_GenerateRefreshToken_ProducesUrlSafeToken_AndMatchingHash() { var service = BuildService(); var (raw, hash) = service.GenerateRefreshToken(); Assert.DoesNotContain("+", raw); Assert.DoesNotContain("/", raw); Assert.DoesNotContain("=", raw); Assert.Equal(service.HashRefreshToken(raw), hash); } [Fact] public void Test_GenerateRefreshToken_TwoCalls_ProduceDifferentTokens() { var service = BuildService(); var (raw1, _) = service.GenerateRefreshToken(); var (raw2, _) = service.GenerateRefreshToken(); Assert.NotEqual(raw1, raw2); } [Fact] public async Task Test_IssueAccessTokenAsync_StillWorks_ForIntermediateTokenCase() { // AuthBLL.LoginAsync still uses this narrower method (no refresh token) for the // multi-context "pick a role" intermediate token — confirm it still functions post-refactor. var service = BuildService(); var token = await service.IssueAccessTokenAsync(new DXPAccessTokenClaims { DxpUserId = 1, RoleCode = "" }, CancellationToken.None); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); Assert.Equal("1", jwt.Claims.Single(c => c.Type == DXPClaimTypes.DxpUserId).Value); } }