UDT-005: Gestión de Permisos (RBAC) — catálogo + asignación rol↔permisos #9
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;
|
context.ExceptionHandled = true;
|
||||||
break;
|
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:
|
case RolAlreadyExistsException rolExistsEx:
|
||||||
context.Result = new ObjectResult(new
|
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.Login;
|
||||||
using SIGCM2.Application.Auth.Logout;
|
using SIGCM2.Application.Auth.Logout;
|
||||||
using SIGCM2.Application.Auth.Refresh;
|
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.Create;
|
||||||
using SIGCM2.Application.Roles.Deactivate;
|
using SIGCM2.Application.Roles.Deactivate;
|
||||||
using SIGCM2.Application.Roles.Dtos;
|
using SIGCM2.Application.Roles.Dtos;
|
||||||
@@ -31,6 +35,11 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
|
services.AddScoped<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<DeactivateRolCommand, RolDto>, DeactivateRolCommandHandler>();
|
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)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
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<IUsuarioRepository, UsuarioRepository>();
|
||||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||||
services.AddScoped<IRolRepository, RolRepository>();
|
services.AddScoped<IRolRepository, RolRepository>();
|
||||||
|
services.AddScoped<IPermisoRepository, PermisoRepository>();
|
||||||
|
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
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,
|
Settings,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
KeyRound,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -117,6 +118,18 @@ export function SidebarNav() {
|
|||||||
<ShieldCheck className="h-4 w-4 shrink-0" />
|
<ShieldCheck className="h-4 w-4 shrink-0" />
|
||||||
<span>Roles</span>
|
<span>Roles</span>
|
||||||
</Link>
|
</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>
|
</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 { RolesPage } from './features/roles/pages/RolesPage'
|
||||||
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
||||||
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||||
|
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -88,6 +89,16 @@ export function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/permisos"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ProtectedLayout>
|
||||||
|
<RolPermisosPage />
|
||||||
|
</ProtectedLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</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,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
// 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);
|
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,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
// 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.
|
// 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,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
// 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();
|
await ResetAndSeedAsync();
|
||||||
@@ -40,6 +46,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await _respawner.ResetAsync(_connection);
|
await _respawner.ResetAsync(_connection);
|
||||||
await SeedRolCanonicalAsync();
|
await SeedRolCanonicalAsync();
|
||||||
|
await SeedPermisosCanonicalAsync();
|
||||||
|
await SeedRolPermisosCanonicalAsync();
|
||||||
await SeedAdminAsync();
|
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()
|
private async Task SeedAdminAsync()
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
|
|||||||
Reference in New Issue
Block a user