diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IPermisoRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IPermisoRepository.cs new file mode 100644 index 0000000..5ac860e --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IPermisoRepository.cs @@ -0,0 +1,10 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IPermisoRepository +{ + Task> ListAsync(CancellationToken ct = default); + Task GetByCodigoAsync(string codigo, CancellationToken ct = default); + Task> GetByCodigosAsync(IEnumerable codigos, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IRolPermisoRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolPermisoRepository.cs new file mode 100644 index 0000000..5465c0b --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolPermisoRepository.cs @@ -0,0 +1,9 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IRolPermisoRepository +{ + Task> GetByRolCodigoAsync(string rolCodigo, CancellationToken ct = default); + Task ReplaceForRolAsync(int rolId, IEnumerable permisoIds, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index cba09e3..c864f1a 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -4,6 +4,10 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; +using SIGCM2.Application.Permisos.Assign; +using SIGCM2.Application.Permisos.Dtos; +using SIGCM2.Application.Permisos.GetByRol; +using SIGCM2.Application.Permisos.List; using SIGCM2.Application.Roles.Create; using SIGCM2.Application.Roles.Deactivate; using SIGCM2.Application.Roles.Dtos; @@ -31,6 +35,11 @@ public static class DependencyInjection services.AddScoped, UpdateRolCommandHandler>(); services.AddScoped, DeactivateRolCommandHandler>(); + // Permisos (UDT-005) + services.AddScoped>, ListPermisosQueryHandler>(); + services.AddScoped>, GetRolPermisosQueryHandler>(); + services.AddScoped>, AssignPermisosToRolCommandHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommand.cs b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommand.cs new file mode 100644 index 0000000..34b9930 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommand.cs @@ -0,0 +1,5 @@ +namespace SIGCM2.Application.Permisos.Assign; + +public sealed record AssignPermisosToRolCommand( + string RolCodigo, + IReadOnlyList Codigos); diff --git a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs new file mode 100644 index 0000000..5f705f7 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs @@ -0,0 +1,52 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Permisos.Assign; + +public sealed class AssignPermisosToRolCommandHandler : ICommandHandler> +{ + private readonly IRolRepository _rolRepository; + private readonly IPermisoRepository _permisoRepository; + private readonly IRolPermisoRepository _rolPermisoRepository; + + public AssignPermisosToRolCommandHandler( + IRolRepository rolRepository, + IPermisoRepository permisoRepository, + IRolPermisoRepository rolPermisoRepository) + { + _rolRepository = rolRepository; + _permisoRepository = permisoRepository; + _rolPermisoRepository = rolPermisoRepository; + } + + public async Task> Handle(AssignPermisosToRolCommand command) + { + // 1. Validar que el rol existe + var rol = await _rolRepository.GetByCodigoAsync(command.RolCodigo); + if (rol is null) + throw new RolNotFoundException(command.RolCodigo); + + // 2. Validar que todos los códigos existen en BD + var codigosList = command.Codigos.ToList(); + var permisos = await _permisoRepository.GetByCodigosAsync(codigosList); + + if (permisos.Count != codigosList.Count) + { + // Detectar el primer código que no fue encontrado + var foundCodigos = permisos.Select(p => p.Codigo).ToHashSet(); + var missing = codigosList.First(c => !foundCodigos.Contains(c)); + throw new PermisoNotFoundException(missing); + } + + // 3. Reemplazar el set (DELETE+INSERT en transacción dentro del repo) + var permisoIds = permisos.Select(p => p.Id); + await _rolPermisoRepository.ReplaceForRolAsync(rol.Id, permisoIds); + + // 4. Retornar el nuevo set asignado + return permisos + .Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandValidator.cs b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandValidator.cs new file mode 100644 index 0000000..ab822d8 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using SIGCM2.Domain.Permissions; + +namespace SIGCM2.Application.Permisos.Assign; + +public sealed class AssignPermisosToRolCommandValidator : AbstractValidator +{ + private const string AdminCodigo = "admin"; + + public AssignPermisosToRolCommandValidator() + { + RuleFor(x => x.RolCodigo) + .NotEmpty().WithMessage("El código del rol es requerido."); + + RuleFor(x => x.Codigos) + .NotNull().WithMessage("La lista de permisos no puede ser nula."); + + // Admin no puede quedar con lista vacía — regla RBAC explícita (convención admin-convention) + RuleFor(x => x.Codigos) + .Must((cmd, codigos) => !(cmd.RolCodigo == AdminCodigo && codigos.Count == 0)) + .WithMessage("El rol 'admin' debe retener al menos un permiso."); + + // Cada código debe pertenecer al catálogo canónico + RuleForEach(x => x.Codigos) + .Must(codigo => Permiso.Todos.Contains(codigo)) + .WithMessage("El código de permiso '{PropertyValue}' no existe en el catálogo."); + } +} diff --git a/src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs b/src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs new file mode 100644 index 0000000..08196ca --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Permisos.Dtos; + +public sealed record PermisoDto( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + string Modulo); diff --git a/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQuery.cs b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQuery.cs new file mode 100644 index 0000000..946d9b0 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Permisos.GetByRol; + +public sealed record GetRolPermisosQuery(string RolCodigo); diff --git a/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryHandler.cs b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryHandler.cs new file mode 100644 index 0000000..35dacb0 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Permisos.GetByRol; + +public sealed class GetRolPermisosQueryHandler : ICommandHandler> +{ + private readonly IRolRepository _rolRepository; + private readonly IRolPermisoRepository _rolPermisoRepository; + + public GetRolPermisosQueryHandler(IRolRepository rolRepository, IRolPermisoRepository rolPermisoRepository) + { + _rolRepository = rolRepository; + _rolPermisoRepository = rolPermisoRepository; + } + + public async Task> Handle(GetRolPermisosQuery query) + { + var rol = await _rolRepository.GetByCodigoAsync(query.RolCodigo); + if (rol is null) + throw new RolNotFoundException(query.RolCodigo); + + var permisos = await _rolPermisoRepository.GetByRolCodigoAsync(query.RolCodigo); + return permisos + .Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Permisos/List/ListPermisosQuery.cs b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQuery.cs new file mode 100644 index 0000000..4175fc4 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Permisos.List; + +public sealed record ListPermisosQuery(); diff --git a/src/api/SIGCM2.Application/Permisos/List/ListPermisosQueryHandler.cs b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQueryHandler.cs new file mode 100644 index 0000000..7bd81d7 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQueryHandler.cs @@ -0,0 +1,23 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Dtos; + +namespace SIGCM2.Application.Permisos.List; + +public sealed class ListPermisosQueryHandler : ICommandHandler> +{ + private readonly IPermisoRepository _repository; + + public ListPermisosQueryHandler(IPermisoRepository repository) + { + _repository = repository; + } + + public async Task> Handle(ListPermisosQuery query) + { + var permisos = await _repository.ListAsync(); + return permisos + .Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo)) + .ToList(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs new file mode 100644 index 0000000..54a9818 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs @@ -0,0 +1,123 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Assign; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Permisos.Assign; + +public class AssignPermisosToRolCommandHandlerTests +{ + private readonly IRolRepository _rolRepository = Substitute.For(); + private readonly IPermisoRepository _permisoRepository = Substitute.For(); + private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For(); + private readonly AssignPermisosToRolCommandHandler _handler; + + public AssignPermisosToRolCommandHandlerTests() + { + _handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository); + } + + private static Rol MakeRol(int id, string codigo) => + new(id, codigo, codigo, null, true, DateTime.UtcNow, null); + + private static Permiso MakePermiso(int id, string codigo, string modulo = "ventas") => + Permiso.ForRead(id, codigo, codigo, null, modulo, true, DateTime.UtcNow); + + [Fact] + public async Task Handle_HappyPath_CallsReplaceWithCorrectIds() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + var permisoCrear = MakePermiso(1, "ventas:contado:crear"); + var permisoFact = MakePermiso(2, "ventas:contado:facturar"); + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List { permisoCrear, permisoFact }); + + var codigos = new List { "ventas:contado:crear", "ventas:contado:facturar" }; + await _handler.Handle(new AssignPermisosToRolCommand("cajero", codigos)); + + await _rolPermisoRepository.Received(1).ReplaceForRolAsync( + 5, + Arg.Is>(ids => ids.SequenceEqual(new[] { 1, 2 }))); + } + + [Fact] + public async Task Handle_RolInexistente_ThrowsRolNotFoundException() + { + _rolRepository.GetByCodigoAsync("fantasma").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("fantasma", new[] { "ventas:contado:crear" }))); + + Assert.Equal("fantasma", ex.Codigo); + } + + [Fact] + public async Task Handle_RolInexistente_DoesNotCallReplace() + { + _rolRepository.GetByCodigoAsync("fantasma").Returns((Rol?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("fantasma", new[] { "ventas:contado:crear" }))); + + await _rolPermisoRepository.DidNotReceive().ReplaceForRolAsync(Arg.Any(), Arg.Any>()); + } + + [Fact] + public async Task Handle_PermisoInexistente_ThrowsPermisoNotFoundException() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + // Repo devuelve 0 permisos (ningún código matchea en BD) + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List()); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("cajero", new[] { "permiso:inexistente" }))); + + Assert.Equal("permiso:inexistente", ex.Codigo); + } + + [Fact] + public async Task Handle_PartialPermisoMatch_ThrowsPermisoNotFoundForMissing() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + // Solo devuelve 1 de 2 — el segundo no existe + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List { MakePermiso(1, "ventas:contado:crear") }); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("cajero", + new[] { "ventas:contado:crear", "permiso:inexistente" }))); + + Assert.Equal("permiso:inexistente", ex.Codigo); + } + + [Fact] + public async Task Handle_EmptyList_CallsReplaceWithEmptyIds() + { + // Para roles no-admin, lista vacía es válida + _rolRepository.GetByCodigoAsync("reportes").Returns(MakeRol(3, "reportes")); + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List()); + + await _handler.Handle(new AssignPermisosToRolCommand("reportes", new List())); + + await _rolPermisoRepository.Received(1).ReplaceForRolAsync( + 3, + Arg.Is>(ids => !ids.Any())); + } + + [Fact] + public async Task Handle_IdempotentCall_CallsReplaceExactlyOnce() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List { MakePermiso(1, "ventas:contado:crear") }); + + var cmd = new AssignPermisosToRolCommand("cajero", new[] { "ventas:contado:crear" }); + await _handler.Handle(cmd); + await _handler.Handle(cmd); + + await _rolPermisoRepository.Received(2).ReplaceForRolAsync(Arg.Any(), Arg.Any>()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs new file mode 100644 index 0000000..2021183 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs @@ -0,0 +1,91 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.GetByRol; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Permisos.GetByRol; + +public class GetRolPermisosQueryHandlerTests +{ + private readonly IRolRepository _rolRepository = Substitute.For(); + private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For(); + private readonly GetRolPermisosQueryHandler _handler; + + public GetRolPermisosQueryHandlerTests() + { + _handler = new GetRolPermisosQueryHandler(_rolRepository, _rolPermisoRepository); + } + + private static Rol MakeRol(int id, string codigo) => + new(id, codigo, codigo, null, true, DateTime.UtcNow, null); + + private static Permiso MakePermiso(int id, string codigo, string modulo) => + Permiso.ForRead(id, codigo, codigo, null, modulo, true, DateTime.UtcNow); + + [Fact] + public async Task Handle_ExistingRol_ReturnsMappedPermisoDtos() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + _rolPermisoRepository.GetByRolCodigoAsync("cajero").Returns(new List + { + MakePermiso(1, "ventas:contado:crear", "ventas"), + MakePermiso(2, "ventas:contado:cobrar", "ventas"), + MakePermiso(3, "ventas:contado:facturar","ventas"), + MakePermiso(4, "ventas:contado:modificar","ventas"), + }); + + var result = await _handler.Handle(new GetRolPermisosQuery("cajero")); + + Assert.Equal(4, result.Count); + Assert.Contains(result, r => r.Codigo == "ventas:contado:crear"); + } + + [Fact] + public async Task Handle_RolWithNoPermisos_ReturnsEmptyList() + { + _rolRepository.GetByCodigoAsync("reportes").Returns(MakeRol(3, "reportes")); + _rolPermisoRepository.GetByRolCodigoAsync("reportes").Returns(new List()); + + var result = await _handler.Handle(new GetRolPermisosQuery("reportes")); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_NonExistentRol_ThrowsRolNotFoundException() + { + _rolRepository.GetByCodigoAsync("inexistente").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new GetRolPermisosQuery("inexistente"))); + + Assert.Equal("inexistente", ex.Codigo); + } + + [Fact] + public async Task Handle_NonExistentRol_DoesNotCallRolPermisoRepository() + { + _rolRepository.GetByCodigoAsync("ghost").Returns((Rol?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetRolPermisosQuery("ghost"))); + + await _rolPermisoRepository.DidNotReceive().GetByRolCodigoAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_AdminRol_Returns18Permisos() + { + _rolRepository.GetByCodigoAsync("admin").Returns(MakeRol(1, "admin")); + var adminPermisos = Enumerable.Range(1, 18) + .Select(i => MakePermiso(i, $"modulo{i}:accion{i}", $"modulo{i}")) + .ToList(); + _rolPermisoRepository.GetByRolCodigoAsync("admin").Returns(adminPermisos); + + var result = await _handler.Handle(new GetRolPermisosQuery("admin")); + + Assert.Equal(18, result.Count); + } +} diff --git a/tests/SIGCM2.Application.Tests/Permisos/List/ListPermisosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/List/ListPermisosQueryHandlerTests.cs new file mode 100644 index 0000000..18e7fdc --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Permisos/List/ListPermisosQueryHandlerTests.cs @@ -0,0 +1,75 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Permisos.List; + +public class ListPermisosQueryHandlerTests +{ + private readonly IPermisoRepository _repository = Substitute.For(); + private readonly ListPermisosQueryHandler _handler; + + public ListPermisosQueryHandlerTests() + { + _handler = new ListPermisosQueryHandler(_repository); + } + + private static Permiso MakePermiso(int id, string codigo, string nombre, string modulo) => + Permiso.ForRead(id, codigo, nombre, null, modulo, true, DateTime.UtcNow); + + [Fact] + public async Task Handle_ReturnsDtosProjectedFromRepository() + { + _repository.ListAsync().Returns(new List + { + MakePermiso(1, "ventas:contado:crear", "Cargar orden contado", "ventas"), + MakePermiso(2, "textos:editar", "Editar textos", "textos"), + }); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Equal(2, result.Count); + Assert.Equal("ventas:contado:crear", result[0].Codigo); + Assert.Equal("Cargar orden contado", result[0].Nombre); + Assert.Equal("ventas", result[0].Modulo); + Assert.Equal("textos:editar", result[1].Codigo); + } + + [Fact] + public async Task Handle_WithFullCatalog_Returns18Items() + { + var permisos = Enumerable.Range(1, 18) + .Select(i => MakePermiso(i, $"modulo{i}:accion{i}", $"Permiso {i}", $"modulo{i}")) + .ToList(); + _repository.ListAsync().Returns(permisos); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Equal(18, result.Count); + } + + [Fact] + public async Task Handle_EmptyRepository_ReturnsEmptyList() + { + _repository.ListAsync().Returns(new List()); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_NullDescripcion_MappedCorrectly() + { + _repository.ListAsync().Returns(new List + { + Permiso.ForRead(1, "pauta:limpiar", "Limpieza de pauta", null, "pauta", true, DateTime.UtcNow), + }); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Single(result); + Assert.Null(result[0].Descripcion); + } +}