using NSubstitute; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; using SIGCM2.Application.Auth; using SIGCM2.Application.Auth.Login; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Security; namespace SIGCM2.Application.Tests.Auth.Login; public class LoginCommandHandlerTests { private readonly IUsuarioRepository _repository = Substitute.For(); private readonly IPasswordHasher _hasher = Substitute.For(); private readonly IJwtService _jwtService = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For(); private readonly IClientContext _clientCtx = Substitute.For(); private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 }; private readonly LoginCommandHandler _handler; public LoginCommandHandlerTests() { _clientCtx.Ip.Returns("127.0.0.1"); _clientCtx.UserAgent.Returns("TestAgent"); _refreshGenerator.Generate().Returns("raw_refresh_token_value"); _refreshRepo.AddAsync(Arg.Any()).Returns(1); _handler = new LoginCommandHandler( _repository, _hasher, _jwtService, _refreshRepo, _refreshGenerator, _clientCtx, _authOptions); } // Scenario: valid credentials → returns token response with usuario populated [Fact] public async Task Handle_ValidCredentials_ReturnsTokenResponse() { var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); _repository.GetByUsernameAsync("admin").Returns(usuario); _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true); _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here"); var command = new LoginCommand("admin", "@Diego550@"); var result = await _handler.Handle(command); Assert.Equal("jwt.token.here", result.AccessToken); Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken)); Assert.Equal(3600, result.ExpiresIn); // Contract: Usuario must be populated Assert.NotNull(result.Usuario); Assert.Equal(1, result.Usuario.Id); Assert.Equal("Admin Sys", result.Usuario.Nombre); Assert.Equal("admin", result.Usuario.Rol); Assert.NotNull(result.Usuario.Permisos); Assert.Contains("*", result.Usuario.Permisos); } // Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user [Fact] public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser() { var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "Cajero", "[\"ventas:contado:create\",\"ventas:contado:read\"]", true); _repository.GetByUsernameAsync("cajero1").Returns(usuario); _hasher.Verify("pass123", "$2a$12$hash3").Returns(true); _jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token"); var command = new LoginCommand("cajero1", "pass123"); var result = await _handler.Handle(command); Assert.Equal(42, result.Usuario.Id); Assert.Equal("María González", result.Usuario.Nombre); Assert.Equal("Cajero", result.Usuario.Rol); Assert.Equal(2, result.Usuario.Permisos.Length); Assert.Contains("ventas:contado:create", result.Usuario.Permisos); Assert.Contains("ventas:contado:read", result.Usuario.Permisos); } // Scenario: user does not exist → throws InvalidCredentialsException [Fact] public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException() { _repository.GetByUsernameAsync("noexiste").Returns((Usuario?)null); var command = new LoginCommand("noexiste", "anything"); await Assert.ThrowsAsync(() => _handler.Handle(command)); } // Scenario: user is inactive → throws InvalidCredentialsException [Fact] public async Task Handle_InactiveUser_ThrowsInvalidCredentialsException() { var inactive = new Usuario(2, "operador", "$2a$12$hash2", "Juan", "Pérez", null, "vendedor", "[]", false); _repository.GetByUsernameAsync("operador").Returns(inactive); var command = new LoginCommand("operador", "correctpassword"); await Assert.ThrowsAsync(() => _handler.Handle(command)); } // Scenario: wrong password → throws InvalidCredentialsException [Fact] public async Task Handle_WrongPassword_ThrowsInvalidCredentialsException() { var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); _repository.GetByUsernameAsync("admin").Returns(usuario); _hasher.Verify("WrongPass1", "$2a$12$hash").Returns(false); var command = new LoginCommand("admin", "WrongPass1"); await Assert.ThrowsAsync(() => _handler.Handle(command)); } // T-034: Refresh token persistence on login [Fact] public async Task Handle_PersistsHashedRefreshToken() { var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); _repository.GetByUsernameAsync("admin").Returns(usuario); _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true); _jwtService.GenerateAccessToken(usuario).Returns("jwt"); var command = new LoginCommand("admin", "@Diego550@"); var result = await _handler.Handle(command); // Raw token returned to client Assert.Equal("raw_refresh_token_value", result.RefreshToken); // Repository received a token with the hash of the raw value — not the raw itself var expectedHash = TokenHasher.Sha256Base64Url("raw_refresh_token_value"); await _refreshRepo.Received(1).AddAsync(Arg.Is(t => t.TokenHash == expectedHash && t.UsuarioId == 1)); } [Fact] public async Task Handle_GeneratesNewFamilyId() { var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); _repository.GetByUsernameAsync("admin").Returns(usuario); _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); var capturedFamilies = new List(); _refreshRepo.AddAsync(Arg.Do(t => capturedFamilies.Add(t.FamilyId))).Returns(1); await _handler.Handle(new LoginCommand("admin", "@Diego550@")); await _handler.Handle(new LoginCommand("admin", "@Diego550@")); // Each login gets a unique FamilyId Assert.Equal(2, capturedFamilies.Count); Assert.NotEqual(capturedFamilies[0], capturedFamilies[1]); } [Fact] public async Task Handle_UsesConfiguredRefreshTokenDays() { var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); _repository.GetByUsernameAsync("admin").Returns(usuario); _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); var before = DateTime.UtcNow; await _handler.Handle(new LoginCommand("admin", "@Diego550@")); var after = DateTime.UtcNow; // The token added to the repo must have ExpiresAt ~7 days from now await _refreshRepo.Received(1).AddAsync(Arg.Is(t => t.ExpiresAt >= before.AddDays(6).AddHours(23) && t.ExpiresAt <= after.AddDays(7).AddSeconds(5))); } }