247 lines
11 KiB
C#
247 lines
11 KiB
C#
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<IUsuarioRepository>();
|
|
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
|
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
|
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
|
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
|
|
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
|
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
|
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<RefreshToken>()).Returns(1);
|
|
|
|
// Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben
|
|
_rolPermisoRepo.GetByRolCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(new List<Permiso>().AsReadOnly());
|
|
|
|
_handler = new LoginCommandHandler(
|
|
_repository, _hasher, _jwtService,
|
|
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
|
|
_rolPermisoRepo);
|
|
}
|
|
|
|
// 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<Permiso>
|
|
{
|
|
MakePermiso(1, "administracion:usuarios:gestionar"),
|
|
MakePermiso(2, "administracion:roles:gestionar"),
|
|
MakePermiso(3, "administracion:permisos:ver"),
|
|
};
|
|
_rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
|
|
.Returns(adminPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
|
|
|
// 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<CancellationToken>());
|
|
}
|
|
|
|
// 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<CancellationToken>())
|
|
.Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);
|
|
|
|
// 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<Permiso>
|
|
{
|
|
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<CancellationToken>())
|
|
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
|
|
|
|
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<InvalidCredentialsException>(() => _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<InvalidCredentialsException>(() => _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<InvalidCredentialsException>(() => _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<RefreshToken>(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<string>(), Arg.Any<string>()).Returns(true);
|
|
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
|
|
|
|
var capturedFamilies = new List<Guid>();
|
|
_refreshRepo.AddAsync(Arg.Do<RefreshToken>(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<string>(), Arg.Any<string>()).Returns(true);
|
|
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).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<RefreshToken>(t =>
|
|
t.ExpiresAt >= before.AddDays(6).AddHours(23) &&
|
|
t.ExpiresAt <= after.AddDays(7).AddSeconds(5)));
|
|
}
|
|
}
|