using Microsoft.Extensions.Logging; 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 IRolPermisoRepository _rolPermisoRepo = Substitute.For(); private readonly ILogger _logger = 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); // Default: UpdateUltimoLoginAsync succeeds silently _repository.UpdateUltimoLoginAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); // Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben _rolPermisoRepo.GetByRolCodigoAsync(Arg.Any(), Arg.Any()) .Returns(new List().AsReadOnly()); _handler = new LoginCommandHandler( _repository, _hasher, _jwtService, _refreshRepo, _refreshGenerator, _clientCtx, _authOptions, _rolPermisoRepo, _logger); } // 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); } // UDT-006 B-05: Permisos vienen desde IRolPermisoRepository, no desde PermisosJson [Fact] public async Task Handle_AdminLogin_PermisosFromRolPermisoRepository() { // Arrange 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 adminPermisos = new List { MakePermiso(1, "administracion:usuarios:gestionar"), MakePermiso(2, "administracion:roles:gestionar"), MakePermiso(3, "administracion:permisos:ver"), }; _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any()) .Returns(adminPermisos.AsReadOnly() as IReadOnlyList); // Act var result = await _handler.Handle(new LoginCommand("admin", "@Diego550@")); // Assert — permisos vienen del repo, no de PermisosJson Assert.Equal(3, result.Usuario.Permisos.Length); Assert.Contains("administracion:usuarios:gestionar", result.Usuario.Permisos); Assert.Contains("administracion:roles:gestionar", result.Usuario.Permisos); Assert.Contains("administracion:permisos:ver", result.Usuario.Permisos); // IRolPermisoRepository fue consultado con el rol del usuario await _rolPermisoRepo.Received(1).GetByRolCodigoAsync("admin", Arg.Any()); } // UDT-006 B-05-03: Rol sin permisos en RolPermiso → array vacío (nunca null) [Fact] public async Task Handle_RolSinPermisos_PermisosArrayVacio() { // Arrange var usuario = new Usuario(5, "rep1", "$2a$12$hash", "Rep", "User", null, "reportes", "[]", true); _repository.GetByUsernameAsync("rep1").Returns(usuario); _hasher.Verify("pass", "$2a$12$hash").Returns(true); _jwtService.GenerateAccessToken(usuario).Returns("jwt.rep"); // repo devuelve lista vacía para "reportes" _rolPermisoRepo.GetByRolCodigoAsync("reportes", Arg.Any()) .Returns(new List().AsReadOnly() as IReadOnlyList); // Act var result = await _handler.Handle(new LoginCommand("rep1", "pass")); // Assert — empty array, NOT null Assert.NotNull(result.Usuario.Permisos); Assert.Empty(result.Usuario.Permisos); } // Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user [Fact] public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser() { var cajeroPermisos = new List { MakePermiso(10, "ventas:contado:crear"), MakePermiso(11, "ventas:contado:cobrar"), }; var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "cajero", "[]", true); _repository.GetByUsernameAsync("cajero1").Returns(usuario); _hasher.Verify("pass123", "$2a$12$hash3").Returns(true); _jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token"); _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); 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:crear", result.Usuario.Permisos); Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos); } // Helper: construir Permiso via ForRead para tests private static Permiso MakePermiso(int id, string codigo) => Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow); // 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))); } // ── UDT-008: username + mustChangePassword + UltimoLogin ───────────────── [Fact] public async Task Handle_PopulatesUsername_InUsuarioDto() { var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true); _repository.GetByUsernameAsync("jperez").Returns(usuario); _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); var result = await _handler.Handle(new LoginCommand("jperez", "pass")); Assert.Equal("jperez", result.Usuario.Username); } [Fact] public async Task Handle_PopulatesMustChangePassword_False_WhenZero() { var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true, mustChangePassword: false); _repository.GetByUsernameAsync("jperez").Returns(usuario); _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); var result = await _handler.Handle(new LoginCommand("jperez", "pass")); Assert.False(result.Usuario.MustChangePassword); } [Fact] public async Task Handle_PopulatesMustChangePassword_True_WhenSet() { var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true, mustChangePassword: true); _repository.GetByUsernameAsync("jperez").Returns(usuario); _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); var result = await _handler.Handle(new LoginCommand("jperez", "pass")); Assert.True(result.Usuario.MustChangePassword); } [Fact] public async Task Handle_CallsUpdateUltimoLoginAsync_AfterSuccessfulAuth() { var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true); _repository.GetByUsernameAsync("jperez").Returns(usuario); _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); await _handler.Handle(new LoginCommand("jperez", "pass")); await _repository.Received(1).UpdateUltimoLoginAsync(1, Arg.Any(), Arg.Any()); } [Fact] public async Task Handle_Succeeds_EvenIf_UpdateUltimoLogin_Throws() { var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true); _repository.GetByUsernameAsync("jperez").Returns(usuario); _hasher.Verify(Arg.Any(), Arg.Any()).Returns(true); _jwtService.GenerateAccessToken(Arg.Any()).Returns("jwt"); // Simulate DB hiccup on UltimoLogin update _repository.UpdateUltimoLoginAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new Exception("DB timeout"))); // Login must still succeed var result = await _handler.Handle(new LoginCommand("jperez", "pass")); Assert.NotNull(result); Assert.NotNull(result.AccessToken); } }