using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography; using Microsoft.IdentityModel.Tokens; using SIGCM2.Domain.Entities; using SIGCM2.Infrastructure.Security; namespace SIGCM2.Application.Tests.Infrastructure; public class JwtServiceTests : IDisposable { private readonly RSA _rsa; private readonly JwtOptions _options; private readonly JwtService _jwtService; public JwtServiceTests() { // Generate a test RSA key pair inline (no files needed for unit tests) _rsa = RSA.Create(2048); _options = new JwtOptions { Issuer = "sigcm2.api", Audience = "sigcm2.web", AccessTokenMinutes = 60 }; _jwtService = new JwtService(_rsa, _options); } public void Dispose() => _rsa.Dispose(); // Scenario: generated token uses RS256 algorithm [Fact] public void GenerateAccessToken_UsesRS256Algorithm() { var usuario = MakeUsuario(); var token = _jwtService.GenerateAccessToken(usuario); var handler = new JwtSecurityTokenHandler(); var parsed = handler.ReadJwtToken(token); Assert.Equal("RS256", parsed.Header.Alg); } // Scenario: claims contain expected values [Fact] public void GenerateAccessToken_ContainsExpectedClaims() { var usuario = MakeUsuario(); var token = _jwtService.GenerateAccessToken(usuario); var handler = new JwtSecurityTokenHandler(); var parsed = handler.ReadJwtToken(token); Assert.Equal("1", parsed.Subject); // sub = user ID Assert.Equal("sigcm2.api", parsed.Issuer); // iss Assert.Contains("sigcm2.web", parsed.Audiences); // aud Assert.Contains(parsed.Claims, c => c.Type == "name" && c.Value == "admin"); Assert.Contains(parsed.Claims, c => c.Type == "rol" && c.Value == "admin"); } // Scenario: token is verifiable with the public key [Fact] public void GenerateAccessToken_IsVerifiableWithPublicKey() { var usuario = MakeUsuario(); var token = _jwtService.GenerateAccessToken(usuario); var publicKey = RSA.Create(); publicKey.ImportRSAPublicKey(_rsa.ExportRSAPublicKey(), out _); var validationParams = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new RsaSecurityKey(publicKey), ValidIssuer = "sigcm2.api", ValidAudience = "sigcm2.web", ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; var handler = new JwtSecurityTokenHandler(); var principal = handler.ValidateToken(token, validationParams, out var validatedToken); Assert.NotNull(principal); Assert.IsType(validatedToken); } // Scenario: expiry is 60 minutes from now [Fact] public void GenerateAccessToken_ExpiryIs60MinutesFromNow() { var usuario = MakeUsuario(); var before = DateTime.UtcNow; var token = _jwtService.GenerateAccessToken(usuario); var after = DateTime.UtcNow; var handler = new JwtSecurityTokenHandler(); var parsed = handler.ReadJwtToken(token); var expectedMinExpiry = before.AddMinutes(59).AddSeconds(55); var expectedMaxExpiry = after.AddMinutes(60).AddSeconds(5); Assert.True(parsed.ValidTo >= expectedMinExpiry, $"exp {parsed.ValidTo} < expected min {expectedMinExpiry}"); Assert.True(parsed.ValidTo <= expectedMaxExpiry, $"exp {parsed.ValidTo} > expected max {expectedMaxExpiry}"); } // Triangulation: different user produces token with different sub claim [Fact] public void GenerateAccessToken_DifferentUser_DifferentSubClaim() { var user1 = MakeUsuario(id: 1, username: "admin"); var user2 = MakeUsuario(id: 2, username: "vendedor"); var token1 = _jwtService.GenerateAccessToken(user1); var token2 = _jwtService.GenerateAccessToken(user2); var handler = new JwtSecurityTokenHandler(); var parsed1 = handler.ReadJwtToken(token1); var parsed2 = handler.ReadJwtToken(token2); Assert.NotEqual(parsed1.Subject, parsed2.Subject); Assert.Equal("1", parsed1.Subject); Assert.Equal("2", parsed2.Subject); } // T-040: GetPrincipalFromExpiredToken [Fact] public void GetPrincipalFromExpiredToken_ValidSignatureExpired_ReturnsPrincipal() { // Generate a token that will expire in 1 second, then manually create an expired JWT // using JwtSecurityTokenHandler directly (bypassing the service to control timestamps). var signingKey = new RsaSecurityKey(_rsa); var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256); var past = DateTime.UtcNow.AddHours(-2); var descriptor = new SecurityTokenDescriptor { Subject = new System.Security.Claims.ClaimsIdentity( [new System.Security.Claims.Claim("sub", "1")]), Issuer = "sigcm2.api", Audience = "sigcm2.web", IssuedAt = past, NotBefore = past, Expires = past.AddMinutes(60), // expired 1h ago SigningCredentials = credentials, }; var handler = new JwtSecurityTokenHandler(); var token = handler.CreateToken(descriptor); var expiredToken = handler.WriteToken(token); // Now use the service — it must validate the signature but ignore expiry var principal = _jwtService.GetPrincipalFromExpiredToken(expiredToken); Assert.NotNull(principal); // The JWT handler maps "sub" to ClaimTypes.NameIdentifier by default, // but our JwtService uses a custom "sub" claim. Check both. var sub = principal.FindFirst("sub")?.Value ?? principal.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; Assert.Equal("1", sub); } [Fact] public void GetPrincipalFromExpiredToken_InvalidSignature_Throws() { // Sign with a different RSA key using var otherRsa = System.Security.Cryptography.RSA.Create(2048); var otherOptions = new JwtOptions { Issuer = "sigcm2.api", Audience = "sigcm2.web", AccessTokenMinutes = 60 }; var otherService = new JwtService(otherRsa, otherOptions); var tokenFromOtherKey = otherService.GenerateAccessToken(MakeUsuario()); // Validating with the correct key should throw Assert.Throws( () => _jwtService.GetPrincipalFromExpiredToken(tokenFromOtherKey)); } [Fact] public void GetPrincipalFromExpiredToken_MalformedToken_Throws() { Assert.ThrowsAny( () => _jwtService.GetPrincipalFromExpiredToken("not.a.valid.jwt")); } private static Usuario MakeUsuario(int id = 1, string username = "admin") => new(id, username, "$2a$12$hash", "Administrador", "Sistema", null, "admin", "[\"*\"]", true); }