Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs
dmolinari b619c05762 feat(audit): security events en Auth + authorization handlers (UDT-010 B9)
Instruments auth pipeline with ISecurityEventLogger per #REQ-AUTH-SEC:

LoginCommandHandler:
- login success → action=login result=success actorUserId=user.Id
- login failure disaggregated internally (client still sees 401 unified):
  user_not_found / user_inactive / invalid_password
  — attempts captured with attemptedUsername + FailureReason

LogoutCommandHandler:
- action=logout result=success actorUserId=cmd.UsuarioId

RefreshCommandHandler:
- refresh.issue success on successful rotation
- refresh.reuse_detected failure when revoked token is presented (chain
  revoke already happens; we add the security event with metadata.familyId)
- refresh.issue failure for: token_expired / sub_mismatch / user_not_found /
  user_inactive

PermissionAuthorizationHandler:
- permission.denied failure on require-permission rejection, with metadata
  { permissionRequired, endpoint, method }. ActorUserId from JWT sub.

DI: ISecurityEventLogger was already registered by B6 (AddInfrastructure).

Test updates: 4 test classes now inject ISecurityEventLogger mock:
- LoginCommandHandlerTests, LogoutCommandHandlerTests, RefreshCommandHandlerTests
- PermissionAuthorizationHandlerTests (Api.Tests)

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-SEC-2/3/4/5 #REQ-AUTH-SEC,
design, tasks#B9}
2026-04-16 13:59:27 -03:00

458 lines
20 KiB
C#

using Microsoft.Extensions.Logging;
using NSubstitute;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
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 ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
private readonly ILogger<LoginCommandHandler> _logger = Substitute.For<ILogger<LoginCommandHandler>>();
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: UpdateUltimoLoginAsync succeeds silently
_repository.UpdateUltimoLoginAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
// 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, _security, _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<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);
}
// ── UDT-009: PermisoResolver integration in LoginCommandHandler ─────────────
// L-01: Admin sin overrides → permisos = exactamente los del rol
[Fact]
public async Task Handle_AdminNoOverrides_PermisosEqualRolPermisos()
{
// Arrange
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin",
"""{"grant":[],"deny":[]}""", true);
_repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
var adminPermisos = Enumerable.Range(1, 21)
.Select(i => MakePermiso(i, $"perm:mod{i}:accion{i}"))
.ToList();
_rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
.Returns(adminPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
// Act
var result = await _handler.Handle(new LoginCommand("admin", "pass"));
// Assert
Assert.Equal(21, result.Usuario.Permisos.Length);
}
// L-02: Cajero + grant nuevo permiso → result contiene permiso del grant
[Fact]
public async Task Handle_CajeroWithGrant_PermisosContainGrantedPermiso()
{
// Arrange
var usuario = new Usuario(2, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
"""{"grant":["textos:editar"],"deny":[]}""", true);
_repository.GetByUsernameAsync("cajero1").Returns(usuario);
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
var cajeroPermisos = new List<Permiso>
{
MakePermiso(10, "ventas:contado:crear"),
MakePermiso(11, "ventas:contado:modificar"),
MakePermiso(12, "ventas:contado:cobrar"),
MakePermiso(13, "ventas:contado:facturar"),
};
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
// Act
var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));
// Assert
Assert.Equal(5, result.Usuario.Permisos.Length);
Assert.Contains("textos:editar", result.Usuario.Permisos);
Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
}
// L-03: Cajero + deny uno del rol → result NO contiene el permiso denegado
[Fact]
public async Task Handle_CajeroWithDeny_PermisosExcludeDeniedPermiso()
{
// Arrange
var usuario = new Usuario(3, "cajero2", "$2a$12$hash", "C", "B", null, "cajero",
"""{"grant":[],"deny":["ventas:contado:cobrar"]}""", true);
_repository.GetByUsernameAsync("cajero2").Returns(usuario);
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
var cajeroPermisos = new List<Permiso>
{
MakePermiso(10, "ventas:contado:crear"),
MakePermiso(11, "ventas:contado:modificar"),
MakePermiso(12, "ventas:contado:cobrar"),
MakePermiso(13, "ventas:contado:facturar"),
};
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
// Act
var result = await _handler.Handle(new LoginCommand("cajero2", "pass"));
// Assert — ventas:contado:cobrar was denied
Assert.Equal(3, result.Usuario.Permisos.Length);
Assert.DoesNotContain("ventas:contado:cobrar", result.Usuario.Permisos);
Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
}
// L-04: DTO always returns Permisos as string[] — not grant/deny shape
[Fact]
public async Task Handle_AlwaysReturnsPermisosAsStringArray_NotGrantDenyShape()
{
var usuario = new Usuario(1, "admin", "$2a$12$hash", "A", "B", null, "admin",
"""{"grant":["extra:perm"],"deny":[]}""", true);
_repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
_rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
.Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);
var result = await _handler.Handle(new LoginCommand("admin", "pass"));
// Must be string[] — no grant/deny wrapping
Assert.IsType<string[]>(result.Usuario.Permisos);
}
// L-05: Legacy PermisosJson "[]" → treated as Empty → permisos = only rol
[Fact]
public async Task Handle_LegacyPermisosJson_EmptyArray_TreatedAsEmpty()
{
var usuario = new Usuario(1, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
"[]", true);
_repository.GetByUsernameAsync("cajero1").Returns(usuario);
_hasher.Verify("pass", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
var cajeroPermisos = new List<Permiso>
{
MakePermiso(10, "ventas:contado:crear"),
MakePermiso(11, "ventas:contado:cobrar"),
};
_rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
.Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);
var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));
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)));
}
// ── 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<string>(), Arg.Any<string>()).Returns(true);
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).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<string>(), Arg.Any<string>()).Returns(true);
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).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<string>(), Arg.Any<string>()).Returns(true);
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).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<string>(), Arg.Any<string>()).Returns(true);
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
await _handler.Handle(new LoginCommand("jperez", "pass"));
await _repository.Received(1).UpdateUltimoLoginAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
[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<string>(), Arg.Any<string>()).Returns(true);
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
// Simulate DB hiccup on UltimoLogin update
_repository.UpdateUltimoLoginAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.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);
}
}