199 lines
8.7 KiB
C#
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)));
|
|
}
|
|
}
|