From f6ad371de44c1557e7593986b560c4ad352464ff Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:26:19 -0300 Subject: [PATCH 1/8] chore(tests): BATCH 0 - agregar Permiso y RolPermiso a TablesToIgnore [UDT-005] --- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index ac9419d..2eeddd2 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -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(); -- 2.49.1 From 7d2190c37e129e1ab1dec499a09a63500b587e2a Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:26:22 -0300 Subject: [PATCH 2/8] feat(db): BATCH 1 - V005/V006 Permiso y RolPermiso + seed [UDT-005] --- database/migrations/V005__create_permiso.sql | 65 +++++++++++++ .../migrations/V006__create_rol_permiso.sql | 96 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 database/migrations/V005__create_permiso.sql create mode 100644 database/migrations/V006__create_rol_permiso.sql diff --git a/database/migrations/V005__create_permiso.sql b/database/migrations/V005__create_permiso.sql new file mode 100644 index 0000000..b2b19e2 --- /dev/null +++ b/database/migrations/V005__create_permiso.sql @@ -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 diff --git a/database/migrations/V006__create_rol_permiso.sql b/database/migrations/V006__create_rol_permiso.sql new file mode 100644 index 0000000..803c360 --- /dev/null +++ b/database/migrations/V006__create_rol_permiso.sql @@ -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 -- 2.49.1 From 7ddb71c24c5e7299f0b0eafbc6b304bfeb993c5e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:31:20 -0300 Subject: [PATCH 3/8] feat(domain): BATCH 2 - Permiso entity + catalogo const [UDT-005] --- src/api/SIGCM2.Domain/Entities/Permiso.cs | 49 +++++++++++++++++ .../Exceptions/PermisoNotFoundException.cs | 12 +++++ src/api/SIGCM2.Domain/Permissions/Permiso.cs | 53 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Entities/Permiso.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/PermisoNotFoundException.cs create mode 100644 src/api/SIGCM2.Domain/Permissions/Permiso.cs diff --git a/src/api/SIGCM2.Domain/Entities/Permiso.cs b/src/api/SIGCM2.Domain/Entities/Permiso.cs new file mode 100644 index 0000000..ee08070 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Permiso.cs @@ -0,0 +1,49 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// 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). +/// +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; + } + + /// + /// Factory para hidratación desde BD (read-only — el catálogo solo se modifica vía migraciones). + /// + 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); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/PermisoNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/PermisoNotFoundException.cs new file mode 100644 index 0000000..c9f95cb --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/PermisoNotFoundException.cs @@ -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; + } +} diff --git a/src/api/SIGCM2.Domain/Permissions/Permiso.cs b/src/api/SIGCM2.Domain/Permissions/Permiso.cs new file mode 100644 index 0000000..e07dadf --- /dev/null +++ b/src/api/SIGCM2.Domain/Permissions/Permiso.cs @@ -0,0 +1,53 @@ +namespace SIGCM2.Domain.Permissions; + +/// +/// 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. +/// +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"; + + /// + /// Set completo de todos los códigos canónicos (útil para validación y seeds). + /// + public static readonly IReadOnlySet Todos = new HashSet + { + VentasContadoCrear, VentasContadoModificar, VentasContadoCobrar, VentasContadoFacturar, + VentasCtacteCrear, VentasCtacteFacturar, + TextosEditar, TextosReclamosVer, + PautaAzanuVer, PautaLimpiar, PautaRecursosFueraDeHora, + ProductoresDeudaVer, ProductoresPendientesCrear, ProductoresDeudaBypass, + AdministracionUsuariosGestionar, AdministracionTarifariosGestionar, + AdministracionMediosGestionar, AdministracionAuditoriaVer, + }; +} -- 2.49.1 From 704794a2e2eb28f93dfa15228beb4d865c64a37b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:31:26 -0300 Subject: [PATCH 4/8] feat(app): BATCH 3 - handlers permisos con TDD [UDT-005] --- .../Persistence/IPermisoRepository.cs | 10 ++ .../Persistence/IRolPermisoRepository.cs | 9 ++ .../SIGCM2.Application/DependencyInjection.cs | 9 ++ .../Assign/AssignPermisosToRolCommand.cs | 5 + .../AssignPermisosToRolCommandHandler.cs | 52 ++++++++ .../AssignPermisosToRolCommandValidator.cs | 28 ++++ .../Permisos/Dtos/PermisoDto.cs | 8 ++ .../Permisos/GetByRol/GetRolPermisosQuery.cs | 3 + .../GetByRol/GetRolPermisosQueryHandler.cs | 30 +++++ .../Permisos/List/ListPermisosQuery.cs | 3 + .../Permisos/List/ListPermisosQueryHandler.cs | 23 ++++ .../AssignPermisosToRolCommandHandlerTests.cs | 123 ++++++++++++++++++ .../GetRolPermisosQueryHandlerTests.cs | 91 +++++++++++++ .../List/ListPermisosQueryHandlerTests.cs | 75 +++++++++++ 14 files changed, 469 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IPermisoRepository.cs create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IRolPermisoRepository.cs create mode 100644 src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommand.cs create mode 100644 src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs create mode 100644 src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQuery.cs create mode 100644 src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Permisos/List/ListPermisosQuery.cs create mode 100644 src/api/SIGCM2.Application/Permisos/List/ListPermisosQueryHandler.cs create mode 100644 tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Permisos/List/ListPermisosQueryHandlerTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IPermisoRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IPermisoRepository.cs new file mode 100644 index 0000000..5ac860e --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IPermisoRepository.cs @@ -0,0 +1,10 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IPermisoRepository +{ + Task> ListAsync(CancellationToken ct = default); + Task GetByCodigoAsync(string codigo, CancellationToken ct = default); + Task> GetByCodigosAsync(IEnumerable codigos, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IRolPermisoRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolPermisoRepository.cs new file mode 100644 index 0000000..5465c0b --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IRolPermisoRepository.cs @@ -0,0 +1,9 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IRolPermisoRepository +{ + Task> GetByRolCodigoAsync(string rolCodigo, CancellationToken ct = default); + Task ReplaceForRolAsync(int rolId, IEnumerable permisoIds, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index cba09e3..c864f1a 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -4,6 +4,10 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; +using SIGCM2.Application.Permisos.Assign; +using SIGCM2.Application.Permisos.Dtos; +using SIGCM2.Application.Permisos.GetByRol; +using SIGCM2.Application.Permisos.List; using SIGCM2.Application.Roles.Create; using SIGCM2.Application.Roles.Deactivate; using SIGCM2.Application.Roles.Dtos; @@ -31,6 +35,11 @@ public static class DependencyInjection services.AddScoped, UpdateRolCommandHandler>(); services.AddScoped, DeactivateRolCommandHandler>(); + // Permisos (UDT-005) + services.AddScoped>, ListPermisosQueryHandler>(); + services.AddScoped>, GetRolPermisosQueryHandler>(); + services.AddScoped>, AssignPermisosToRolCommandHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommand.cs b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommand.cs new file mode 100644 index 0000000..34b9930 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommand.cs @@ -0,0 +1,5 @@ +namespace SIGCM2.Application.Permisos.Assign; + +public sealed record AssignPermisosToRolCommand( + string RolCodigo, + IReadOnlyList Codigos); diff --git a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs new file mode 100644 index 0000000..5f705f7 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs @@ -0,0 +1,52 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Permisos.Assign; + +public sealed class AssignPermisosToRolCommandHandler : ICommandHandler> +{ + private readonly IRolRepository _rolRepository; + private readonly IPermisoRepository _permisoRepository; + private readonly IRolPermisoRepository _rolPermisoRepository; + + public AssignPermisosToRolCommandHandler( + IRolRepository rolRepository, + IPermisoRepository permisoRepository, + IRolPermisoRepository rolPermisoRepository) + { + _rolRepository = rolRepository; + _permisoRepository = permisoRepository; + _rolPermisoRepository = rolPermisoRepository; + } + + public async Task> Handle(AssignPermisosToRolCommand command) + { + // 1. Validar que el rol existe + var rol = await _rolRepository.GetByCodigoAsync(command.RolCodigo); + if (rol is null) + throw new RolNotFoundException(command.RolCodigo); + + // 2. Validar que todos los códigos existen en BD + var codigosList = command.Codigos.ToList(); + var permisos = await _permisoRepository.GetByCodigosAsync(codigosList); + + if (permisos.Count != codigosList.Count) + { + // Detectar el primer código que no fue encontrado + var foundCodigos = permisos.Select(p => p.Codigo).ToHashSet(); + var missing = codigosList.First(c => !foundCodigos.Contains(c)); + throw new PermisoNotFoundException(missing); + } + + // 3. Reemplazar el set (DELETE+INSERT en transacción dentro del repo) + var permisoIds = permisos.Select(p => p.Id); + await _rolPermisoRepository.ReplaceForRolAsync(rol.Id, permisoIds); + + // 4. Retornar el nuevo set asignado + return permisos + .Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandValidator.cs b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandValidator.cs new file mode 100644 index 0000000..ab822d8 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using SIGCM2.Domain.Permissions; + +namespace SIGCM2.Application.Permisos.Assign; + +public sealed class AssignPermisosToRolCommandValidator : AbstractValidator +{ + private const string AdminCodigo = "admin"; + + public AssignPermisosToRolCommandValidator() + { + RuleFor(x => x.RolCodigo) + .NotEmpty().WithMessage("El código del rol es requerido."); + + RuleFor(x => x.Codigos) + .NotNull().WithMessage("La lista de permisos no puede ser nula."); + + // Admin no puede quedar con lista vacía — regla RBAC explícita (convención admin-convention) + RuleFor(x => x.Codigos) + .Must((cmd, codigos) => !(cmd.RolCodigo == AdminCodigo && codigos.Count == 0)) + .WithMessage("El rol 'admin' debe retener al menos un permiso."); + + // Cada código debe pertenecer al catálogo canónico + RuleForEach(x => x.Codigos) + .Must(codigo => Permiso.Todos.Contains(codigo)) + .WithMessage("El código de permiso '{PropertyValue}' no existe en el catálogo."); + } +} diff --git a/src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs b/src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs new file mode 100644 index 0000000..08196ca --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/Dtos/PermisoDto.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Permisos.Dtos; + +public sealed record PermisoDto( + int Id, + string Codigo, + string Nombre, + string? Descripcion, + string Modulo); diff --git a/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQuery.cs b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQuery.cs new file mode 100644 index 0000000..946d9b0 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Permisos.GetByRol; + +public sealed record GetRolPermisosQuery(string RolCodigo); diff --git a/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryHandler.cs b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryHandler.cs new file mode 100644 index 0000000..35dacb0 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Dtos; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Permisos.GetByRol; + +public sealed class GetRolPermisosQueryHandler : ICommandHandler> +{ + private readonly IRolRepository _rolRepository; + private readonly IRolPermisoRepository _rolPermisoRepository; + + public GetRolPermisosQueryHandler(IRolRepository rolRepository, IRolPermisoRepository rolPermisoRepository) + { + _rolRepository = rolRepository; + _rolPermisoRepository = rolPermisoRepository; + } + + public async Task> Handle(GetRolPermisosQuery query) + { + var rol = await _rolRepository.GetByCodigoAsync(query.RolCodigo); + if (rol is null) + throw new RolNotFoundException(query.RolCodigo); + + var permisos = await _rolPermisoRepository.GetByRolCodigoAsync(query.RolCodigo); + return permisos + .Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Permisos/List/ListPermisosQuery.cs b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQuery.cs new file mode 100644 index 0000000..4175fc4 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Permisos.List; + +public sealed record ListPermisosQuery(); diff --git a/src/api/SIGCM2.Application/Permisos/List/ListPermisosQueryHandler.cs b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQueryHandler.cs new file mode 100644 index 0000000..7bd81d7 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/List/ListPermisosQueryHandler.cs @@ -0,0 +1,23 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Dtos; + +namespace SIGCM2.Application.Permisos.List; + +public sealed class ListPermisosQueryHandler : ICommandHandler> +{ + private readonly IPermisoRepository _repository; + + public ListPermisosQueryHandler(IPermisoRepository repository) + { + _repository = repository; + } + + public async Task> Handle(ListPermisosQuery query) + { + var permisos = await _repository.ListAsync(); + return permisos + .Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo)) + .ToList(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs new file mode 100644 index 0000000..54a9818 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs @@ -0,0 +1,123 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.Assign; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Permisos.Assign; + +public class AssignPermisosToRolCommandHandlerTests +{ + private readonly IRolRepository _rolRepository = Substitute.For(); + private readonly IPermisoRepository _permisoRepository = Substitute.For(); + private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For(); + private readonly AssignPermisosToRolCommandHandler _handler; + + public AssignPermisosToRolCommandHandlerTests() + { + _handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository); + } + + private static Rol MakeRol(int id, string codigo) => + new(id, codigo, codigo, null, true, DateTime.UtcNow, null); + + private static Permiso MakePermiso(int id, string codigo, string modulo = "ventas") => + Permiso.ForRead(id, codigo, codigo, null, modulo, true, DateTime.UtcNow); + + [Fact] + public async Task Handle_HappyPath_CallsReplaceWithCorrectIds() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + var permisoCrear = MakePermiso(1, "ventas:contado:crear"); + var permisoFact = MakePermiso(2, "ventas:contado:facturar"); + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List { permisoCrear, permisoFact }); + + var codigos = new List { "ventas:contado:crear", "ventas:contado:facturar" }; + await _handler.Handle(new AssignPermisosToRolCommand("cajero", codigos)); + + await _rolPermisoRepository.Received(1).ReplaceForRolAsync( + 5, + Arg.Is>(ids => ids.SequenceEqual(new[] { 1, 2 }))); + } + + [Fact] + public async Task Handle_RolInexistente_ThrowsRolNotFoundException() + { + _rolRepository.GetByCodigoAsync("fantasma").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("fantasma", new[] { "ventas:contado:crear" }))); + + Assert.Equal("fantasma", ex.Codigo); + } + + [Fact] + public async Task Handle_RolInexistente_DoesNotCallReplace() + { + _rolRepository.GetByCodigoAsync("fantasma").Returns((Rol?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("fantasma", new[] { "ventas:contado:crear" }))); + + await _rolPermisoRepository.DidNotReceive().ReplaceForRolAsync(Arg.Any(), Arg.Any>()); + } + + [Fact] + public async Task Handle_PermisoInexistente_ThrowsPermisoNotFoundException() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + // Repo devuelve 0 permisos (ningún código matchea en BD) + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List()); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("cajero", new[] { "permiso:inexistente" }))); + + Assert.Equal("permiso:inexistente", ex.Codigo); + } + + [Fact] + public async Task Handle_PartialPermisoMatch_ThrowsPermisoNotFoundForMissing() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + // Solo devuelve 1 de 2 — el segundo no existe + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List { MakePermiso(1, "ventas:contado:crear") }); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new AssignPermisosToRolCommand("cajero", + new[] { "ventas:contado:crear", "permiso:inexistente" }))); + + Assert.Equal("permiso:inexistente", ex.Codigo); + } + + [Fact] + public async Task Handle_EmptyList_CallsReplaceWithEmptyIds() + { + // Para roles no-admin, lista vacía es válida + _rolRepository.GetByCodigoAsync("reportes").Returns(MakeRol(3, "reportes")); + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List()); + + await _handler.Handle(new AssignPermisosToRolCommand("reportes", new List())); + + await _rolPermisoRepository.Received(1).ReplaceForRolAsync( + 3, + Arg.Is>(ids => !ids.Any())); + } + + [Fact] + public async Task Handle_IdempotentCall_CallsReplaceExactlyOnce() + { + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + _permisoRepository.GetByCodigosAsync(Arg.Any>()) + .Returns(new List { MakePermiso(1, "ventas:contado:crear") }); + + var cmd = new AssignPermisosToRolCommand("cajero", new[] { "ventas:contado:crear" }); + await _handler.Handle(cmd); + await _handler.Handle(cmd); + + await _rolPermisoRepository.Received(2).ReplaceForRolAsync(Arg.Any(), Arg.Any>()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs new file mode 100644 index 0000000..2021183 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs @@ -0,0 +1,91 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.GetByRol; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Permisos.GetByRol; + +public class GetRolPermisosQueryHandlerTests +{ + private readonly IRolRepository _rolRepository = Substitute.For(); + private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For(); + private readonly GetRolPermisosQueryHandler _handler; + + public GetRolPermisosQueryHandlerTests() + { + _handler = new GetRolPermisosQueryHandler(_rolRepository, _rolPermisoRepository); + } + + private static Rol MakeRol(int id, string codigo) => + new(id, codigo, codigo, null, true, DateTime.UtcNow, null); + + private static Permiso MakePermiso(int id, string codigo, string modulo) => + Permiso.ForRead(id, codigo, codigo, null, modulo, true, DateTime.UtcNow); + + [Fact] + public async Task Handle_ExistingRol_ReturnsMappedPermisoDtos() + { + var now = new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc); + _rolRepository.GetByCodigoAsync("cajero").Returns(MakeRol(5, "cajero")); + _rolPermisoRepository.GetByRolCodigoAsync("cajero").Returns(new List + { + MakePermiso(1, "ventas:contado:crear", "ventas"), + MakePermiso(2, "ventas:contado:cobrar", "ventas"), + MakePermiso(3, "ventas:contado:facturar","ventas"), + MakePermiso(4, "ventas:contado:modificar","ventas"), + }); + + var result = await _handler.Handle(new GetRolPermisosQuery("cajero")); + + Assert.Equal(4, result.Count); + Assert.Contains(result, r => r.Codigo == "ventas:contado:crear"); + } + + [Fact] + public async Task Handle_RolWithNoPermisos_ReturnsEmptyList() + { + _rolRepository.GetByCodigoAsync("reportes").Returns(MakeRol(3, "reportes")); + _rolPermisoRepository.GetByRolCodigoAsync("reportes").Returns(new List()); + + var result = await _handler.Handle(new GetRolPermisosQuery("reportes")); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_NonExistentRol_ThrowsRolNotFoundException() + { + _rolRepository.GetByCodigoAsync("inexistente").Returns((Rol?)null); + + var ex = await Assert.ThrowsAsync( + () => _handler.Handle(new GetRolPermisosQuery("inexistente"))); + + Assert.Equal("inexistente", ex.Codigo); + } + + [Fact] + public async Task Handle_NonExistentRol_DoesNotCallRolPermisoRepository() + { + _rolRepository.GetByCodigoAsync("ghost").Returns((Rol?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetRolPermisosQuery("ghost"))); + + await _rolPermisoRepository.DidNotReceive().GetByRolCodigoAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_AdminRol_Returns18Permisos() + { + _rolRepository.GetByCodigoAsync("admin").Returns(MakeRol(1, "admin")); + var adminPermisos = Enumerable.Range(1, 18) + .Select(i => MakePermiso(i, $"modulo{i}:accion{i}", $"modulo{i}")) + .ToList(); + _rolPermisoRepository.GetByRolCodigoAsync("admin").Returns(adminPermisos); + + var result = await _handler.Handle(new GetRolPermisosQuery("admin")); + + Assert.Equal(18, result.Count); + } +} diff --git a/tests/SIGCM2.Application.Tests/Permisos/List/ListPermisosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/List/ListPermisosQueryHandlerTests.cs new file mode 100644 index 0000000..18e7fdc --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Permisos/List/ListPermisosQueryHandlerTests.cs @@ -0,0 +1,75 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Permisos.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Permisos.List; + +public class ListPermisosQueryHandlerTests +{ + private readonly IPermisoRepository _repository = Substitute.For(); + private readonly ListPermisosQueryHandler _handler; + + public ListPermisosQueryHandlerTests() + { + _handler = new ListPermisosQueryHandler(_repository); + } + + private static Permiso MakePermiso(int id, string codigo, string nombre, string modulo) => + Permiso.ForRead(id, codigo, nombre, null, modulo, true, DateTime.UtcNow); + + [Fact] + public async Task Handle_ReturnsDtosProjectedFromRepository() + { + _repository.ListAsync().Returns(new List + { + MakePermiso(1, "ventas:contado:crear", "Cargar orden contado", "ventas"), + MakePermiso(2, "textos:editar", "Editar textos", "textos"), + }); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Equal(2, result.Count); + Assert.Equal("ventas:contado:crear", result[0].Codigo); + Assert.Equal("Cargar orden contado", result[0].Nombre); + Assert.Equal("ventas", result[0].Modulo); + Assert.Equal("textos:editar", result[1].Codigo); + } + + [Fact] + public async Task Handle_WithFullCatalog_Returns18Items() + { + var permisos = Enumerable.Range(1, 18) + .Select(i => MakePermiso(i, $"modulo{i}:accion{i}", $"Permiso {i}", $"modulo{i}")) + .ToList(); + _repository.ListAsync().Returns(permisos); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Equal(18, result.Count); + } + + [Fact] + public async Task Handle_EmptyRepository_ReturnsEmptyList() + { + _repository.ListAsync().Returns(new List()); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_NullDescripcion_MappedCorrectly() + { + _repository.ListAsync().Returns(new List + { + Permiso.ForRead(1, "pauta:limpiar", "Limpieza de pauta", null, "pauta", true, DateTime.UtcNow), + }); + + var result = await _handler.Handle(new ListPermisosQuery()); + + Assert.Single(result); + Assert.Null(result[0].Descripcion); + } +} -- 2.49.1 From be2257a9bf4890f2ee17f4cb0ddedde9353a0ab4 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:39:25 -0300 Subject: [PATCH 5/8] feat(infra): BATCH 4 - Permiso/RolPermiso repos Dapper + tests integracion [UDT-005] --- .../DependencyInjection.cs | 2 + .../Persistence/PermisoRepository.cs | 85 +++++ .../Persistence/RolPermisoRepository.cs | 97 ++++++ .../RefreshTokenRepositoryTests.cs | 7 +- .../Integration/PermisoRepositoryTests.cs | 194 +++++++++++ .../Integration/RolPermisoRepositoryTests.cs | 315 ++++++++++++++++++ .../Integration/UsuarioRepositoryTests.cs | 7 +- tests/SIGCM2.TestSupport/SqlTestFixture.cs | 89 +++++ 8 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index f1e4a02..baa417f 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -29,6 +29,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs new file mode 100644 index 0000000..833cd0d --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/PermisoRepository.cs @@ -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> 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(sql); + return rows.Select(MapRow).ToList(); + } + + public async Task 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(sql, new { Codigo = codigo }); + return row is null ? null : MapRow(row); + } + + public async Task> GetByCodigosAsync( + IEnumerable codigos, + CancellationToken ct = default) + { + var codigosList = codigos.ToList(); + if (codigosList.Count == 0) + return Array.Empty(); + + 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(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); +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs new file mode 100644 index 0000000..459643a --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/RolPermisoRepository.cs @@ -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> 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(sql, new { RolCodigo = rolCodigo }); + return rows.Select(MapRow).ToList(); + } + + public async Task ReplaceForRolAsync( + int rolId, + IEnumerable 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); +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs index 13bb776..8758b8e 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs @@ -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); diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs new file mode 100644 index 0000000..cb54042 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -0,0 +1,194 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// Integration tests for PermisoRepository against SIGCM2_Test. +/// RED: written before the repository implementation exists. +/// +[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()); + + 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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs new file mode 100644 index 0000000..f38e405 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -0,0 +1,315 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Integration; + +/// +/// Integration tests for RolPermisoRepository against SIGCM2_Test. +/// RED: written before the repository implementation exists. +/// +[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(); + } + + /// + /// Restores the 4 canonical cajero permisos to match V006 seed state. + /// Uses MERGE so it's idempotent. + /// + 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( + "SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'"); + var textoPermisoId = await _connection.ExecuteScalarAsync( + "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( + "SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'"); + var permisoId = await _connection.ExecuteScalarAsync( + "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( + "SELECT Id FROM dbo.Rol WHERE Codigo = 'cajero'"); + + await _repository.ReplaceForRolAsync(cajeroId, Array.Empty()); + + var permisos = await _repository.GetByRolCodigoAsync("cajero"); + Assert.Empty(permisos); + } + + [Fact] + public async Task ReplaceForRolAsync_PostReplace_GetReflectsNewSet() + { + var cajeroId = await _connection.ExecuteScalarAsync( + "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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs index 33e55f7..27b34e9 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -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. diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 2eeddd2..dc8b062 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -46,6 +46,8 @@ public sealed class SqlTestFixture : IAsyncLifetime { await _respawner.ResetAsync(_connection); await SeedRolCanonicalAsync(); + await SeedPermisosCanonicalAsync(); + await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); } @@ -81,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 = """ -- 2.49.1 From 4913a35d069a7c9ce18efbd0a9e23147ed7a3d96 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:42:03 -0300 Subject: [PATCH 6/8] feat(api): BATCH 5 - PermisosController + tests HTTP [UDT-005] --- .../Controllers/PermisosController.cs | 83 ++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 12 + .../Permisos/PermisosEndpointTests.cs | 416 ++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/PermisosController.cs create mode 100644 tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/PermisosController.cs b/src/api/SIGCM2.Api/Controllers/PermisosController.cs new file mode 100644 index 0000000..77ed8de --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/PermisosController.cs @@ -0,0 +1,83 @@ +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 _assignValidator; + + public PermisosController( + IDispatcher dispatcher, + IValidator assignValidator) + { + _dispatcher = dispatcher; + _assignValidator = assignValidator; + } + + /// Lists all permisos in the canonical catalog. Requires admin role. + [HttpGet("permisos")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListPermisos() + { + var result = await _dispatcher.Send>(new ListPermisosQuery()); + return Ok(result); + } + + /// Gets all permisos assigned to a rol. Requires admin role. + [HttpGet("roles/{codigo}/permisos")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetRolPermisos(string codigo) + { + var result = await _dispatcher.Send>( + new GetRolPermisosQuery(codigo)); + return Ok(result); + } + + /// + /// Replace-set: replaces the full permiso assignment for a rol. + /// Returns the updated permiso set (200). Requires admin role. + /// + [HttpPut("roles/{codigo}/permisos")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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>(command); + return Ok(result); + } +} + +public sealed record AssignPermisosRequest(IReadOnlyList? Codigos); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 13b4b5a..e1911b8 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -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 { diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs new file mode 100644 index 0000000..098a6f5 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -0,0 +1,416 @@ +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; + +/// +/// Integration tests for /api/v1/permisos and /api/v1/roles/{codigo}/permisos (UDT-005). +/// RED: written before PermisosController exists. +/// +[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 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(); + 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 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(); + 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(); + 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(); + 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(); + 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(); + 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); + } + } + + // ── 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(); + 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(); + 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(); + 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() }, + 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); + } + } +} -- 2.49.1 From 885a8cef177f6f8786068fda39e86d4310a3b180 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:46:49 -0300 Subject: [PATCH 7/8] 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) --- src/web/src/components/layout/AppSidebar.tsx | 13 ++ .../features/permisos/api/assignPermisos.ts | 12 ++ .../features/permisos/api/getRolPermisos.ts | 9 ++ .../src/features/permisos/api/listPermisos.ts | 7 + src/web/src/features/permisos/api/types.ts | 11 ++ .../permisos/components/RolPermisosEditor.tsx | 140 ++++++++++++++++ .../permisos/hooks/useAssignPermisos.ts | 23 +++ .../features/permisos/hooks/usePermisos.ts | 12 ++ .../features/permisos/hooks/useRolPermisos.ts | 15 ++ .../permisos/pages/RolPermisosPage.tsx | 77 +++++++++ src/web/src/router.tsx | 11 ++ .../permisos/RolPermisosEditor.test.tsx | 153 ++++++++++++++++++ 12 files changed, 483 insertions(+) create mode 100644 src/web/src/features/permisos/api/assignPermisos.ts create mode 100644 src/web/src/features/permisos/api/getRolPermisos.ts create mode 100644 src/web/src/features/permisos/api/listPermisos.ts create mode 100644 src/web/src/features/permisos/api/types.ts create mode 100644 src/web/src/features/permisos/components/RolPermisosEditor.tsx create mode 100644 src/web/src/features/permisos/hooks/useAssignPermisos.ts create mode 100644 src/web/src/features/permisos/hooks/usePermisos.ts create mode 100644 src/web/src/features/permisos/hooks/useRolPermisos.ts create mode 100644 src/web/src/features/permisos/pages/RolPermisosPage.tsx create mode 100644 src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 73252da..7c9330e 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -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() { Roles + + + Permisos + )} diff --git a/src/web/src/features/permisos/api/assignPermisos.ts b/src/web/src/features/permisos/api/assignPermisos.ts new file mode 100644 index 0000000..3a81e7a --- /dev/null +++ b/src/web/src/features/permisos/api/assignPermisos.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { AssignPermisosRequest } from './types' + +export async function assignPermisos( + rolCodigo: string, + payload: AssignPermisosRequest, +): Promise { + await axiosClient.put( + `/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`, + payload, + ) +} diff --git a/src/web/src/features/permisos/api/getRolPermisos.ts b/src/web/src/features/permisos/api/getRolPermisos.ts new file mode 100644 index 0000000..2d0aa2d --- /dev/null +++ b/src/web/src/features/permisos/api/getRolPermisos.ts @@ -0,0 +1,9 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { PermisoDto } from './types' + +export async function getRolPermisos(rolCodigo: string): Promise { + const response = await axiosClient.get( + `/api/v1/roles/${encodeURIComponent(rolCodigo)}/permisos`, + ) + return response.data +} diff --git a/src/web/src/features/permisos/api/listPermisos.ts b/src/web/src/features/permisos/api/listPermisos.ts new file mode 100644 index 0000000..3112882 --- /dev/null +++ b/src/web/src/features/permisos/api/listPermisos.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { PermisoDto } from './types' + +export async function listPermisos(): Promise { + const response = await axiosClient.get('/api/v1/permisos') + return response.data +} diff --git a/src/web/src/features/permisos/api/types.ts b/src/web/src/features/permisos/api/types.ts new file mode 100644 index 0000000..fb5b3a7 --- /dev/null +++ b/src/web/src/features/permisos/api/types.ts @@ -0,0 +1,11 @@ +export interface PermisoDto { + id: number + codigo: string + nombre: string + descripcion: string | null + modulo: string +} + +export interface AssignPermisosRequest { + codigos: string[] +} diff --git a/src/web/src/features/permisos/components/RolPermisosEditor.tsx b/src/web/src/features/permisos/components/RolPermisosEditor.tsx new file mode 100644 index 0000000..a28f4a1 --- /dev/null +++ b/src/web/src/features/permisos/components/RolPermisosEditor.tsx @@ -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 { + const map = new Map() + 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>(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

Cargando permisos...

+ } + + if (errorCatalogo || errorAsignados) { + return ( + + + Error al cargar los permisos del rol. + + ) + } + + if (!catalogo || catalogo.length === 0) { + return

No hay permisos registrados en el sistema.

+ } + + 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 ( +
+ {saveErrMsg && ( + + + {saveErrMsg} + + )} + + {saved && ( + + + Permisos guardados correctamente. + + )} + + {Array.from(grupos.entries()).map(([modulo, permisos]) => ( +
+

+ {modulo} +

+
+ {permisos.map((p) => ( + + ))} +
+
+ ))} + +
+ +
+
+ ) +} diff --git a/src/web/src/features/permisos/hooks/useAssignPermisos.ts b/src/web/src/features/permisos/hooks/useAssignPermisos.ts new file mode 100644 index 0000000..48ad6cc --- /dev/null +++ b/src/web/src/features/permisos/hooks/useAssignPermisos.ts @@ -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) }) + }, + }) +} diff --git a/src/web/src/features/permisos/hooks/usePermisos.ts b/src/web/src/features/permisos/hooks/usePermisos.ts new file mode 100644 index 0000000..d3afcd8 --- /dev/null +++ b/src/web/src/features/permisos/hooks/usePermisos.ts @@ -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, + }) +} diff --git a/src/web/src/features/permisos/hooks/useRolPermisos.ts b/src/web/src/features/permisos/hooks/useRolPermisos.ts new file mode 100644 index 0000000..3fcb986 --- /dev/null +++ b/src/web/src/features/permisos/hooks/useRolPermisos.ts @@ -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, + }) +} diff --git a/src/web/src/features/permisos/pages/RolPermisosPage.tsx b/src/web/src/features/permisos/pages/RolPermisosPage.tsx new file mode 100644 index 0000000..19dd823 --- /dev/null +++ b/src/web/src/features/permisos/pages/RolPermisosPage.tsx @@ -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(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 ( +
+ + + Permisos por rol + + Seleccioná un rol para ver y editar sus permisos. Los cambios se aplican inmediatamente al guardar. + + + + {/* Selector de rol */} +
+ + {loadingRoles ? ( +

Cargando roles...

+ ) : ( + + )} +
+ + {/* Grid de permisos */} + {selectedRol ? ( + + ) : ( +

+ Seleccioná un rol para ver sus permisos. +

+ )} +
+
+
+ ) +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index d43145d..5f2c352 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -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() { } /> + + + + + + } + /> } /> ) diff --git a/src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx b/src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx new file mode 100644 index 0000000..55e1d0d --- /dev/null +++ b/src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx @@ -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( + + + + + , + ) +} + +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), + ) + }) +}) -- 2.49.1 From 1a864e9f8bdd43255331344bac1d7a004c45ea7d Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 15:56:49 -0300 Subject: [PATCH 8/8] 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. --- .../Controllers/PermisosController.cs | 19 ++++++++++-- .../GetByRol/GetRolPermisosQueryValidator.cs | 14 +++++++++ .../Permisos/PermisosEndpointTests.cs | 11 +++++++ .../GetRolPermisosQueryHandlerTests.cs | 31 +++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryValidator.cs diff --git a/src/api/SIGCM2.Api/Controllers/PermisosController.cs b/src/api/SIGCM2.Api/Controllers/PermisosController.cs index 77ed8de..13bc6c4 100644 --- a/src/api/SIGCM2.Api/Controllers/PermisosController.cs +++ b/src/api/SIGCM2.Api/Controllers/PermisosController.cs @@ -16,13 +16,16 @@ public sealed class PermisosController : ControllerBase { private readonly IDispatcher _dispatcher; private readonly IValidator _assignValidator; + private readonly IValidator _getRolPermisosValidator; public PermisosController( IDispatcher dispatcher, - IValidator assignValidator) + IValidator assignValidator, + IValidator getRolPermisosValidator) { _dispatcher = dispatcher; _assignValidator = assignValidator; + _getRolPermisosValidator = getRolPermisosValidator; } /// Lists all permisos in the canonical catalog. Requires admin role. @@ -39,13 +42,23 @@ public sealed class PermisosController : ControllerBase /// Gets all permisos assigned to a rol. Requires admin role. [HttpGet("roles/{codigo}/permisos")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetRolPermisos(string codigo) { - var result = await _dispatcher.Send>( - new GetRolPermisosQuery(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>(query); return Ok(result); } diff --git a/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryValidator.cs b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryValidator.cs new file mode 100644 index 0000000..0768656 --- /dev/null +++ b/src/api/SIGCM2.Application/Permisos/GetByRol/GetRolPermisosQueryValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace SIGCM2.Application.Permisos.GetByRol; + +public sealed class GetRolPermisosQueryValidator : AbstractValidator +{ + 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."); + } +} diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index 098a6f5..ac2fd0c 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -250,6 +250,17 @@ public sealed class PermisosEndpointTests : IAsyncLifetime } } + [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] diff --git a/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs index 2021183..0b459c9 100644 --- a/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Permisos/GetByRol/GetRolPermisosQueryHandlerTests.cs @@ -1,3 +1,4 @@ +using FluentValidation; using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Permisos.GetByRol; @@ -89,3 +90,33 @@ public class GetRolPermisosQueryHandlerTests Assert.Equal(18, result.Count); } } + +public class GetRolPermisosQueryValidatorTests +{ + private readonly IValidator _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); + } +} -- 2.49.1