diff --git a/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs new file mode 100644 index 0000000..b0907eb --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs @@ -0,0 +1,198 @@ +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.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 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); + } + + // 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))); + } +}