using System.Security.Claims; using NSubstitute; using NSubstitute.ExceptionExtensions; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; using SIGCM2.Application.Audit; using SIGCM2.Application.Auth; using SIGCM2.Application.Auth.Refresh; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Security; namespace SIGCM2.Application.Tests.Auth.Refresh; public class RefreshCommandHandlerTests { private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); private readonly IUsuarioRepository _usuarioRepo = Substitute.For(); private readonly IJwtService _jwtService = Substitute.For(); private readonly IRefreshTokenGenerator _generator = Substitute.For(); private readonly IClientContext _clientCtx = Substitute.For(); private readonly ISecurityEventLogger _security = Substitute.For(); private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 }; private readonly RefreshCommandHandler _handler; private static readonly Usuario ActiveUsuario = new( id: 1, username: "admin", passwordHash: "$2a$12$hash", nombre: "Admin", apellido: "Sys", email: null, rol: "admin", permisosJson: "[\"*\"]", activo: true); public RefreshCommandHandlerTests() { _clientCtx.Ip.Returns("127.0.0.1"); _clientCtx.UserAgent.Returns("TestAgent/1.0"); _generator.Generate().Returns("new_raw_token_value_xyz"); _handler = new RefreshCommandHandler( _refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions, _security, TimeProvider.System); } // Helper: build an active stored RefreshToken with a matching principal private (RefreshToken stored, string rawToken, ClaimsPrincipal principal) MakeActiveToken( int usuarioId = 1, bool expired = false, bool revoked = false) { const string rawToken = "test_raw_token_value_abc"; var hash = TokenHasher.Sha256Base64Url(rawToken); var now = DateTime.UtcNow; var expiresAt = expired ? now.AddDays(-1) : now.AddDays(6); var issuedAt = now.AddHours(-1); var stored = RefreshToken.IssueForNewFamily(usuarioId, hash, issuedAt, TimeSpan.FromDays(7), "10.0.0.1", null); // We need to set ExpiresAt to our custom value — since IssueForNewFamily uses now+ttl, // we build differently: use reflection trick. Instead, build a helper stored token directly. // The cleanest approach: build via the public factory, then adjust with MarkAsPersistedRevocation for revoked. // Build a fresh token that expires properly var storedToken = RefreshToken.IssueForNewFamily( usuarioId, hash, now: issuedAt, ttl: expired ? TimeSpan.FromSeconds(1) : TimeSpan.FromDays(7), createdByIp: "10.0.0.1", userAgent: null); if (revoked) storedToken.MarkAsPersistedRevocation(now.AddMinutes(-5), replacedById: null); var claims = new List { new(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, usuarioId.ToString()) }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); return (storedToken, rawToken, principal); } // --- Happy path --- [Fact] public async Task Handle_HappyPath_RotatesAndReturnsNewPair() { var (stored, rawToken, principal) = MakeActiveToken(); var hash = TokenHasher.Sha256Base64Url(rawToken); _jwtService.GetPrincipalFromExpiredToken(Arg.Any()).Returns(principal); _refreshRepo.GetByHashAsync(hash).Returns(stored); _usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario); _jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access_token"); _refreshRepo.AddAsync(Arg.Any()).Returns(99); var cmd = new RefreshCommand("old_access_token", rawToken); var result = await _handler.Handle(cmd); Assert.Equal("new_access_token", result.AccessToken); Assert.Equal("new_raw_token_value_xyz", result.RefreshToken); Assert.Equal(3600, result.ExpiresIn); await _refreshRepo.Received(1).AddAsync(Arg.Any()); await _refreshRepo.Received(1).RevokeAsync(stored.Id, replacedById: 99, revokedAt: Arg.Any()); } [Fact] public async Task Handle_NewTokenInheritsOriginalExpiresAt() { var (stored, rawToken, principal) = MakeActiveToken(); var hash = TokenHasher.Sha256Base64Url(rawToken); _jwtService.GetPrincipalFromExpiredToken(Arg.Any()).Returns(principal); _refreshRepo.GetByHashAsync(hash).Returns(stored); _usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario); _jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access"); _refreshRepo.AddAsync(Arg.Any()).Returns(10); await _handler.Handle(new RefreshCommand("access", rawToken)); // Verify the new token saved to repo inherits the ExpiresAt of the parent await _refreshRepo.Received(1).AddAsync(Arg.Is(t => t.ExpiresAt == stored.ExpiresAt && t.FamilyId == stored.FamilyId)); } // --- Error paths --- [Fact] public async Task Handle_TokenHashNotFound_ThrowsInvalidRefreshToken() { var principal = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, "1")])); _jwtService.GetPrincipalFromExpiredToken(Arg.Any()).Returns(principal); _refreshRepo.GetByHashAsync(Arg.Any()).Returns((RefreshToken?)null); await Assert.ThrowsAsync( () => _handler.Handle(new RefreshCommand("access", "unknown_token"))); } [Fact] public async Task Handle_TokenExpired_ThrowsInvalidRefreshToken() { var (stored, rawToken, principal) = MakeActiveToken(expired: true); var hash = TokenHasher.Sha256Base64Url(rawToken); _jwtService.GetPrincipalFromExpiredToken(Arg.Any()).Returns(principal); _refreshRepo.GetByHashAsync(hash).Returns(stored); await Assert.ThrowsAsync( () => _handler.Handle(new RefreshCommand("access", rawToken))); } [Fact] public async Task Handle_TokenAlreadyRevoked_RevokesFamilyAndThrowsReuse() { var (stored, rawToken, principal) = MakeActiveToken(revoked: true); var hash = TokenHasher.Sha256Base64Url(rawToken); _jwtService.GetPrincipalFromExpiredToken(Arg.Any()).Returns(principal); _refreshRepo.GetByHashAsync(hash).Returns(stored); await Assert.ThrowsAsync( () => _handler.Handle(new RefreshCommand("access", rawToken))); await _refreshRepo.Received(1).RevokeFamilyAsync(stored.FamilyId, Arg.Any()); } [Fact] public async Task Handle_AccessTokenSignatureInvalid_ThrowsInvalidRefreshToken() { _jwtService.GetPrincipalFromExpiredToken(Arg.Any()) .Throws(new Microsoft.IdentityModel.Tokens.SecurityTokenException("Bad sig")); await Assert.ThrowsAsync( () => _handler.Handle(new RefreshCommand("bad.access.token", "some_refresh"))); } [Fact] public async Task Handle_AccessTokenSubMismatch_ThrowsInvalidRefreshToken() { // stored token is for user 1, but access token claims user 99 var (stored, rawToken, _) = MakeActiveToken(usuarioId: 1); var hash = TokenHasher.Sha256Base64Url(rawToken); var mismatchPrincipal = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, "99")])); _jwtService.GetPrincipalFromExpiredToken(Arg.Any()).Returns(mismatchPrincipal); _refreshRepo.GetByHashAsync(hash).Returns(stored); await Assert.ThrowsAsync( () => _handler.Handle(new RefreshCommand("access_user99", rawToken))); } [Fact] public async Task Handle_UsuarioInactive_ThrowsInvalidRefreshToken() { var (stored, rawToken, principal) = MakeActiveToken(); var hash = TokenHasher.Sha256Base64Url(rawToken); _jwtService.GetPrincipalFromExpiredToken(Arg.Any()).Returns(principal); _refreshRepo.GetByHashAsync(hash).Returns(stored); var inactiveUser = new Usuario(1, "admin", "$hash", "Admin", "Sys", null, "admin", "[\"*\"]", activo: false); _usuarioRepo.GetByIdAsync(1).Returns(inactiveUser); await Assert.ThrowsAsync( () => _handler.Handle(new RefreshCommand("access", rawToken))); } }