Compare commits
8 Commits
4d3e55c422
...
1a864e9f8b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a864e9f8b | |||
| 885a8cef17 | |||
| 4913a35d06 | |||
| be2257a9bf | |||
| 704794a2e2 | |||
| 7ddb71c24c | |||
| 7d2190c37e | |||
| f6ad371de4 |
65
database/migrations/V005__create_permiso.sql
Normal file
65
database/migrations/V005__create_permiso.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
-- V005__create_permiso.sql
|
||||
-- Tabla catálogo de permisos atómicos RBAC (18 permisos iniciales §2.4.2).
|
||||
-- Requerimiento: ejecutar ANTES de V006 (FK PermisoId).
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Permiso', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Permiso (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Permiso PRIMARY KEY,
|
||||
Codigo VARCHAR(60) NOT NULL,
|
||||
Nombre NVARCHAR(100) NOT NULL,
|
||||
Descripcion NVARCHAR(500) NULL,
|
||||
Modulo VARCHAR(30) NOT NULL,
|
||||
Activo BIT NOT NULL CONSTRAINT DF_Permiso_Activo DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Permiso_FC DEFAULT(SYSUTCDATETIME()),
|
||||
CONSTRAINT UQ_Permiso_Codigo UNIQUE (Codigo),
|
||||
-- Formato: segmentos en minúsculas separados por ':', p.ej. ventas:contado:crear
|
||||
-- Usa collation binaria para forzar case-sensitivity (igual que CK_Rol_Codigo_Format).
|
||||
CONSTRAINT CK_Permiso_Codigo_Format CHECK (
|
||||
PATINDEX('[a-z]%', Codigo COLLATE Latin1_General_BIN2) = 1
|
||||
AND PATINDEX('%[^a-z0-9_:]%', Codigo COLLATE Latin1_General_BIN2) = 0
|
||||
)
|
||||
);
|
||||
PRINT 'Table dbo.Permiso created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.Permiso already exists — skip.';
|
||||
GO
|
||||
|
||||
-- Seed 18 permisos canónicos (idempotente via MERGE).
|
||||
-- Convención RBAC: cada permiso nuevo → asignar a admin en la misma migración (V006+).
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
|
||||
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
|
||||
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
|
||||
('textos:editar', N'Editar textos', NULL, 'textos'),
|
||||
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
|
||||
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
|
||||
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
|
||||
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
|
||||
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
|
||||
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
|
||||
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
|
||||
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
|
||||
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
|
||||
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuración de medios', 'administracion'),
|
||||
('administracion:auditoria:ver', N'Ver logs de auditoría', N'Acceso al dashboard de auditoría', 'administracion')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
GO
|
||||
|
||||
PRINT 'Permiso seeds applied (18 permisos).';
|
||||
GO
|
||||
96
database/migrations/V006__create_rol_permiso.sql
Normal file
96
database/migrations/V006__create_rol_permiso.sql
Normal file
@@ -0,0 +1,96 @@
|
||||
-- V006__create_rol_permiso.sql
|
||||
-- Tabla M:N Rol ↔ Permiso + seed inicial según matriz §2.4.2.
|
||||
-- Requiere: V003 (dbo.Rol), V005 (dbo.Permiso).
|
||||
-- Convención RBAC: cada permiso nuevo → asignar explícitamente a admin en la misma migración.
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.RolPermiso', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.RolPermiso (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_RolPermiso PRIMARY KEY,
|
||||
RolId INT NOT NULL CONSTRAINT FK_RolPermiso_Rol REFERENCES dbo.Rol(Id) ON DELETE CASCADE,
|
||||
PermisoId INT NOT NULL CONSTRAINT FK_RolPermiso_Permiso REFERENCES dbo.Permiso(Id) ON DELETE CASCADE,
|
||||
FechaAsignacion DATETIME2(3) NOT NULL CONSTRAINT DF_RolPermiso_FA DEFAULT(SYSUTCDATETIME()),
|
||||
CONSTRAINT UQ_RolPermiso UNIQUE (RolId, PermisoId)
|
||||
);
|
||||
CREATE INDEX IX_RolPermiso_RolId ON dbo.RolPermiso (RolId);
|
||||
CREATE INDEX IX_RolPermiso_PermisoId ON dbo.RolPermiso (PermisoId);
|
||||
PRINT 'Table dbo.RolPermiso created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.RolPermiso already exists — skip.';
|
||||
GO
|
||||
|
||||
-- Seed: mapeo rol → permisos según matriz §2.4.2
|
||||
-- admin: 18 permisos (explícito — sin wildcard, convención RBAC)
|
||||
-- cajero: 4 permisos (ventas contado)
|
||||
-- operador_ctacte: 2 permisos (ventas ctacte)
|
||||
-- picadora: 2 permisos (textos)
|
||||
-- jefe_publicidad: 7 permisos (textos + pauta + productores)
|
||||
-- productor: 2 permisos (productores)
|
||||
-- diagramacion: 1 permiso (pauta:azanu:ver)
|
||||
-- reportes: 0 permisos (solo lectura reportes — sin permisos en este catálogo)
|
||||
-- Total rows: 36
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
-- admin (18 permisos)
|
||||
('admin', 'ventas:contado:crear'),
|
||||
('admin', 'ventas:contado:modificar'),
|
||||
('admin', 'ventas:contado:cobrar'),
|
||||
('admin', 'ventas:contado:facturar'),
|
||||
('admin', 'ventas:ctacte:crear'),
|
||||
('admin', 'ventas:ctacte:facturar'),
|
||||
('admin', 'textos:editar'),
|
||||
('admin', 'textos:reclamos:ver'),
|
||||
('admin', 'pauta:azanu:ver'),
|
||||
('admin', 'pauta:limpiar'),
|
||||
('admin', 'pauta:recursos:fueradehora'),
|
||||
('admin', 'productores:deuda:ver'),
|
||||
('admin', 'productores:pendientes:crear'),
|
||||
('admin', 'productores:deuda:bypass'),
|
||||
('admin', 'administracion:usuarios:gestionar'),
|
||||
('admin', 'administracion:tarifarios:gestionar'),
|
||||
('admin', 'administracion:medios:gestionar'),
|
||||
('admin', 'administracion:auditoria:ver'),
|
||||
-- cajero (4 permisos)
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
('cajero', 'ventas:contado:facturar'),
|
||||
-- operador_ctacte (2 permisos)
|
||||
('operador_ctacte', 'ventas:ctacte:crear'),
|
||||
('operador_ctacte', 'ventas:ctacte:facturar'),
|
||||
-- picadora (2 permisos)
|
||||
('picadora', 'textos:editar'),
|
||||
('picadora', 'textos:reclamos:ver'),
|
||||
-- jefe_publicidad (7 permisos)
|
||||
('jefe_publicidad', 'textos:editar'),
|
||||
('jefe_publicidad', 'textos:reclamos:ver'),
|
||||
('jefe_publicidad', 'pauta:azanu:ver'),
|
||||
('jefe_publicidad', 'pauta:limpiar'),
|
||||
('jefe_publicidad', 'pauta:recursos:fueradehora'),
|
||||
('jefe_publicidad', 'productores:deuda:ver'),
|
||||
('jefe_publicidad', 'productores:deuda:bypass'),
|
||||
-- productor (2 permisos)
|
||||
('productor', 'productores:deuda:ver'),
|
||||
('productor', 'productores:pendientes:crear'),
|
||||
-- diagramacion (1 permiso)
|
||||
('diagramacion', 'pauta:azanu:ver')
|
||||
-- reportes: 0 permisos — no filas
|
||||
) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
GO
|
||||
|
||||
PRINT 'RolPermiso seeds applied (36 rows: admin×18 + cajero×4 + operador_ctacte×2 + picadora×2 + jefe_publicidad×7 + productor×2 + diagramacion×1).';
|
||||
GO
|
||||
96
src/api/SIGCM2.Api/Controllers/PermisosController.cs
Normal file
96
src/api/SIGCM2.Api/Controllers/PermisosController.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Permisos.Assign;
|
||||
using SIGCM2.Application.Permisos.Dtos;
|
||||
using SIGCM2.Application.Permisos.GetByRol;
|
||||
using SIGCM2.Application.Permisos.List;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1")]
|
||||
[Authorize(Roles = "admin")]
|
||||
public sealed class PermisosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<AssignPermisosToRolCommand> _assignValidator;
|
||||
private readonly IValidator<GetRolPermisosQuery> _getRolPermisosValidator;
|
||||
|
||||
public PermisosController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<AssignPermisosToRolCommand> assignValidator,
|
||||
IValidator<GetRolPermisosQuery> getRolPermisosValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_assignValidator = assignValidator;
|
||||
_getRolPermisosValidator = getRolPermisosValidator;
|
||||
}
|
||||
|
||||
/// <summary>Lists all permisos in the canonical catalog. Requires admin role.</summary>
|
||||
[HttpGet("permisos")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> ListPermisos()
|
||||
{
|
||||
var result = await _dispatcher.Send<ListPermisosQuery, IReadOnlyList<PermisoDto>>(new ListPermisosQuery());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets all permisos assigned to a rol. Requires admin role.</summary>
|
||||
[HttpGet("roles/{codigo}/permisos")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetRolPermisos(string codigo)
|
||||
{
|
||||
var query = new GetRolPermisosQuery(codigo);
|
||||
var validation = await _getRolPermisosValidator.ValidateAsync(query);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace-set: replaces the full permiso assignment for a rol.
|
||||
/// Returns the updated permiso set (200). Requires admin role.
|
||||
/// </summary>
|
||||
[HttpPut("roles/{codigo}/permisos")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AssignPermisos(string codigo, [FromBody] AssignPermisosRequest request)
|
||||
{
|
||||
var codigos = request.Codigos ?? [];
|
||||
var command = new AssignPermisosToRolCommand(
|
||||
RolCodigo: codigo,
|
||||
Codigos: codigos);
|
||||
|
||||
var validation = await _assignValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AssignPermisosRequest(IReadOnlyList<string>? Codigos);
|
||||
@@ -83,6 +83,18 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case PermisoNotFoundException permisoNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "permiso_not_found",
|
||||
message = permisoNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RolAlreadyExistsException rolExistsEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IPermisoRepository
|
||||
{
|
||||
Task<IReadOnlyList<Permiso>> ListAsync(CancellationToken ct = default);
|
||||
Task<Permiso?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Permiso>> GetByCodigosAsync(IEnumerable<string> codigos, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IRolPermisoRepository
|
||||
{
|
||||
Task<IReadOnlyList<Permiso>> GetByRolCodigoAsync(string rolCodigo, CancellationToken ct = default);
|
||||
Task ReplaceForRolAsync(int rolId, IEnumerable<int> permisoIds, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateRolCommand, RolDto>, DeactivateRolCommandHandler>();
|
||||
|
||||
// Permisos (UDT-005)
|
||||
services.AddScoped<ICommandHandler<ListPermisosQuery, IReadOnlyList<PermisoDto>>, ListPermisosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace SIGCM2.Application.Permisos.Assign;
|
||||
|
||||
public sealed record AssignPermisosToRolCommand(
|
||||
string RolCodigo,
|
||||
IReadOnlyList<string> Codigos);
|
||||
@@ -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<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>
|
||||
{
|
||||
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<IReadOnlyList<PermisoDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Domain.Permissions;
|
||||
|
||||
namespace SIGCM2.Application.Permisos.Assign;
|
||||
|
||||
public sealed class AssignPermisosToRolCommandValidator : AbstractValidator<AssignPermisosToRolCommand>
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
8
src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs
Normal file
8
src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.Permisos.Dtos;
|
||||
|
||||
public sealed record PermisoDto(
|
||||
int Id,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
string Modulo);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Permisos.GetByRol;
|
||||
|
||||
public sealed record GetRolPermisosQuery(string RolCodigo);
|
||||
@@ -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<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>
|
||||
{
|
||||
private readonly IRolRepository _rolRepository;
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||
|
||||
public GetRolPermisosQueryHandler(IRolRepository rolRepository, IRolPermisoRepository rolPermisoRepository)
|
||||
{
|
||||
_rolRepository = rolRepository;
|
||||
_rolPermisoRepository = rolPermisoRepository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermisoDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.Permisos.GetByRol;
|
||||
|
||||
public sealed class GetRolPermisosQueryValidator : AbstractValidator<GetRolPermisosQuery>
|
||||
{
|
||||
public GetRolPermisosQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.RolCodigo)
|
||||
.NotEmpty().WithMessage("El código del rol es requerido.")
|
||||
.Matches(@"^[a-z][a-z0-9_]*$")
|
||||
.WithMessage("El código del rol debe empezar con una letra minúscula y contener solo minúsculas, dígitos o guion bajo.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Permisos.List;
|
||||
|
||||
public sealed record ListPermisosQuery();
|
||||
@@ -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<ListPermisosQuery, IReadOnlyList<PermisoDto>>
|
||||
{
|
||||
private readonly IPermisoRepository _repository;
|
||||
|
||||
public ListPermisosQueryHandler(IPermisoRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermisoDto>> 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();
|
||||
}
|
||||
}
|
||||
49
src/api/SIGCM2.Domain/Entities/Permiso.cs
Normal file
49
src/api/SIGCM2.Domain/Entities/Permiso.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace SIGCM2.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Entidad de dominio que representa un permiso atómico del catálogo RBAC.
|
||||
/// Inmutable — solo se puede leer desde BD (sin creación por API).
|
||||
/// </summary>
|
||||
public sealed class Permiso
|
||||
{
|
||||
public int Id { get; }
|
||||
public string Codigo { get; }
|
||||
public string Nombre { get; }
|
||||
public string? Descripcion { get; }
|
||||
public string Modulo { get; }
|
||||
public bool Activo { get; }
|
||||
public DateTime FechaCreacion { get; }
|
||||
|
||||
private Permiso(
|
||||
int id,
|
||||
string codigo,
|
||||
string nombre,
|
||||
string? descripcion,
|
||||
string modulo,
|
||||
bool activo,
|
||||
DateTime fechaCreacion)
|
||||
{
|
||||
Id = id;
|
||||
Codigo = codigo;
|
||||
Nombre = nombre;
|
||||
Descripcion = descripcion;
|
||||
Modulo = modulo;
|
||||
Activo = activo;
|
||||
FechaCreacion = fechaCreacion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory para hidratación desde BD (read-only — el catálogo solo se modifica vía migraciones).
|
||||
/// </summary>
|
||||
public static Permiso ForRead(
|
||||
int id,
|
||||
string codigo,
|
||||
string nombre,
|
||||
string? descripcion,
|
||||
string modulo,
|
||||
bool activo,
|
||||
DateTime fechaCreacion)
|
||||
{
|
||||
return new Permiso(id, codigo, nombre, descripcion, modulo, activo, fechaCreacion);
|
||||
}
|
||||
}
|
||||
12
src/api/SIGCM2.Domain/Exceptions/PermisoNotFoundException.cs
Normal file
12
src/api/SIGCM2.Domain/Exceptions/PermisoNotFoundException.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
public sealed class PermisoNotFoundException : Exception
|
||||
{
|
||||
public string Codigo { get; }
|
||||
|
||||
public PermisoNotFoundException(string codigo)
|
||||
: base($"El permiso '{codigo}' no existe.")
|
||||
{
|
||||
Codigo = codigo;
|
||||
}
|
||||
}
|
||||
53
src/api/SIGCM2.Domain/Permissions/Permiso.cs
Normal file
53
src/api/SIGCM2.Domain/Permissions/Permiso.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace SIGCM2.Domain.Permissions;
|
||||
|
||||
/// <summary>
|
||||
/// Catálogo canónico de permisos RBAC del sistema.
|
||||
/// Source of truth: cada código aquí debe tener su fila en dbo.Permiso vía migración.
|
||||
/// Convención: al agregar un permiso nuevo → también asignarlo al rol admin en la misma migración.
|
||||
/// </summary>
|
||||
public static class Permiso
|
||||
{
|
||||
// ── Ventas: contado ──────────────────────────────────────────────────────
|
||||
public const string VentasContadoCrear = "ventas:contado:crear";
|
||||
public const string VentasContadoModificar = "ventas:contado:modificar";
|
||||
public const string VentasContadoCobrar = "ventas:contado:cobrar";
|
||||
public const string VentasContadoFacturar = "ventas:contado:facturar";
|
||||
|
||||
// ── Ventas: cuenta corriente ─────────────────────────────────────────────
|
||||
public const string VentasCtacteCrear = "ventas:ctacte:crear";
|
||||
public const string VentasCtacteFacturar = "ventas:ctacte:facturar";
|
||||
|
||||
// ── Textos ───────────────────────────────────────────────────────────────
|
||||
public const string TextosEditar = "textos:editar";
|
||||
public const string TextosReclamosVer = "textos:reclamos:ver";
|
||||
|
||||
// ── Pauta ────────────────────────────────────────────────────────────────
|
||||
public const string PautaAzanuVer = "pauta:azanu:ver";
|
||||
public const string PautaLimpiar = "pauta:limpiar";
|
||||
public const string PautaRecursosFueraDeHora = "pauta:recursos:fueradehora";
|
||||
|
||||
// ── Productores ──────────────────────────────────────────────────────────
|
||||
public const string ProductoresDeudaVer = "productores:deuda:ver";
|
||||
public const string ProductoresPendientesCrear = "productores:pendientes:crear";
|
||||
public const string ProductoresDeudaBypass = "productores:deuda:bypass";
|
||||
|
||||
// ── Administración ───────────────────────────────────────────────────────
|
||||
public const string AdministracionUsuariosGestionar = "administracion:usuarios:gestionar";
|
||||
public const string AdministracionTarifariosGestionar = "administracion:tarifarios:gestionar";
|
||||
public const string AdministracionMediosGestionar = "administracion:medios:gestionar";
|
||||
public const string AdministracionAuditoriaVer = "administracion:auditoria:ver";
|
||||
|
||||
/// <summary>
|
||||
/// Set completo de todos los códigos canónicos (útil para validación y seeds).
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> Todos = new HashSet<string>
|
||||
{
|
||||
VentasContadoCrear, VentasContadoModificar, VentasContadoCobrar, VentasContadoFacturar,
|
||||
VentasCtacteCrear, VentasCtacteFacturar,
|
||||
TextosEditar, TextosReclamosVer,
|
||||
PautaAzanuVer, PautaLimpiar, PautaRecursosFueraDeHora,
|
||||
ProductoresDeudaVer, ProductoresPendientesCrear, ProductoresDeudaBypass,
|
||||
AdministracionUsuariosGestionar, AdministracionTarifariosGestionar,
|
||||
AdministracionMediosGestionar, AdministracionAuditoriaVer,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
services.AddScoped<IRolRepository, RolRepository>();
|
||||
services.AddScoped<IPermisoRepository, PermisoRepository>();
|
||||
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
public sealed class PermisoRepository : IPermisoRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _connectionFactory;
|
||||
|
||||
public PermisoRepository(SqlConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Permiso>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Codigo, Nombre, Descripcion, Modulo, Activo, FechaCreacion
|
||||
FROM dbo.Permiso
|
||||
ORDER BY Id
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.QueryAsync<PermisoRow>(sql);
|
||||
return rows.Select(MapRow).ToList();
|
||||
}
|
||||
|
||||
public async Task<Permiso?> GetByCodigoAsync(string codigo, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Codigo, Nombre, Descripcion, Modulo, Activo, FechaCreacion
|
||||
FROM dbo.Permiso
|
||||
WHERE Codigo = @Codigo
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var row = await connection.QuerySingleOrDefaultAsync<PermisoRow>(sql, new { Codigo = codigo });
|
||||
return row is null ? null : MapRow(row);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Permiso>> GetByCodigosAsync(
|
||||
IEnumerable<string> codigos,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var codigosList = codigos.ToList();
|
||||
if (codigosList.Count == 0)
|
||||
return Array.Empty<Permiso>();
|
||||
|
||||
const string sql = """
|
||||
SELECT Id, Codigo, Nombre, Descripcion, Modulo, Activo, FechaCreacion
|
||||
FROM dbo.Permiso
|
||||
WHERE Codigo IN @Codigos
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.QueryAsync<PermisoRow>(sql, new { Codigos = codigosList });
|
||||
return rows.Select(MapRow).ToList();
|
||||
}
|
||||
|
||||
private static Permiso MapRow(PermisoRow row)
|
||||
=> Permiso.ForRead(
|
||||
id: row.Id,
|
||||
codigo: row.Codigo,
|
||||
nombre: row.Nombre,
|
||||
descripcion: row.Descripcion,
|
||||
modulo: row.Modulo,
|
||||
activo: row.Activo,
|
||||
fechaCreacion: row.FechaCreacion);
|
||||
|
||||
private sealed record PermisoRow(
|
||||
int Id,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
string Modulo,
|
||||
bool Activo,
|
||||
DateTime FechaCreacion);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
public sealed class RolPermisoRepository : IRolPermisoRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _connectionFactory;
|
||||
|
||||
public RolPermisoRepository(SqlConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Permiso>> GetByRolCodigoAsync(
|
||||
string rolCodigo,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo, p.Activo, p.FechaCreacion
|
||||
FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Rol r ON r.Id = rp.RolId
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE r.Codigo = @RolCodigo
|
||||
ORDER BY p.Id
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.QueryAsync<PermisoRow>(sql, new { RolCodigo = rolCodigo });
|
||||
return rows.Select(MapRow).ToList();
|
||||
}
|
||||
|
||||
public async Task ReplaceForRolAsync(
|
||||
int rolId,
|
||||
IEnumerable<int> permisoIds,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var ids = permisoIds.ToList();
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
try
|
||||
{
|
||||
// Step 1: Delete all existing permisos for this rol
|
||||
await connection.ExecuteAsync(
|
||||
"DELETE FROM dbo.RolPermiso WHERE RolId = @RolId",
|
||||
new { RolId = rolId },
|
||||
transaction);
|
||||
|
||||
// Step 2: Insert the new set (bulk via multi-row VALUES)
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
foreach (var permisoId in ids)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO dbo.RolPermiso (RolId, PermisoId)
|
||||
VALUES (@RolId, @PermisoId)
|
||||
""",
|
||||
new { RolId = rolId, PermisoId = permisoId },
|
||||
transaction);
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Permiso MapRow(PermisoRow row)
|
||||
=> Permiso.ForRead(
|
||||
id: row.Id,
|
||||
codigo: row.Codigo,
|
||||
nombre: row.Nombre,
|
||||
descripcion: row.Descripcion,
|
||||
modulo: row.Modulo,
|
||||
activo: row.Activo,
|
||||
fechaCreacion: row.FechaCreacion);
|
||||
|
||||
private sealed record PermisoRow(
|
||||
int Id,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
string Modulo,
|
||||
bool Activo,
|
||||
DateTime FechaCreacion);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Settings,
|
||||
UserPlus,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -117,6 +118,18 @@ export function SidebarNav() {
|
||||
<ShieldCheck className="h-4 w-4 shrink-0" />
|
||||
<span>Roles</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/permisos"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname.startsWith('/admin/permisos')
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 shrink-0" />
|
||||
<span>Permisos</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
12
src/web/src/features/permisos/api/assignPermisos.ts
Normal file
12
src/web/src/features/permisos/api/assignPermisos.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
import type { AssignPermisosRequest } from './types'
|
||||
|
||||
export async function assignPermisos(
|
||||
rolCodigo: string,
|
||||
payload: AssignPermisosRequest,
|
||||
): Promise<void> {
|
||||
await axiosClient.put(
|
||||
`/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
9
src/web/src/features/permisos/api/getRolPermisos.ts
Normal file
9
src/web/src/features/permisos/api/getRolPermisos.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
import type { PermisoDto } from './types'
|
||||
|
||||
export async function getRolPermisos(rolCodigo: string): Promise<PermisoDto[]> {
|
||||
const response = await axiosClient.get<PermisoDto[]>(
|
||||
`/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
7
src/web/src/features/permisos/api/listPermisos.ts
Normal file
7
src/web/src/features/permisos/api/listPermisos.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
import type { PermisoDto } from './types'
|
||||
|
||||
export async function listPermisos(): Promise<PermisoDto[]> {
|
||||
const response = await axiosClient.get<PermisoDto[]>('/api/v1/permisos')
|
||||
return response.data
|
||||
}
|
||||
11
src/web/src/features/permisos/api/types.ts
Normal file
11
src/web/src/features/permisos/api/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface PermisoDto {
|
||||
id: number
|
||||
codigo: string
|
||||
nombre: string
|
||||
descripcion: string | null
|
||||
modulo: string
|
||||
}
|
||||
|
||||
export interface AssignPermisosRequest {
|
||||
codigos: string[]
|
||||
}
|
||||
140
src/web/src/features/permisos/components/RolPermisosEditor.tsx
Normal file
140
src/web/src/features/permisos/components/RolPermisosEditor.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { usePermisos } from '../hooks/usePermisos'
|
||||
import { useRolPermisos } from '../hooks/useRolPermisos'
|
||||
import { useAssignPermisos } from '../hooks/useAssignPermisos'
|
||||
import type { PermisoDto } from '../api/types'
|
||||
|
||||
interface RolPermisosEditorProps {
|
||||
rolCodigo: string
|
||||
}
|
||||
|
||||
function groupByModulo(permisos: PermisoDto[]): Map<string, PermisoDto[]> {
|
||||
const map = new Map<string, PermisoDto[]>()
|
||||
for (const p of permisos) {
|
||||
const modulo = p.codigo.split(':')[0] ?? p.modulo
|
||||
if (!map.has(modulo)) map.set(modulo, [])
|
||||
map.get(modulo)!.push(p)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function RolPermisosEditor({ rolCodigo }: RolPermisosEditorProps) {
|
||||
const { data: catalogo, isLoading: loadingCatalogo, isError: errorCatalogo } = usePermisos()
|
||||
const { data: asignados, isLoading: loadingAsignados, isError: errorAsignados } = useRolPermisos(rolCodigo)
|
||||
const assignMut = useAssignPermisos()
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// Prefill checkboxes cuando lleguen los permisos asignados al rol
|
||||
useEffect(() => {
|
||||
if (asignados) {
|
||||
setSelected(new Set(asignados.map((p) => p.codigo)))
|
||||
setSaved(false)
|
||||
}
|
||||
}, [asignados])
|
||||
|
||||
if (loadingCatalogo || loadingAsignados) {
|
||||
return <p className="text-sm text-muted-foreground">Cargando permisos...</p>
|
||||
}
|
||||
|
||||
if (errorCatalogo || errorAsignados) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Error al cargar los permisos del rol.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!catalogo || catalogo.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No hay permisos registrados en el sistema.</p>
|
||||
}
|
||||
|
||||
const grupos = groupByModulo(catalogo)
|
||||
|
||||
function toggle(codigo: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(codigo)) next.delete(codigo)
|
||||
else next.add(codigo)
|
||||
return next
|
||||
})
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
assignMut.mutate(
|
||||
{ rolCodigo, payload: { codigos: Array.from(selected) } },
|
||||
{
|
||||
onSuccess: () => setSaved(true),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const saveErrMsg = assignMut.error
|
||||
? isAxiosError(assignMut.error)
|
||||
? assignMut.error.response?.status === 400
|
||||
? 'El rol "admin" no puede quedar sin permisos.'
|
||||
: (assignMut.error.response?.data as { message?: string } | undefined)?.message ??
|
||||
'No se pudieron guardar los cambios.'
|
||||
: 'No se pudieron guardar los cambios.'
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{saveErrMsg && (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{saveErrMsg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<Alert role="status">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertDescription>Permisos guardados correctamente.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{Array.from(grupos.entries()).map(([modulo, permisos]) => (
|
||||
<section key={modulo}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3 capitalize">
|
||||
{modulo}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{permisos.map((p) => (
|
||||
<label
|
||||
key={p.codigo}
|
||||
title={p.descripcion ?? p.codigo}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(p.codigo)}
|
||||
onChange={() => toggle(p.codigo)}
|
||||
className="h-4 w-4 accent-primary shrink-0"
|
||||
aria-label={p.nombre}
|
||||
/>
|
||||
<span className="flex-1 truncate">{p.nombre}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground/70 hidden lg:block shrink-0">
|
||||
{p.codigo}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={assignMut.isPending}>
|
||||
{assignMut.isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/web/src/features/permisos/hooks/useAssignPermisos.ts
Normal file
23
src/web/src/features/permisos/hooks/useAssignPermisos.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { assignPermisos } from '../api/assignPermisos'
|
||||
import { permisosQueryKey } from './usePermisos'
|
||||
import { rolPermisosQueryKey } from './useRolPermisos'
|
||||
import type { AssignPermisosRequest } from '../api/types'
|
||||
|
||||
interface AssignPermisosVariables {
|
||||
rolCodigo: string
|
||||
payload: AssignPermisosRequest
|
||||
}
|
||||
|
||||
export function useAssignPermisos() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ rolCodigo, payload }: AssignPermisosVariables) =>
|
||||
assignPermisos(rolCodigo, payload),
|
||||
onSuccess: (_data, { rolCodigo }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: permisosQueryKey })
|
||||
void queryClient.invalidateQueries({ queryKey: rolPermisosQueryKey(rolCodigo) })
|
||||
},
|
||||
})
|
||||
}
|
||||
12
src/web/src/features/permisos/hooks/usePermisos.ts
Normal file
12
src/web/src/features/permisos/hooks/usePermisos.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listPermisos } from '../api/listPermisos'
|
||||
|
||||
export const permisosQueryKey = ['permisos'] as const
|
||||
|
||||
export function usePermisos() {
|
||||
return useQuery({
|
||||
queryKey: permisosQueryKey,
|
||||
queryFn: listPermisos,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
15
src/web/src/features/permisos/hooks/useRolPermisos.ts
Normal file
15
src/web/src/features/permisos/hooks/useRolPermisos.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRolPermisos } from '../api/getRolPermisos'
|
||||
|
||||
export function rolPermisosQueryKey(rolCodigo: string) {
|
||||
return ['permisos', 'rol', rolCodigo] as const
|
||||
}
|
||||
|
||||
export function useRolPermisos(rolCodigo: string | null) {
|
||||
return useQuery({
|
||||
queryKey: rolPermisosQueryKey(rolCodigo ?? ''),
|
||||
queryFn: () => getRolPermisos(rolCodigo!),
|
||||
enabled: rolCodigo !== null && rolCodigo.length > 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
77
src/web/src/features/permisos/pages/RolPermisosPage.tsx
Normal file
77
src/web/src/features/permisos/pages/RolPermisosPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { useRoles } from '../../roles/hooks/useRoles'
|
||||
import { RolPermisosEditor } from '../components/RolPermisosEditor'
|
||||
|
||||
export function RolPermisosPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [selectedRol, setSelectedRol] = useState<string | null>(null)
|
||||
|
||||
const { data: roles, isLoading: loadingRoles } = useRoles()
|
||||
|
||||
if (!user || user.rol !== 'admin') {
|
||||
void navigate('/', { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
const rolesActivos = roles?.filter((r) => r.activo) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-5xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Permisos por rol</CardTitle>
|
||||
<CardDescription>
|
||||
Seleccioná un rol para ver y editar sus permisos. Los cambios se aplican inmediatamente al guardar.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Selector de rol */}
|
||||
<div className="flex flex-col gap-1 max-w-xs">
|
||||
<label
|
||||
htmlFor="rol-selector"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Rol
|
||||
</label>
|
||||
{loadingRoles ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando roles...</p>
|
||||
) : (
|
||||
<select
|
||||
id="rol-selector"
|
||||
value={selectedRol ?? ''}
|
||||
onChange={(e) => setSelectedRol(e.target.value || null)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">— Seleccioná un rol —</option>
|
||||
{rolesActivos.map((r) => (
|
||||
<option key={r.codigo} value={r.codigo}>
|
||||
{r.nombre} ({r.codigo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid de permisos */}
|
||||
{selectedRol ? (
|
||||
<RolPermisosEditor rolCodigo={selectedRol} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seleccioná un rol para ver sus permisos.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
||||
import { RolesPage } from './features/roles/pages/RolesPage'
|
||||
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
||||
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -88,6 +89,16 @@ export function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/permisos"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayout>
|
||||
<RolPermisosPage />
|
||||
</ProtectedLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
153
src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx
Normal file
153
src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { RolPermisosEditor } from '../../../features/permisos/components/RolPermisosEditor'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const catalogoPermisos = [
|
||||
{ id: 1, codigo: 'ventas:contado:crear', nombre: 'Crear venta contado', descripcion: 'Permite crear una venta al contado', modulo: 'ventas' },
|
||||
{ id: 2, codigo: 'ventas:contado:anular', nombre: 'Anular venta contado', descripcion: 'Permite anular una venta al contado', modulo: 'ventas' },
|
||||
{ id: 3, codigo: 'reportes:ventas:ver', nombre: 'Ver reporte ventas', descripcion: 'Permite ver el reporte de ventas', modulo: 'reportes' },
|
||||
{ id: 4, codigo: 'admin:usuarios:gestionar', nombre: 'Gestionar usuarios', descripcion: 'Permite crear y editar usuarios', modulo: 'admin' },
|
||||
]
|
||||
|
||||
const rolPermisos = [
|
||||
catalogoPermisos[0]!, // ventas:contado:crear
|
||||
catalogoPermisos[2]!, // reportes:ventas:ver
|
||||
]
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderEditor(rolCodigo = 'cajero') {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<RolPermisosEditor rolCodigo={rolCodigo} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('RolPermisosEditor', () => {
|
||||
it('renders without crash and shows permission checkboxes grouped by module', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
|
||||
)
|
||||
|
||||
renderEditor()
|
||||
|
||||
// Loading state should appear initially
|
||||
expect(screen.getByText(/cargando permisos/i)).toBeInTheDocument()
|
||||
|
||||
// After fetch resolves, show groups
|
||||
await waitFor(() => expect(screen.getByText('ventas')).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText('reportes')).toBeInTheDocument()
|
||||
expect(screen.getByText('admin')).toBeInTheDocument()
|
||||
|
||||
// Shows permission names
|
||||
expect(screen.getByLabelText('Crear venta contado')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Anular venta contado')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Ver reporte ventas')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Gestionar usuarios')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefills checkboxes for already-assigned permissions', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
|
||||
)
|
||||
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('Crear venta contado')).toBeInTheDocument())
|
||||
|
||||
const crearCheckbox = screen.getByLabelText('Crear venta contado') as HTMLInputElement
|
||||
const anularCheckbox = screen.getByLabelText('Anular venta contado') as HTMLInputElement
|
||||
const reporteCheckbox = screen.getByLabelText('Ver reporte ventas') as HTMLInputElement
|
||||
const adminCheckbox = screen.getByLabelText('Gestionar usuarios') as HTMLInputElement
|
||||
|
||||
// cajero has ventas:contado:crear and reportes:ventas:ver assigned
|
||||
expect(crearCheckbox.checked).toBe(true)
|
||||
expect(reporteCheckbox.checked).toBe(true)
|
||||
// not assigned
|
||||
expect(anularCheckbox.checked).toBe(false)
|
||||
expect(adminCheckbox.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles a checkbox on click', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('Anular venta contado')).toBeInTheDocument())
|
||||
|
||||
const anularCheckbox = screen.getByLabelText('Anular venta contado') as HTMLInputElement
|
||||
expect(anularCheckbox.checked).toBe(false)
|
||||
|
||||
// Toggle on
|
||||
await u.click(anularCheckbox)
|
||||
expect(anularCheckbox.checked).toBe(true)
|
||||
|
||||
// Toggle off
|
||||
await u.click(anularCheckbox)
|
||||
expect(anularCheckbox.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('shows success alert after saving permissions', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/roles/cajero/permisos`, () => HttpResponse.json(rolPermisos)),
|
||||
http.put(`${API_URL}/api/v1/roles/cajero/permisos`, () => new HttpResponse(null, { status: 200 })),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderEditor()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||
|
||||
await u.click(screen.getByText('Guardar cambios'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/guardados correctamente/i),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows 400 error message when admin would be left without permissions', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.get(`${API_URL}/api/v1/roles/admin/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||
http.put(`${API_URL}/api/v1/roles/admin/permisos`, () =>
|
||||
HttpResponse.json({ message: 'El rol admin no puede quedar sin permisos' }, { status: 400 }),
|
||||
),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderEditor('admin')
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||
|
||||
await u.click(screen.getByText('Guardar cambios'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/admin.*sin permisos/i),
|
||||
)
|
||||
})
|
||||
})
|
||||
427
tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs
Normal file
427
tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs
Normal file
@@ -0,0 +1,427 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Permisos;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for /api/v1/permisos and /api/v1/roles/{codigo}/permisos (UDT-005).
|
||||
/// RED: written before PermisosController exists.
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private const string TestConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PermisosEndpointTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetBearerTokenAsync(string username, string password)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new { username, password });
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException($"Login failed ({(int)response.StatusCode}): {body}");
|
||||
}
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (bearerToken is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static async Task RestoreCajeroPermisosAsync()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Remove any test-added permisos from cajero
|
||||
await conn.ExecuteAsync("""
|
||||
DELETE rp FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Rol r ON r.Id = rp.RolId
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE r.Codigo = 'cajero'
|
||||
AND p.Codigo NOT IN (
|
||||
'ventas:contado:crear','ventas:contado:modificar',
|
||||
'ventas:contado:cobrar','ventas:contado:facturar'
|
||||
);
|
||||
""");
|
||||
|
||||
// Re-add missing canonical cajero permisos
|
||||
await conn.ExecuteAsync("""
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
('cajero','ventas:contado:crear'),
|
||||
('cajero','ventas:contado:modificar'),
|
||||
('cajero','ventas:contado:cobrar'),
|
||||
('cajero','ventas:contado:facturar')
|
||||
) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
""");
|
||||
}
|
||||
|
||||
private async Task<string> CreateNonAdminUserAndGetTokenAsync(string username, string rol = "cajero")
|
||||
{
|
||||
var adminToken = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
|
||||
// Create non-admin user via API
|
||||
using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new
|
||||
{
|
||||
username,
|
||||
password = "Secure1234!",
|
||||
nombre = "Non",
|
||||
apellido = "Admin",
|
||||
email = (string?)null,
|
||||
rol
|
||||
}, adminToken);
|
||||
var mkUserResp = await _client.SendAsync(mkUser);
|
||||
if (mkUserResp.StatusCode != HttpStatusCode.Created && mkUserResp.StatusCode != HttpStatusCode.Conflict)
|
||||
Assert.Fail($"Seed non-admin user failed: {mkUserResp.StatusCode}");
|
||||
|
||||
return await GetBearerTokenAsync(username, "Secure1234!");
|
||||
}
|
||||
|
||||
private static async Task DeleteUsuarioIfExistsAsync(string username)
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("""
|
||||
DELETE rt FROM dbo.RefreshToken rt
|
||||
INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId
|
||||
WHERE u.Username = @Username
|
||||
""", new { Username = username });
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM dbo.Usuario WHERE Username = @Username",
|
||||
new { Username = username });
|
||||
}
|
||||
|
||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithAdmin_Returns200With18Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(18, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithoutToken_Returns401()
|
||||
{
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, "/api/v1/permisos"));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithNonAdminToken_Returns403()
|
||||
{
|
||||
const string username = "perm_nonadmin_list";
|
||||
try
|
||||
{
|
||||
var token = await CreateNonAdminUserAndGetTokenAsync(username);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioIfExistsAsync(username);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_ResponseContainsCodigoNombreFields()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var first = list.EnumerateArray().First();
|
||||
Assert.True(first.TryGetProperty("codigo", out _), "Response item missing 'codigo' field");
|
||||
Assert.True(first.TryGetProperty("nombre", out _), "Response item missing 'nombre' field");
|
||||
}
|
||||
|
||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With18Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(18, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_CajeroRol_Returns200With4Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/cajero/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(4, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_ReportesRol_Returns200WithEmptyArray()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/reportes/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(0, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_InexistentRol_Returns404()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/rol_inexistente_xyz/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_WithoutToken_Returns401()
|
||||
{
|
||||
var resp = await _client.SendAsync(BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos"));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_WithNonAdminToken_Returns403()
|
||||
{
|
||||
const string username = "perm_nonadmin_getRol";
|
||||
try
|
||||
{
|
||||
var token = await CreateNonAdminUserAndGetTokenAsync(username);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioIfExistsAsync(username);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_InvalidCodigoFormat_Returns400()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
// "ROL-INVALIDO" no matchea ^[a-z][a-z0-9_]*$ (tiene guion y mayúsculas)
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/ROL-INVALIDO/permisos", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_ValidAssignment_Returns200WithUpdatedSet()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
|
||||
try
|
||||
{
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Put,
|
||||
"/api/v1/roles/cajero/permisos",
|
||||
new { codigos = new[] { "ventas:contado:crear" } },
|
||||
token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var list = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(1, list.GetArrayLength());
|
||||
Assert.Equal("ventas:contado:crear", list[0].GetProperty("codigo").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await RestoreCajeroPermisosAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_ThenGet_ReturnsUpdatedSet()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
|
||||
try
|
||||
{
|
||||
// Assign 1 permiso to cajero
|
||||
using var putReq = BuildRequest(
|
||||
HttpMethod.Put,
|
||||
"/api/v1/roles/cajero/permisos",
|
||||
new { codigos = new[] { "textos:editar" } },
|
||||
token);
|
||||
var putResp = await _client.SendAsync(putReq);
|
||||
Assert.Equal(HttpStatusCode.OK, putResp.StatusCode);
|
||||
|
||||
// GET should now return 1 item
|
||||
using var getReq = BuildRequest(HttpMethod.Get, "/api/v1/roles/cajero/permisos", bearerToken: token);
|
||||
var getResp = await _client.SendAsync(getReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, getResp.StatusCode);
|
||||
var list = await getResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(1, list.GetArrayLength());
|
||||
Assert.Equal("textos:editar", list[0].GetProperty("codigo").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await RestoreCajeroPermisosAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_Idempotent_TwoCallsSameResult()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
|
||||
try
|
||||
{
|
||||
var body = new { codigos = new[] { "ventas:contado:crear", "textos:editar" } };
|
||||
|
||||
using var req1 = BuildRequest(HttpMethod.Put, "/api/v1/roles/cajero/permisos", body, token);
|
||||
var resp1 = await _client.SendAsync(req1);
|
||||
Assert.Equal(HttpStatusCode.OK, resp1.StatusCode);
|
||||
|
||||
using var req2 = BuildRequest(HttpMethod.Put, "/api/v1/roles/cajero/permisos", body, token);
|
||||
var resp2 = await _client.SendAsync(req2);
|
||||
Assert.Equal(HttpStatusCode.OK, resp2.StatusCode);
|
||||
|
||||
var list2 = await resp2.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(2, list2.GetArrayLength());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await RestoreCajeroPermisosAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_AdminWithEmptyList_Returns400()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Put,
|
||||
"/api/v1/roles/admin/permisos",
|
||||
new { codigos = Array.Empty<string>() },
|
||||
token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_NonExistentPermiso_Returns404()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Put,
|
||||
"/api/v1/roles/cajero/permisos",
|
||||
new { codigos = new[] { "permiso:no:existe" } },
|
||||
token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
// Validator rejects unknown codes with 400 (not in catalog) before handler can 404
|
||||
// The validator checks Permiso.Todos — if code not in static catalog → 400
|
||||
Assert.True(
|
||||
resp.StatusCode == HttpStatusCode.BadRequest || resp.StatusCode == HttpStatusCode.NotFound,
|
||||
$"Expected 400 or 404 but got {resp.StatusCode}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_InexistentRol_Returns404()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Put,
|
||||
"/api/v1/roles/rol_inexistente_xyz/permisos",
|
||||
new { codigos = new[] { "ventas:contado:crear" } },
|
||||
token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_WithoutToken_Returns401()
|
||||
{
|
||||
var resp = await _client.SendAsync(BuildRequest(
|
||||
HttpMethod.Put,
|
||||
"/api/v1/roles/cajero/permisos",
|
||||
new { codigos = new[] { "ventas:contado:crear" } }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutRolPermisos_WithNonAdminToken_Returns403()
|
||||
{
|
||||
const string username = "perm_nonadmin_put";
|
||||
try
|
||||
{
|
||||
var token = await CreateNonAdminUserAndGetTokenAsync(username);
|
||||
using var req = BuildRequest(
|
||||
HttpMethod.Put,
|
||||
"/api/v1/roles/cajero/permisos",
|
||||
new { codigos = new[] { "ventas:contado:crear" } },
|
||||
token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteUsuarioIfExistsAsync(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,12 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
||||
TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")]
|
||||
TablesToIgnore =
|
||||
[
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
]
|
||||
});
|
||||
|
||||
await _respawner.ResetAsync(_connection);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PermisoRepository against SIGCM2_Test.
|
||||
/// RED: written before the repository implementation exists.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class PermisoRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
|
||||
private SqlConnection _connection = null!;
|
||||
private PermisoRepository _repository = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_connection = new SqlConnection(ConnectionString);
|
||||
await _connection.OpenAsync();
|
||||
|
||||
// Ensure the 18 canonical permisos are present — idempotent MERGE.
|
||||
// Needed because other test classes (RefreshTokenRepositoryTests, UsuarioRepositoryTests)
|
||||
// may call Respawn.ResetAsync before us, which would clear Permiso even if listed in
|
||||
// TablesToIgnore of the central SqlTestFixture (each class configures its own Respawner).
|
||||
await SeedPermisosCanonicalAsync();
|
||||
|
||||
var factory = new SqlConnectionFactory(ConnectionString);
|
||||
_repository = new PermisoRepository(factory);
|
||||
}
|
||||
|
||||
private async Task SeedPermisosCanonicalAsync()
|
||||
{
|
||||
const string sql = """
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
|
||||
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
|
||||
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
|
||||
('textos:editar', N'Editar textos', NULL, 'textos'),
|
||||
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
|
||||
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
|
||||
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
|
||||
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
|
||||
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
|
||||
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
|
||||
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
|
||||
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
|
||||
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
|
||||
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'),
|
||||
('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
""";
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
|
||||
// ── ListAsync ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_Returns18CanonicalSeeds()
|
||||
{
|
||||
var list = await _repository.ListAsync();
|
||||
|
||||
// V005 seeds exactly 18 canonical permisos
|
||||
Assert.Equal(18, list.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ContainsExpectedCodigos()
|
||||
{
|
||||
var list = await _repository.ListAsync();
|
||||
var codigos = list.Select(p => p.Codigo).ToHashSet();
|
||||
|
||||
Assert.Contains("ventas:contado:crear", codigos);
|
||||
Assert.Contains("ventas:contado:facturar", codigos);
|
||||
Assert.Contains("administracion:usuarios:gestionar", codigos);
|
||||
Assert.Contains("administracion:auditoria:ver", codigos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_AllItemsHaveNonEmptyCodigoAndNombre()
|
||||
{
|
||||
var list = await _repository.ListAsync();
|
||||
|
||||
foreach (var p in list)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Codigo));
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Nombre));
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetByCodigoAsync ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigoAsync_ExistingCodigo_ReturnsPermiso()
|
||||
{
|
||||
var permiso = await _repository.GetByCodigoAsync("ventas:contado:crear");
|
||||
|
||||
Assert.NotNull(permiso);
|
||||
Assert.Equal("ventas:contado:crear", permiso!.Codigo);
|
||||
Assert.False(string.IsNullOrWhiteSpace(permiso.Nombre));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigoAsync_AnotherExistingCodigo_ReturnsCorrectPermiso()
|
||||
{
|
||||
var permiso = await _repository.GetByCodigoAsync("administracion:usuarios:gestionar");
|
||||
|
||||
Assert.NotNull(permiso);
|
||||
Assert.Equal("administracion:usuarios:gestionar", permiso!.Codigo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigoAsync_NonExistentCodigo_ReturnsNull()
|
||||
{
|
||||
var permiso = await _repository.GetByCodigoAsync("permiso:inexistente:xyz");
|
||||
|
||||
Assert.Null(permiso);
|
||||
}
|
||||
|
||||
// ── GetByCodigosAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigosAsync_ThreeValidCodigos_ReturnsThreeEntities()
|
||||
{
|
||||
var codigos = new[]
|
||||
{
|
||||
"ventas:contado:crear",
|
||||
"ventas:contado:facturar",
|
||||
"textos:editar"
|
||||
};
|
||||
|
||||
var result = await _repository.GetByCodigosAsync(codigos);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
var returnedCodigos = result.Select(p => p.Codigo).ToHashSet();
|
||||
foreach (var c in codigos)
|
||||
Assert.Contains(c, returnedCodigos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigosAsync_MixedExistingAndNonExisting_ReturnsOnlyExisting()
|
||||
{
|
||||
var codigos = new[]
|
||||
{
|
||||
"ventas:contado:crear",
|
||||
"permiso:no:existe",
|
||||
"textos:editar"
|
||||
};
|
||||
|
||||
var result = await _repository.GetByCodigosAsync(codigos);
|
||||
|
||||
// Only 2 of 3 exist
|
||||
Assert.Equal(2, result.Count);
|
||||
var returnedCodigos = result.Select(p => p.Codigo).ToHashSet();
|
||||
Assert.Contains("ventas:contado:crear", returnedCodigos);
|
||||
Assert.Contains("textos:editar", returnedCodigos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigosAsync_EmptyList_ReturnsEmpty()
|
||||
{
|
||||
var result = await _repository.GetByCodigosAsync(Array.Empty<string>());
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCodigosAsync_AllNonExisting_ReturnsEmpty()
|
||||
{
|
||||
var codigos = new[] { "no:existe:uno", "no:existe:dos" };
|
||||
|
||||
var result = await _repository.GetByCodigosAsync(codigos);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for RolPermisoRepository against SIGCM2_Test.
|
||||
/// RED: written before the repository implementation exists.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class RolPermisoRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConnectionString =
|
||||
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
|
||||
|
||||
private SqlConnection _connection = null!;
|
||||
private RolPermisoRepository _repository = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_connection = new SqlConnection(ConnectionString);
|
||||
await _connection.OpenAsync();
|
||||
|
||||
// Ensure the 18 canonical permisos exist — idempotent MERGE.
|
||||
// Other test classes call Respawn.ResetAsync which may have cleared Permiso.
|
||||
await SeedPermisosCanonicalAsync();
|
||||
|
||||
// Ensure canonical RolPermiso seeds are present.
|
||||
await SeedRolPermisosCanonicalAsync();
|
||||
|
||||
// Restore RolPermiso seeds for 'cajero' (4 permisos) in case prior test modified them.
|
||||
await RestoreCajeroPermisosAsync();
|
||||
|
||||
var factory = new SqlConnectionFactory(ConnectionString);
|
||||
_repository = new RolPermisoRepository(factory);
|
||||
}
|
||||
|
||||
private async Task SeedPermisosCanonicalAsync()
|
||||
{
|
||||
const string sql = """
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
|
||||
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
|
||||
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
|
||||
('textos:editar', N'Editar textos', NULL, 'textos'),
|
||||
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
|
||||
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
|
||||
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
|
||||
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
|
||||
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
|
||||
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
|
||||
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
|
||||
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
|
||||
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
|
||||
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'),
|
||||
('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
""";
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
private async Task SeedRolPermisosCanonicalAsync()
|
||||
{
|
||||
const string sql = """
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
('admin', 'ventas:contado:crear'),
|
||||
('admin', 'ventas:contado:modificar'),
|
||||
('admin', 'ventas:contado:cobrar'),
|
||||
('admin', 'ventas:contado:facturar'),
|
||||
('admin', 'ventas:ctacte:crear'),
|
||||
('admin', 'ventas:ctacte:facturar'),
|
||||
('admin', 'textos:editar'),
|
||||
('admin', 'textos:reclamos:ver'),
|
||||
('admin', 'pauta:azanu:ver'),
|
||||
('admin', 'pauta:limpiar'),
|
||||
('admin', 'pauta:recursos:fueradehora'),
|
||||
('admin', 'productores:deuda:ver'),
|
||||
('admin', 'productores:pendientes:crear'),
|
||||
('admin', 'productores:deuda:bypass'),
|
||||
('admin', 'administracion:usuarios:gestionar'),
|
||||
('admin', 'administracion:tarifarios:gestionar'),
|
||||
('admin', 'administracion:medios:gestionar'),
|
||||
('admin', 'administracion:auditoria:ver'),
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
('cajero', 'ventas:contado:facturar'),
|
||||
('operador_ctacte', 'ventas:ctacte:crear'),
|
||||
('operador_ctacte', 'ventas:ctacte:facturar'),
|
||||
('picadora', 'textos:editar'),
|
||||
('picadora', 'textos:reclamos:ver'),
|
||||
('jefe_publicidad', 'textos:editar'),
|
||||
('jefe_publicidad', 'textos:reclamos:ver'),
|
||||
('jefe_publicidad', 'pauta:azanu:ver'),
|
||||
('jefe_publicidad', 'pauta:limpiar'),
|
||||
('jefe_publicidad', 'pauta:recursos:fueradehora'),
|
||||
('jefe_publicidad', 'productores:deuda:ver'),
|
||||
('jefe_publicidad', 'productores:deuda:bypass'),
|
||||
('productor', 'productores:deuda:ver'),
|
||||
('productor', 'productores:pendientes:crear'),
|
||||
('diagramacion', 'pauta:azanu:ver')
|
||||
) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
""";
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
// Restore cajero permisos so TablesToIgnore still reflects clean seed state.
|
||||
await RestoreCajeroPermisosAsync();
|
||||
await _connection.CloseAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the 4 canonical cajero permisos to match V006 seed state.
|
||||
/// Uses MERGE so it's idempotent.
|
||||
/// </summary>
|
||||
private async Task RestoreCajeroPermisosAsync()
|
||||
{
|
||||
// Delete any extra permisos assigned to cajero by tests
|
||||
await _connection.ExecuteAsync("""
|
||||
DELETE rp FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Rol r ON r.Id = rp.RolId
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE r.Codigo = 'cajero'
|
||||
AND p.Codigo NOT IN (
|
||||
'ventas:contado:crear',
|
||||
'ventas:contado:modificar',
|
||||
'ventas:contado:cobrar',
|
||||
'ventas:contado:facturar'
|
||||
);
|
||||
""");
|
||||
|
||||
// Re-add the 4 canonical cajero permisos if missing
|
||||
await _connection.ExecuteAsync("""
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
('cajero', 'ventas:contado:facturar')
|
||||
) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
""");
|
||||
}
|
||||
|
||||
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_Admin_Returns18Permisos()
|
||||
{
|
||||
// admin has all 18 permisos assigned in V006 seed
|
||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||
|
||||
Assert.Equal(18, permisos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_Admin_ContainsAllModules()
|
||||
{
|
||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||
var codigos = permisos.Select(p => p.Codigo).ToHashSet();
|
||||
|
||||
Assert.Contains("ventas:contado:crear", codigos);
|
||||
Assert.Contains("administracion:auditoria:ver", codigos);
|
||||
Assert.Contains("pauta:limpiar", codigos);
|
||||
Assert.Contains("productores:deuda:bypass", codigos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_Cajero_Returns4Permisos()
|
||||
{
|
||||
// cajero: ventas:contado:crear, :modificar, :cobrar, :facturar
|
||||
var permisos = await _repository.GetByRolCodigoAsync("cajero");
|
||||
|
||||
Assert.Equal(4, permisos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_Cajero_OnlyVentasContadoPermisos()
|
||||
{
|
||||
var permisos = await _repository.GetByRolCodigoAsync("cajero");
|
||||
var codigos = permisos.Select(p => p.Codigo).ToHashSet();
|
||||
|
||||
Assert.Contains("ventas:contado:crear", codigos);
|
||||
Assert.Contains("ventas:contado:modificar", codigos);
|
||||
Assert.Contains("ventas:contado:cobrar", codigos);
|
||||
Assert.Contains("ventas:contado:facturar", codigos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_Reportes_ReturnsEmpty()
|
||||
{
|
||||
// 'reportes' rol has 0 permisos in V006 seed
|
||||
var permisos = await _repository.GetByRolCodigoAsync("reportes");
|
||||
|
||||
Assert.Empty(permisos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_NonExistentRol_ReturnsEmpty()
|
||||
{
|
||||
// Unknown rol código — returns empty list, not an exception
|
||||
var permisos = await _repository.GetByRolCodigoAsync("rol_inexistente_xyz");
|
||||
|
||||
Assert.Empty(permisos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_ReturnsFullPermisoEntities()
|
||||
{
|
||||
var permisos = await _repository.GetByRolCodigoAsync("cajero");
|
||||
|
||||
var primero = permisos.First();
|
||||
// All entity fields must be populated
|
||||
Assert.True(primero.Id > 0);
|
||||
Assert.False(string.IsNullOrWhiteSpace(primero.Codigo));
|
||||
Assert.False(string.IsNullOrWhiteSpace(primero.Nombre));
|
||||
}
|
||||
|
||||
// ── ReplaceForRolAsync ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceForRolAsync_ReplacesExistingSetWithNewSet()
|
||||
{
|
||||
// Get cajero's rol ID and a different permiso ID
|
||||
var cajeroId = await _connection.ExecuteScalarAsync<int>(
|
||||
"SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'");
|
||||
var textoPermisoId = await _connection.ExecuteScalarAsync<int>(
|
||||
"SELECT Id FROM dbo.Permiso WHERE Codigo = 'textos:editar'");
|
||||
|
||||
// Replace cajero's 4 permisos with just 1
|
||||
await _repository.ReplaceForRolAsync(cajeroId, new[] { textoPermisoId });
|
||||
|
||||
var permisos = await _repository.GetByRolCodigoAsync("cajero");
|
||||
Assert.Single(permisos);
|
||||
Assert.Equal("textos:editar", permisos[0].Codigo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceForRolAsync_Idempotent_SameCallTwiceProducesSameResult()
|
||||
{
|
||||
var cajeroId = await _connection.ExecuteScalarAsync<int>(
|
||||
"SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'");
|
||||
var permisoId = await _connection.ExecuteScalarAsync<int>(
|
||||
"SELECT Id FROM dbo.Permiso WHERE Codigo = 'ventas:contado:crear'");
|
||||
|
||||
await _repository.ReplaceForRolAsync(cajeroId, new[] { permisoId });
|
||||
await _repository.ReplaceForRolAsync(cajeroId, new[] { permisoId });
|
||||
|
||||
var permisos = await _repository.GetByRolCodigoAsync("cajero");
|
||||
Assert.Single(permisos);
|
||||
Assert.Equal("ventas:contado:crear", permisos[0].Codigo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceForRolAsync_WithEmptyList_DeletesAllPermisos()
|
||||
{
|
||||
var cajeroId = await _connection.ExecuteScalarAsync<int>(
|
||||
"SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'");
|
||||
|
||||
await _repository.ReplaceForRolAsync(cajeroId, Array.Empty<int>());
|
||||
|
||||
var permisos = await _repository.GetByRolCodigoAsync("cajero");
|
||||
Assert.Empty(permisos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceForRolAsync_PostReplace_GetReflectsNewSet()
|
||||
{
|
||||
var cajeroId = await _connection.ExecuteScalarAsync<int>(
|
||||
"SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'");
|
||||
|
||||
// Get IDs of 2 specific permisos
|
||||
var rows = await _connection.QueryAsync<(int Id, string Codigo)>(
|
||||
"SELECT Id, Codigo FROM dbo.Permiso WHERE Codigo IN ('pauta:azanu:ver', 'pauta:limpiar')");
|
||||
var ids = rows.Select(r => r.Id).ToArray();
|
||||
|
||||
await _repository.ReplaceForRolAsync(cajeroId, ids);
|
||||
|
||||
var permisos = await _repository.GetByRolCodigoAsync("cajero");
|
||||
var codigos = permisos.Select(p => p.Codigo).ToHashSet();
|
||||
|
||||
Assert.Equal(2, permisos.Count);
|
||||
Assert.Contains("pauta:azanu:ver", codigos);
|
||||
Assert.Contains("pauta:limpiar", codigos);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,12 @@ public class UsuarioRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
||||
TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")]
|
||||
TablesToIgnore =
|
||||
[
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
]
|
||||
});
|
||||
|
||||
// Reset DB, re-seed Rol canonical table (lookup) and admin user for each test class run.
|
||||
|
||||
@@ -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<IRolRepository>();
|
||||
private readonly IPermisoRepository _permisoRepository = Substitute.For<IPermisoRepository>();
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For<IRolPermisoRepository>();
|
||||
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<IEnumerable<string>>())
|
||||
.Returns(new List<Permiso> { permisoCrear, permisoFact });
|
||||
|
||||
var codigos = new List<string> { "ventas:contado:crear", "ventas:contado:facturar" };
|
||||
await _handler.Handle(new AssignPermisosToRolCommand("cajero", codigos));
|
||||
|
||||
await _rolPermisoRepository.Received(1).ReplaceForRolAsync(
|
||||
5,
|
||||
Arg.Is<IEnumerable<int>>(ids => ids.SequenceEqual(new[] { 1, 2 })));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RolInexistente_ThrowsRolNotFoundException()
|
||||
{
|
||||
_rolRepository.GetByCodigoAsync("fantasma").Returns((Rol?)null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RolNotFoundException>(
|
||||
() => _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<RolNotFoundException>(
|
||||
() => _handler.Handle(new AssignPermisosToRolCommand("fantasma", new[] { "ventas:contado:crear" })));
|
||||
|
||||
await _rolPermisoRepository.DidNotReceive().ReplaceForRolAsync(Arg.Any<int>(), Arg.Any<IEnumerable<int>>());
|
||||
}
|
||||
|
||||
[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<IEnumerable<string>>())
|
||||
.Returns(new List<Permiso>());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<PermisoNotFoundException>(
|
||||
() => _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<IEnumerable<string>>())
|
||||
.Returns(new List<Permiso> { MakePermiso(1, "ventas:contado:crear") });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<PermisoNotFoundException>(
|
||||
() => _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<IEnumerable<string>>())
|
||||
.Returns(new List<Permiso>());
|
||||
|
||||
await _handler.Handle(new AssignPermisosToRolCommand("reportes", new List<string>()));
|
||||
|
||||
await _rolPermisoRepository.Received(1).ReplaceForRolAsync(
|
||||
3,
|
||||
Arg.Is<IEnumerable<int>>(ids => !ids.Any()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_IdempotentCall_CallsReplaceExactlyOnce()
|
||||
{
|
||||
_rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero"));
|
||||
_permisoRepository.GetByCodigosAsync(Arg.Any<IEnumerable<string>>())
|
||||
.Returns(new List<Permiso> { 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<int>(), Arg.Any<IEnumerable<int>>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using FluentValidation;
|
||||
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<IRolRepository>();
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For<IRolPermisoRepository>();
|
||||
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<Permiso>
|
||||
{
|
||||
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<Permiso>());
|
||||
|
||||
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<RolNotFoundException>(
|
||||
() => _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<RolNotFoundException>(
|
||||
() => _handler.Handle(new GetRolPermisosQuery("ghost")));
|
||||
|
||||
await _rolPermisoRepository.DidNotReceive().GetByRolCodigoAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
public class GetRolPermisosQueryValidatorTests
|
||||
{
|
||||
private readonly IValidator<GetRolPermisosQuery> _validator =
|
||||
new GetRolPermisosQueryValidator();
|
||||
|
||||
[Theory]
|
||||
[InlineData("ROL-INVALIDO")]
|
||||
[InlineData("ROL:INVALIDO")]
|
||||
[InlineData("123abc")]
|
||||
[InlineData("UPPER")]
|
||||
[InlineData("con espacio")]
|
||||
[InlineData("")]
|
||||
public async Task Validate_InvalidCodigoFormat_ReturnsInvalid(string codigo)
|
||||
{
|
||||
var result = await _validator.ValidateAsync(new GetRolPermisosQuery(codigo));
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin")]
|
||||
[InlineData("cajero")]
|
||||
[InlineData("rol_valido")]
|
||||
[InlineData("abc123")]
|
||||
public async Task Validate_ValidCodigoFormat_ReturnsValid(string codigo)
|
||||
{
|
||||
var result = await _validator.ValidateAsync(new GetRolPermisosQuery(codigo));
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -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<IPermisoRepository>();
|
||||
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<Permiso>
|
||||
{
|
||||
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<Permiso>());
|
||||
|
||||
var result = await _handler.Handle(new ListPermisosQuery());
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NullDescripcion_MappedCorrectly()
|
||||
{
|
||||
_repository.ListAsync().Returns(new List<Permiso>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,13 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
||||
TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")]
|
||||
// Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog.
|
||||
TablesToIgnore =
|
||||
[
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
]
|
||||
});
|
||||
|
||||
await ResetAndSeedAsync();
|
||||
@@ -40,6 +46,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
{
|
||||
await _respawner.ResetAsync(_connection);
|
||||
await SeedRolCanonicalAsync();
|
||||
await SeedPermisosCanonicalAsync();
|
||||
await SeedRolPermisosCanonicalAsync();
|
||||
await SeedAdminAsync();
|
||||
}
|
||||
|
||||
@@ -75,6 +83,93 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SeedPermisosCanonicalAsync()
|
||||
{
|
||||
const string sql = """
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
|
||||
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
|
||||
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
|
||||
('textos:editar', N'Editar textos', NULL, 'textos'),
|
||||
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
|
||||
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
|
||||
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
|
||||
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
|
||||
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
|
||||
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
|
||||
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
|
||||
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
|
||||
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
|
||||
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'),
|
||||
('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
""";
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
private async Task SeedRolPermisosCanonicalAsync()
|
||||
{
|
||||
const string sql = """
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
('admin', 'ventas:contado:crear'),
|
||||
('admin', 'ventas:contado:modificar'),
|
||||
('admin', 'ventas:contado:cobrar'),
|
||||
('admin', 'ventas:contado:facturar'),
|
||||
('admin', 'ventas:ctacte:crear'),
|
||||
('admin', 'ventas:ctacte:facturar'),
|
||||
('admin', 'textos:editar'),
|
||||
('admin', 'textos:reclamos:ver'),
|
||||
('admin', 'pauta:azanu:ver'),
|
||||
('admin', 'pauta:limpiar'),
|
||||
('admin', 'pauta:recursos:fueradehora'),
|
||||
('admin', 'productores:deuda:ver'),
|
||||
('admin', 'productores:pendientes:crear'),
|
||||
('admin', 'productores:deuda:bypass'),
|
||||
('admin', 'administracion:usuarios:gestionar'),
|
||||
('admin', 'administracion:tarifarios:gestionar'),
|
||||
('admin', 'administracion:medios:gestionar'),
|
||||
('admin', 'administracion:auditoria:ver'),
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
('cajero', 'ventas:contado:facturar'),
|
||||
('operador_ctacte', 'ventas:ctacte:crear'),
|
||||
('operador_ctacte', 'ventas:ctacte:facturar'),
|
||||
('picadora', 'textos:editar'),
|
||||
('picadora', 'textos:reclamos:ver'),
|
||||
('jefe_publicidad', 'textos:editar'),
|
||||
('jefe_publicidad', 'textos:reclamos:ver'),
|
||||
('jefe_publicidad', 'pauta:azanu:ver'),
|
||||
('jefe_publicidad', 'pauta:limpiar'),
|
||||
('jefe_publicidad', 'pauta:recursos:fueradehora'),
|
||||
('jefe_publicidad', 'productores:deuda:ver'),
|
||||
('jefe_publicidad', 'productores:deuda:bypass'),
|
||||
('productor', 'productores:deuda:ver'),
|
||||
('productor', 'productores:pendientes:crear'),
|
||||
('diagramacion', 'pauta:azanu:ver')
|
||||
) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
""";
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
private async Task SeedAdminAsync()
|
||||
{
|
||||
const string sql = """
|
||||
|
||||
Reference in New Issue
Block a user