Compare commits

...

9 Commits

Author SHA1 Message Date
2afac53fca Merge pull request 'UDT-005: Gestión de Permisos (RBAC) — catálogo + asignación rol↔permisos' (#9) from feature/UDT-005 into main 2026-04-15 19:02:02 +00:00
1a864e9f8b fix(app): validar formato codigo rol en GetRolPermisos [UDT-005]
Agrega GetRolPermisosQueryValidator con regex ^[a-z][a-z0-9_]*$ para
rechazar codigos invalidos con 400 en GET /api/v1/roles/{codigo}/permisos.
2026-04-15 15:56:49 -03:00
885a8cef17 feat(web): BATCH 6 - feature permisos con grid por modulo [UDT-005]
- api/types.ts: PermisoDto, AssignPermisosRequest
- api/listPermisos, getRolPermisos, assignPermisos
- hooks: usePermisos, useRolPermisos, useAssignPermisos (TanStack Query)
- components/RolPermisosEditor: checkbox-grid agrupado por modulo (codigo.split(':')[0])
- pages/RolPermisosPage: selector rol activo + guard admin + RolPermisosEditor
- router.tsx: ruta /admin/permisos
- AppSidebar.tsx: link Permisos (KeyRound icon) en seccion admin
- tests: 5 smoke tests RolPermisosEditor (render, prefill, toggle, save, 400)
2026-04-15 15:46:49 -03:00
4913a35d06 feat(api): BATCH 5 - PermisosController + tests HTTP [UDT-005] 2026-04-15 15:42:03 -03:00
be2257a9bf feat(infra): BATCH 4 - Permiso/RolPermiso repos Dapper + tests integracion [UDT-005] 2026-04-15 15:39:25 -03:00
704794a2e2 feat(app): BATCH 3 - handlers permisos con TDD [UDT-005] 2026-04-15 15:31:26 -03:00
7ddb71c24c feat(domain): BATCH 2 - Permiso entity + catalogo const [UDT-005] 2026-04-15 15:31:20 -03:00
7d2190c37e feat(db): BATCH 1 - V005/V006 Permiso y RolPermiso + seed [UDT-005] 2026-04-15 15:26:22 -03:00
f6ad371de4 chore(tests): BATCH 0 - agregar Permiso y RolPermiso a TablesToIgnore [UDT-005] 2026-04-15 15:26:19 -03:00
43 changed files with 2608 additions and 3 deletions

View 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

View 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

View 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);

View File

@@ -83,6 +83,18 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
case PermisoNotFoundException permisoNotFoundEx:
context.Result = new ObjectResult(new
{
error = "permiso_not_found",
message = permisoNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case RolAlreadyExistsException rolExistsEx:
context.Result = new ObjectResult(new
{

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -4,6 +4,10 @@ using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Application.Auth.Logout;
using SIGCM2.Application.Auth.Refresh;
using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Application.Permisos.GetByRol;
using SIGCM2.Application.Permisos.List;
using SIGCM2.Application.Roles.Create;
using SIGCM2.Application.Roles.Deactivate;
using SIGCM2.Application.Roles.Dtos;
@@ -31,6 +35,11 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<UpdateRolCommand, RolDto>, UpdateRolCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateRolCommand, RolDto>, DeactivateRolCommandHandler>();
// Permisos (UDT-005)
services.AddScoped<ICommandHandler<ListPermisosQuery, IReadOnlyList<PermisoDto>>, ListPermisosQueryHandler>();
services.AddScoped<ICommandHandler<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>, GetRolPermisosQueryHandler>();
services.AddScoped<ICommandHandler<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>, AssignPermisosToRolCommandHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -0,0 +1,5 @@
namespace SIGCM2.Application.Permisos.Assign;
public sealed record AssignPermisosToRolCommand(
string RolCodigo,
IReadOnlyList<string> Codigos);

View File

@@ -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();
}
}

View File

@@ -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.");
}
}

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.Permisos.Dtos;
public sealed record PermisoDto(
int Id,
string Codigo,
string Nombre,
string? Descripcion,
string Modulo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Permisos.GetByRol;
public sealed record GetRolPermisosQuery(string RolCodigo);

View File

@@ -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();
}
}

View File

@@ -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.");
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Permisos.List;
public sealed record ListPermisosQuery();

View File

@@ -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();
}
}

View 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);
}
}

View 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;
}
}

View 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,
};
}

View File

@@ -29,6 +29,8 @@ public static class DependencyInjection
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
services.AddScoped<IRolRepository, RolRepository>();
services.AddScoped<IPermisoRepository, PermisoRepository>();
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -7,6 +7,7 @@ import {
Settings,
UserPlus,
ShieldCheck,
KeyRound,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
@@ -117,6 +118,18 @@ export function SidebarNav() {
<ShieldCheck className="h-4 w-4 shrink-0" />
<span>Roles</span>
</Link>
<Link
to="/admin/permisos"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
pathname.startsWith('/admin/permisos')
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<KeyRound className="h-4 w-4 shrink-0" />
<span>Permisos</span>
</Link>
</>
)}
</nav>

View 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,
)
}

View 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
}

View 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
}

View File

@@ -0,0 +1,11 @@
export interface PermisoDto {
id: number
codigo: string
nombre: string
descripcion: string | null
modulo: string
}
export interface AssignPermisosRequest {
codigos: string[]
}

View 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>
)
}

View 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) })
},
})
}

View 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,
})
}

View 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,
})
}

View 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>
)
}

View File

@@ -5,6 +5,7 @@ import { CreateUserPage } from './features/users/pages/CreateUserPage'
import { RolesPage } from './features/roles/pages/RolesPage'
import { NewRolPage } from './features/roles/pages/NewRolPage'
import { EditRolPage } from './features/roles/pages/EditRolPage'
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -88,6 +89,16 @@ export function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/admin/permisos"
element={
<ProtectedRoute>
<ProtectedLayout>
<RolPermisosPage />
</ProtectedLayout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View 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),
)
})
})

View 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);
}
}
}

View File

@@ -31,7 +31,12 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
{
DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")]
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
]
});
await _respawner.ResetAsync(_connection);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -23,7 +23,12 @@ public class UsuarioRepositoryTests : IAsyncLifetime
{
DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")]
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
]
});
// Reset DB, re-seed Rol canonical table (lookup) and admin user for each test class run.

View File

@@ -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>>());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -30,7 +30,13 @@ public sealed class SqlTestFixture : IAsyncLifetime
{
DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
TablesToIgnore = [new Respawn.Graph.Table("dbo", "Rol")]
// Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog.
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
]
});
await ResetAndSeedAsync();
@@ -40,6 +46,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
{
await _respawner.ResetAsync(_connection);
await SeedRolCanonicalAsync();
await SeedPermisosCanonicalAsync();
await SeedRolPermisosCanonicalAsync();
await SeedAdminAsync();
}
@@ -75,6 +83,93 @@ public sealed class SqlTestFixture : IAsyncLifetime
}
}
private async Task SeedPermisosCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.Permiso AS t
USING (VALUES
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
('textos:editar', N'Editar textos', NULL, 'textos'),
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'),
('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
""";
await _connection.ExecuteAsync(sql);
}
private async Task SeedRolPermisosCanonicalAsync()
{
const string sql = """
SET QUOTED_IDENTIFIER ON;
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'ventas:contado:crear'),
('admin', 'ventas:contado:modificar'),
('admin', 'ventas:contado:cobrar'),
('admin', 'ventas:contado:facturar'),
('admin', 'ventas:ctacte:crear'),
('admin', 'ventas:ctacte:facturar'),
('admin', 'textos:editar'),
('admin', 'textos:reclamos:ver'),
('admin', 'pauta:azanu:ver'),
('admin', 'pauta:limpiar'),
('admin', 'pauta:recursos:fueradehora'),
('admin', 'productores:deuda:ver'),
('admin', 'productores:pendientes:crear'),
('admin', 'productores:deuda:bypass'),
('admin', 'administracion:usuarios:gestionar'),
('admin', 'administracion:tarifarios:gestionar'),
('admin', 'administracion:medios:gestionar'),
('admin', 'administracion:auditoria:ver'),
('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'),
('cajero', 'ventas:contado:facturar'),
('operador_ctacte', 'ventas:ctacte:crear'),
('operador_ctacte', 'ventas:ctacte:facturar'),
('picadora', 'textos:editar'),
('picadora', 'textos:reclamos:ver'),
('jefe_publicidad', 'textos:editar'),
('jefe_publicidad', 'textos:reclamos:ver'),
('jefe_publicidad', 'pauta:azanu:ver'),
('jefe_publicidad', 'pauta:limpiar'),
('jefe_publicidad', 'pauta:recursos:fueradehora'),
('jefe_publicidad', 'productores:deuda:ver'),
('jefe_publicidad', 'productores:deuda:bypass'),
('productor', 'productores:deuda:ver'),
('productor', 'productores:pendientes:crear'),
('diagramacion', 'pauta:azanu:ver')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
""";
await _connection.ExecuteAsync(sql);
}
private async Task SeedAdminAsync()
{
const string sql = """