Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Infrastructure/JwtServiceTests.cs

240 lines
9.1 KiB
C#
Raw Normal View History

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, TimeProvider.System);
}
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");
// J-01 (UDT-009): token must NOT contain 'permisos' claim post-UDT-009
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
}
// J-01: token post-UDT-009 does NOT have 'permisos' claim
[Fact]
public void GenerateAccessToken_DoesNotContainPermisosClaim()
{
var usuario = MakeUsuario();
var token = _jwtService.GenerateAccessToken(usuario);
var handler = new JwtSecurityTokenHandler();
var parsed = handler.ReadJwtToken(token);
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
}
// J-02: claims present are sub, jti, name, rol (+ iat/exp/nbf) — no extras
[Fact]
public void GenerateAccessToken_HasExactlyExpectedClaims_NoPermisos()
{
var usuario = MakeUsuario();
var token = _jwtService.GenerateAccessToken(usuario);
var handler = new JwtSecurityTokenHandler();
var parsed = handler.ReadJwtToken(token);
// Must have sub, name, rol, jti
Assert.Contains(parsed.Claims, c => c.Type == "sub");
Assert.Contains(parsed.Claims, c => c.Type == "name");
Assert.Contains(parsed.Claims, c => c.Type == "rol");
Assert.Contains(parsed.Claims, c => c.Type == "jti");
// Must NOT have permisos
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
}
// J-03: MakeUsuario with '["*"]' PermisosJson → token still has no 'permisos' claim
[Fact]
public void GenerateAccessToken_WithLegacyPermisosJson_NoPermisosClaim()
{
// MakeUsuario already uses '[\"*\"]' — this explicitly tests J-03
var usuario = MakeUsuario();
Assert.Equal("[\"*\"]", usuario.PermisosJson); // verify the helper
var token = _jwtService.GenerateAccessToken(usuario);
var handler = new JwtSecurityTokenHandler();
var parsed = handler.ReadJwtToken(token);
// Post-UDT-009: JwtService ignores PermisosJson entirely — no claim emitted
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
}
// 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<JwtSecurityToken>(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, TimeProvider.System);
var tokenFromOtherKey = otherService.GenerateAccessToken(MakeUsuario());
// Validating with the correct key should throw
Assert.Throws<Microsoft.IdentityModel.Tokens.SecurityTokenSignatureKeyNotFoundException>(
() => _jwtService.GetPrincipalFromExpiredToken(tokenFromOtherKey));
}
[Fact]
public void GetPrincipalFromExpiredToken_MalformedToken_Throws()
{
Assert.ThrowsAny<Exception>(
() => _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);
}