UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth #12

Merged
dmolinari merged 14 commits from feature/UDT-009 into main 2026-04-16 13:12:23 +00:00
2 changed files with 136 additions and 4 deletions
Showing only changes of commit fb07a1139a - Show all commits

View File

@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security; using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities; using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security; using SIGCM2.Domain.Security;
@@ -75,10 +76,12 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_logger.LogWarning(ex, "Failed to update UltimoLogin for usuario {Id} — login proceeds", usuario.Id); _logger.LogWarning(ex, "Failed to update UltimoLogin for usuario {Id} — login proceeds", usuario.Id);
} }
// UDT-006: permisos vienen de RolPermiso, no de Usuario.PermisosJson // UDT-009: permisos efectivos = (rol grant) \ deny via PermisoResolver
// Usuario.PermisosJson queda reservado para UDT-009 (overrides por usuario) var rolPermisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol);
var permisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol); var rolPermisos = rolPermisoEntities.Select(p => p.Codigo);
var permisos = permisoEntities.Select(p => p.Codigo).ToArray(); var overrides = PermisosOverride.FromJson(usuario.PermisosJson);
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray();
return new LoginResponseDto( return new LoginResponseDto(
AccessToken: accessToken, AccessToken: accessToken,

View File

@@ -150,6 +150,135 @@ public class LoginCommandHandlerTests
Assert.Contains("ventas:contado:cobrar", 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 // Helper: construir Permiso via ForRead para tests
private static Permiso MakePermiso(int id, string codigo) => private static Permiso MakePermiso(int id, string codigo) =>
Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow); Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow);