diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs index 796a2aa..fc9a06e 100644 --- a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Security; @@ -75,10 +76,12 @@ public sealed class LoginCommandHandler : ICommandHandler p.Codigo).ToArray(); + // UDT-009: permisos efectivos = (rol ∪ grant) \ deny via PermisoResolver + var rolPermisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol); + var rolPermisos = rolPermisoEntities.Select(p => p.Codigo); + var overrides = PermisosOverride.FromJson(usuario.PermisosJson); + var effective = PermisoResolver.Resolve(rolPermisos, overrides); + var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray(); return new LoginResponseDto( AccessToken: accessToken, diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs index f96306c..e009f00 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs @@ -150,6 +150,135 @@ public class LoginCommandHandlerTests 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()) + .Returns(adminPermisos.AsReadOnly() as IReadOnlyList); + + // 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 + { + 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()) + .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); + + // 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 + { + 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()) + .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); + + // 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()) + .Returns(new List().AsReadOnly() as IReadOnlyList); + + var result = await _handler.Handle(new LoginCommand("admin", "pass")); + + // Must be string[] — no grant/deny wrapping + Assert.IsType(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 + { + MakePermiso(10, "ventas:contado:crear"), + MakePermiso(11, "ventas:contado:cobrar"), + }; + _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any()) + .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList); + + 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);