Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs

199 lines
8.7 KiB
C#

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<IRefreshTokenRepository>();
private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
private readonly IRefreshTokenGenerator _generator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
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<Claim>
{
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<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
_usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario);
_jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access_token");
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).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<RefreshToken>());
await _refreshRepo.Received(1).RevokeAsync(stored.Id, replacedById: 99, revokedAt: Arg.Any<DateTime>());
}
[Fact]
public async Task Handle_NewTokenInheritsOriginalExpiresAt()
{
var (stored, rawToken, principal) = MakeActiveToken();
var hash = TokenHasher.Sha256Base64Url(rawToken);
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
_usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario);
_jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access");
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).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<RefreshToken>(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<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(Arg.Any<string>()).Returns((RefreshToken?)null);
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _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<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _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<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
await Assert.ThrowsAsync<TokenReuseDetectedException>(
() => _handler.Handle(new RefreshCommand("access", rawToken)));
await _refreshRepo.Received(1).RevokeFamilyAsync(stored.FamilyId, Arg.Any<DateTime>());
}
[Fact]
public async Task Handle_AccessTokenSignatureInvalid_ThrowsInvalidRefreshToken()
{
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>())
.Throws(new Microsoft.IdentityModel.Tokens.SecurityTokenException("Bad sig"));
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _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<string>()).Returns(mismatchPrincipal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _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<string>()).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<InvalidRefreshTokenException>(
() => _handler.Handle(new RefreshCommand("access", rawToken)));
}
}