feat(api): login response permisos desde RolPermiso [UDT-006]

This commit is contained in:
2026-04-15 16:24:21 -03:00
parent 2afac53fca
commit cdb8dcd03c
2 changed files with 85 additions and 12 deletions

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
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;
@@ -17,6 +16,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
private readonly IRefreshTokenGenerator _refreshGenerator; private readonly IRefreshTokenGenerator _refreshGenerator;
private readonly IClientContext _clientContext; private readonly IClientContext _clientContext;
private readonly AuthOptions _authOptions; private readonly AuthOptions _authOptions;
private readonly IRolPermisoRepository _rolPermisoRepository;
public LoginCommandHandler( public LoginCommandHandler(
IUsuarioRepository repository, IUsuarioRepository repository,
@@ -25,7 +25,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
IRefreshTokenRepository refreshRepository, IRefreshTokenRepository refreshRepository,
IRefreshTokenGenerator refreshGenerator, IRefreshTokenGenerator refreshGenerator,
IClientContext clientContext, IClientContext clientContext,
AuthOptions authOptions) AuthOptions authOptions,
IRolPermisoRepository rolPermisoRepository)
{ {
_repository = repository; _repository = repository;
_hasher = hasher; _hasher = hasher;
@@ -34,6 +35,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_refreshGenerator = refreshGenerator; _refreshGenerator = refreshGenerator;
_clientContext = clientContext; _clientContext = clientContext;
_authOptions = authOptions; _authOptions = authOptions;
_rolPermisoRepository = rolPermisoRepository;
} }
public async Task<LoginResponseDto> Handle(LoginCommand command) public async Task<LoginResponseDto> Handle(LoginCommand command)
@@ -59,8 +61,10 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_clientContext.Ip, _clientContext.UserAgent); _clientContext.Ip, _clientContext.UserAgent);
await _refreshRepository.AddAsync(entity); await _refreshRepository.AddAsync(entity);
var permisos = JsonSerializer.Deserialize<string[]>(usuario.PermisosJson) // UDT-006: permisos vienen de RolPermiso, no de Usuario.PermisosJson
?? Array.Empty<string>(); // Usuario.PermisosJson queda reservado para UDT-008 (overrides por usuario)
var permisoEntities = await _rolPermisoRepository.GetByRolCodigoAsync(usuario.Rol);
var permisos = permisoEntities.Select(p => p.Codigo).ToArray();
return new LoginResponseDto( return new LoginResponseDto(
AccessToken: accessToken, AccessToken: accessToken,

View File

@@ -18,6 +18,7 @@ public class LoginCommandHandlerTests
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>(); private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>(); 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 AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
private readonly LoginCommandHandler _handler; private readonly LoginCommandHandler _handler;
@@ -28,16 +29,21 @@ public class LoginCommandHandlerTests
_refreshGenerator.Generate().Returns("raw_refresh_token_value"); _refreshGenerator.Generate().Returns("raw_refresh_token_value");
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(1); _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( _handler = new LoginCommandHandler(
_repository, _hasher, _jwtService, _repository, _hasher, _jwtService,
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions); _refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
_rolPermisoRepo);
} }
// Scenario: valid credentials → returns token response with usuario populated // Scenario: valid credentials → returns token response with usuario populated
[Fact] [Fact]
public async Task Handle_ValidCredentials_ReturnsTokenResponse() public async Task Handle_ValidCredentials_ReturnsTokenResponse()
{ {
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true); var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[]", true);
_repository.GetByUsernameAsync("admin").Returns(usuario); _repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true); _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here"); _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here");
@@ -55,30 +61,93 @@ public class LoginCommandHandlerTests
Assert.Equal("Admin Sys", result.Usuario.Nombre); Assert.Equal("Admin Sys", result.Usuario.Nombre);
Assert.Equal("admin", result.Usuario.Rol); Assert.Equal("admin", result.Usuario.Rol);
Assert.NotNull(result.Usuario.Permisos); Assert.NotNull(result.Usuario.Permisos);
Assert.Contains("*", 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 // Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user
[Fact] [Fact]
public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser() public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser()
{ {
var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "Cajero", var cajeroPermisos = new List<Permiso>
"[\"ventas:contado:create\",\"ventas:contado:read\"]", true); {
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); _repository.GetByUsernameAsync("cajero1").Returns(usuario);
_hasher.Verify("pass123", "$2a$12$hash3").Returns(true); _hasher.Verify("pass123", "$2a$12$hash3").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token"); _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 command = new LoginCommand("cajero1", "pass123");
var result = await _handler.Handle(command); var result = await _handler.Handle(command);
Assert.Equal(42, result.Usuario.Id); Assert.Equal(42, result.Usuario.Id);
Assert.Equal("María González", result.Usuario.Nombre); Assert.Equal("María González", result.Usuario.Nombre);
Assert.Equal("Cajero", result.Usuario.Rol); Assert.Equal("cajero", result.Usuario.Rol);
Assert.Equal(2, result.Usuario.Permisos.Length); Assert.Equal(2, result.Usuario.Permisos.Length);
Assert.Contains("ventas:contado:create", result.Usuario.Permisos); Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
Assert.Contains("ventas:contado:read", 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 // Scenario: user does not exist → throws InvalidCredentialsException
[Fact] [Fact]
public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException() public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException()