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