UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14

Merged
dmolinari merged 14 commits from feature/UDT-010 into main 2026-04-16 20:30:17 +00:00
89 changed files with 5066 additions and 72 deletions

View File

@@ -15,6 +15,7 @@
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
</ItemGroup>
<!-- Test dependencies -->
<ItemGroup>

85
database/README.md Normal file
View File

@@ -0,0 +1,85 @@
# `database/` — SIG-CM 2.0
Todo el DDL del sistema vive acá: migraciones versionadas, stored procedures, functions, seeds.
## Estructura
```
database/
├── migrations/ # Migraciones versionadas V0XX__<descripcion>.sql (SQL puro, idempotentes)
├── procedures/ # Stored procedures — creados/alterados por UDTs específicas
├── functions/ # User-defined functions
├── seeds/ # Data de referencia no-versionada
└── schemas/ # Extracciones de schema (referencia)
```
## Migraciones aplicadas (orden obligatorio)
| Versión | Archivo | UDT | Descripción |
|---|---|---|---|
| V001 | `V001__create_usuario.sql` | UDT-001 | Tabla Usuario + IX_Usuario_Username_Activo |
| V002 | `V002__create_refresh_token.sql` | UDT-002 | Tabla RefreshToken |
| V003 | `V003__create_rol.sql` | UDT-004 | Tabla Rol + 8 roles canónicos |
| V004 | `V004__alter_usuario_rol_fk.sql` | UDT-004 | FK Usuario.Rol → Rol.Codigo |
| V005 | `V005__create_permiso.sql` | UDT-005 | Tabla Permiso + 18 permisos canónicos |
| V006 | `V006__create_rol_permiso.sql` | UDT-005 | Tabla RolPermiso + seed 36 rows |
| V007 | `V007__add_admin_permissions_udt006.sql` | UDT-006 | 3 permisos administrativos RBAC |
| V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol |
| V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | Migración shape `PermisosJson` `{grant, deny}` |
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
## Convenciones
- **SQL puro** ejecutado manualmente (no hay runner automático; decisión arquitectónica).
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
- **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
## Cómo aplicar migraciones
### En dev (manual)
```bash
# Con sqlcmd:
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
```
O desde SSMS: abrir el archivo, conectar a cada base, F5.
### En integration tests
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
### En producción (roadmap futuro)
1. Backup completo de la base.
2. Revisar notas específicas de la migración (ver abajo).
3. Ventana de mantenimiento si la migración lo requiere.
4. `sqlcmd` + script + verify que los `PRINT` salieron esperados.
5. Smoke test post-migración.
## ⚠️ Notas especiales por migración
### V010 — Infraestructura de Auditoría
**Riesgos específicos**:
- Activa `SYSTEM_VERSIONING` en `Usuario`, `Rol`, `Permiso`, `RolPermiso`. Tablas con datos. `ALTER TABLE ADD PERIOD FOR SYSTEM_TIME` toma **Sch-M lock** (schema modification). En dev con pocos usuarios el lock es milisegundos; en prod con conexiones activas puede generar espera.
- Crea filegroups `AUDIT_HOT` y `AUDIT_COLD` con archivos físicos `<DB>_AUDIT_HOT.ndf` y `<DB>_AUDIT_COLD.ndf` en el default data path del server.
- Crea 2 partition functions + schemes mensuales (boundaries 2026-01..2027-02). El job `AuditPartitionManagerJob` (B11) extiende la ventana mes a mes.
**Para aplicar en prod**:
1. **Backup completo previo** (no negociable).
2. **Ventana de mantenimiento ≥ 10 min** (los ALTER de SYSTEM_VERSIONING son rápidos pero pueden caer en timeouts si hay transacciones largas).
3. Ejecutar el script + verificar todos los `PRINT` "created/applied".
4. Smoke test post: `SELECT TOP 1 * FROM dbo.AuditEvent` (vacío OK); `SELECT temporal_type FROM sys.tables WHERE name = 'Usuario'` (debe devolver `2` = system-versioned).
5. Si algo falla → `V010_ROLLBACK.sql` (pierde toda la historia) o restore de backup.
**Catálogo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catálogo y activar `SYSTEM_VERSIONING` en su migración.
## Recursos
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
- Decisión persistida (engram): `sig-cm2/audit-architecture`
- SDD artifacts UDT-010 (engram): `sdd/udt-010-auditoria-trazabilidad/{explore,proposal,spec,design,tasks,apply-progress}`

View File

@@ -0,0 +1,183 @@
-- V010_ROLLBACK.sql
-- Reversa de V010__audit_infrastructure.sql.
--
-- ⚠️ ADVERTENCIA: ejecutar este script ELIMINA toda la historia auditada.
-- - dbo.AuditEvent y dbo.SecurityEvent se dropean (junto con datos).
-- - History tables (Usuario_History, Rol_History, Permiso_History, RolPermiso_History) se dropean.
-- - Particionamiento, filegroups y archivos físicos se desmontan.
--
-- Uso intended: ROLLBACK de emergencia en entornos NO-productivos.
-- En prod futuro, este script NO se ejecuta: si hace falta revertir, se hace
-- restore de backup previo a V010.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover columnas PERIOD en las 4 tablas
-- ═══════════════════════════════════════════════════════════════════════
-- Usuario
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Usuario SET (SYSTEM_VERSIONING = OFF);
PRINT 'Usuario: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Usuario'))
BEGIN
ALTER TABLE dbo.Usuario DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Usuario: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidFrom;
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidTo;
ALTER TABLE dbo.Usuario DROP COLUMN ValidFrom, ValidTo;
PRINT 'Usuario: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Usuario_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Usuario_History;
PRINT 'Usuario_History dropped.';
END
GO
-- Rol
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2)
ALTER TABLE dbo.Rol SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rol'))
ALTER TABLE dbo.Rol DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidFrom;
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidTo;
ALTER TABLE dbo.Rol DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.Rol_History', N'U') IS NOT NULL DROP TABLE dbo.Rol_History;
GO
-- Permiso
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2)
ALTER TABLE dbo.Permiso SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Permiso'))
ALTER TABLE dbo.Permiso DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidFrom;
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidTo;
ALTER TABLE dbo.Permiso DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.Permiso_History', N'U') IS NOT NULL DROP TABLE dbo.Permiso_History;
GO
-- RolPermiso
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2)
ALTER TABLE dbo.RolPermiso SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.RolPermiso'))
ALTER TABLE dbo.RolPermiso DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidFrom;
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidTo;
ALTER TABLE dbo.RolPermiso DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.RolPermiso_History', N'U') IS NOT NULL DROP TABLE dbo.RolPermiso_History;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop AuditEvent + SecurityEvent
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.AuditEvent;
PRINT 'Table dbo.AuditEvent dropped.';
END
GO
IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecurityEvent;
PRINT 'Table dbo.SecurityEvent dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Drop partition schemes + functions
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly')
DROP PARTITION SCHEME ps_AuditEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly')
DROP PARTITION FUNCTION pf_AuditEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly')
DROP PARTITION SCHEME ps_SecurityEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly')
DROP PARTITION FUNCTION pf_SecurityEvent_Monthly;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Remover archivos físicos y filegroups
-- ═══════════════════════════════════════════════════════════════════════
DECLARE @dbName NVARCHAR(128) = DB_NAME();
DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT';
DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD';
DECLARE @sql NVARCHAR(MAX);
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @hotLogical + N'];';
EXEC sp_executesql @sql;
PRINT 'File ' + @hotLogical + ' removed.';
END
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @coldLogical + N'];';
EXEC sp_executesql @sql;
PRINT 'File ' + @coldLogical + ' removed.';
END
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT')
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_HOT;';
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD')
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_COLD;';
GO
PRINT '';
PRINT 'V010 rolled back. Audit infrastructure removed. All audit history is permanently LOST.';
GO

View File

@@ -0,0 +1,434 @@
-- V010__audit_infrastructure.sql
-- UDT-010: Infraestructura de Auditoría y Trazabilidad (Fase 0.5 — transversal).
--
-- Cambios:
-- 1. Filegroups AUDIT_HOT y AUDIT_COLD (archivos físicos en default data path).
-- 2. Partition functions + schemes mensuales (RANGE RIGHT) para AuditEvent y SecurityEvent.
-- 3. dbo.AuditEvent particionada + 4 índices + CHECK constraints.
-- 4. dbo.SecurityEvent particionada + 3 índices + CHECK constraints.
-- 5. SYSTEM_VERSIONING en dbo.Usuario, dbo.Rol, dbo.Permiso, dbo.RolPermiso
-- con HISTORY_RETENTION_PERIOD = 10 YEARS y PAGE compression en history tables.
--
-- Source of truth del diseño: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V010_ROLLBACK.sql (pierde TODA la historia auditada).
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). Para producción futuro,
-- revisar database/README.md (ventana de mantenimiento + backup previo).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. FILEGROUPS + ARCHIVOS FÍSICOS
-- ═══════════════════════════════════════════════════════════════════════
-- Usamos el default data path del server + DB_NAME como prefijo lógico,
-- así SIGCM2 y SIGCM2_Test coexisten sin colisión de logical file names.
DECLARE @dbName NVARCHAR(128) = DB_NAME();
DECLARE @dataPath NVARCHAR(260) = CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS NVARCHAR(260));
DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT';
DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD';
DECLARE @hotPhysical NVARCHAR(260) = @dataPath + @hotLogical + N'.ndf';
DECLARE @coldPhysical NVARCHAR(260) = @dataPath + @coldLogical + N'.ndf';
DECLARE @sql NVARCHAR(MAX);
-- Filegroup AUDIT_HOT
IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT')
BEGIN
EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_HOT;';
PRINT 'Filegroup AUDIT_HOT created.';
END
ELSE
PRINT 'Filegroup AUDIT_HOT already exists — skip.';
IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT ADD FILE (
NAME = N''' + @hotLogical + N''',
FILENAME = N''' + @hotPhysical + N''',
SIZE = 64MB,
FILEGROWTH = 64MB
) TO FILEGROUP AUDIT_HOT;';
EXEC sp_executesql @sql;
PRINT 'File ' + @hotLogical + ' added to filegroup AUDIT_HOT.';
END
ELSE
PRINT 'File ' + @hotLogical + ' already exists — skip.';
-- Filegroup AUDIT_COLD
IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD')
BEGIN
EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_COLD;';
PRINT 'Filegroup AUDIT_COLD created.';
END
ELSE
PRINT 'Filegroup AUDIT_COLD already exists — skip.';
IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT ADD FILE (
NAME = N''' + @coldLogical + N''',
FILENAME = N''' + @coldPhysical + N''',
SIZE = 64MB,
FILEGROWTH = 64MB
) TO FILEGROUP AUDIT_COLD;';
EXEC sp_executesql @sql;
PRINT 'File ' + @coldLogical + ' added to filegroup AUDIT_COLD.';
END
ELSE
PRINT 'File ' + @coldLogical + ' already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. PARTITION FUNCTIONS + SCHEMES (mensuales, RANGE RIGHT)
-- ═══════════════════════════════════════════════════════════════════════
-- Boundaries iniciales: 14 valores de 2026-01-01 a 2027-02-01 → 15 particiones.
-- AuditPartitionManagerJob (B11) extiende mes a mes automáticamente.
IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly')
BEGIN
CREATE PARTITION FUNCTION pf_AuditEvent_Monthly (DATETIME2(3))
AS RANGE RIGHT FOR VALUES (
'2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000',
'2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000',
'2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000',
'2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000',
'2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000'
);
PRINT 'Partition function pf_AuditEvent_Monthly created.';
END
ELSE
PRINT 'Partition function pf_AuditEvent_Monthly already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly')
BEGIN
CREATE PARTITION SCHEME ps_AuditEvent_Monthly
AS PARTITION pf_AuditEvent_Monthly ALL TO ([AUDIT_HOT]);
PRINT 'Partition scheme ps_AuditEvent_Monthly created.';
END
ELSE
PRINT 'Partition scheme ps_AuditEvent_Monthly already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly')
BEGIN
CREATE PARTITION FUNCTION pf_SecurityEvent_Monthly (DATETIME2(3))
AS RANGE RIGHT FOR VALUES (
'2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000',
'2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000',
'2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000',
'2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000',
'2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000'
);
PRINT 'Partition function pf_SecurityEvent_Monthly created.';
END
ELSE
PRINT 'Partition function pf_SecurityEvent_Monthly already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly')
BEGIN
CREATE PARTITION SCHEME ps_SecurityEvent_Monthly
AS PARTITION pf_SecurityEvent_Monthly ALL TO ([AUDIT_HOT]);
PRINT 'Partition scheme ps_SecurityEvent_Monthly created.';
END
ELSE
PRINT 'Partition scheme ps_SecurityEvent_Monthly already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. dbo.AuditEvent (eventos de dominio, retention 10 años)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NULL
BEGIN
CREATE TABLE dbo.AuditEvent (
Id BIGINT IDENTITY(1,1) NOT NULL,
OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_AuditEvent_OccurredAt DEFAULT(SYSUTCDATETIME()),
ActorUserId INT NULL, -- NULL solo para eventos del sistema (jobs)
ActorRoleId INT NULL, -- rol efectivo al momento del evento (denormalizado)
Action VARCHAR(100) NOT NULL, -- "usuario.create", "cliente.deactivate"
TargetType VARCHAR(50) NOT NULL, -- "Usuario", "Cliente", "Factura"
TargetId VARCHAR(100) NOT NULL, -- PK del target como string (soporta INT/GUID)
CorrelationId UNIQUEIDENTIFIER NULL, -- linkea eventos de una misma operación
IpAddress VARCHAR(45) NULL, -- IPv4/IPv6
UserAgent VARCHAR(500) NULL,
Metadata NVARCHAR(MAX) NULL, -- JSON libre (ya sanitizado por la app)
CONSTRAINT PK_AuditEvent PRIMARY KEY CLUSTERED (OccurredAt, Id)
ON ps_AuditEvent_Monthly(OccurredAt),
CONSTRAINT CK_AuditEvent_Action CHECK (Action LIKE '%.%'),
CONSTRAINT CK_AuditEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1)
) ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Table dbo.AuditEvent created (partitioned monthly on OccurredAt).';
END
ELSE
PRINT 'Table dbo.AuditEvent already exists — skip.';
GO
-- Índices (cubren 95% de queries: actor / target / action / correlation)
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Actor' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Actor
ON dbo.AuditEvent(ActorUserId, OccurredAt DESC)
INCLUDE (Action, TargetType, TargetId, CorrelationId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Actor created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Target' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Target
ON dbo.AuditEvent(TargetType, TargetId, OccurredAt DESC)
INCLUDE (ActorUserId, Action, CorrelationId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Target created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Action' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Action
ON dbo.AuditEvent(Action, OccurredAt DESC)
INCLUDE (ActorUserId, TargetType, TargetId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Action created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Correlation' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Correlation
ON dbo.AuditEvent(CorrelationId)
WHERE CorrelationId IS NOT NULL
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Correlation (filtered) created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. dbo.SecurityEvent (eventos de seguridad, retention 5 años)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SecurityEvent (
Id BIGINT IDENTITY(1,1) NOT NULL,
OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_SecurityEvent_OccurredAt DEFAULT(SYSUTCDATETIME()),
ActorUserId INT NULL, -- NULL si login falló y user no existe
AttemptedUsername VARCHAR(256) NULL, -- para login failures
SessionId UNIQUEIDENTIFIER NULL,
Action VARCHAR(100) NOT NULL, -- "login", "logout", "refresh.reuse_detected", "permission.denied"
Result VARCHAR(20) NOT NULL, -- "success" | "failure"
FailureReason VARCHAR(200) NULL, -- "invalid_password", "account_locked"
IpAddress VARCHAR(45) NULL,
UserAgent VARCHAR(500) NULL,
Metadata NVARCHAR(MAX) NULL,
CONSTRAINT PK_SecurityEvent PRIMARY KEY CLUSTERED (OccurredAt, Id)
ON ps_SecurityEvent_Monthly(OccurredAt),
CONSTRAINT CK_SecurityEvent_Result CHECK (Result IN ('success','failure')),
CONSTRAINT CK_SecurityEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1)
) ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Table dbo.SecurityEvent created (partitioned monthly on OccurredAt).';
END
ELSE
PRINT 'Table dbo.SecurityEvent already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Actor' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Actor
ON dbo.SecurityEvent(ActorUserId, OccurredAt DESC)
INCLUDE (Action, Result, SessionId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Index IX_SecurityEvent_Actor created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Action_Result' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Action_Result
ON dbo.SecurityEvent(Action, Result, OccurredAt DESC)
INCLUDE (ActorUserId, IpAddress)
WITH (DATA_COMPRESSION = PAGE)
ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Index IX_SecurityEvent_Action_Result created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Ip_Failure' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Ip_Failure
ON dbo.SecurityEvent(IpAddress, OccurredAt DESC)
WHERE Result = 'failure'
ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Index IX_SecurityEvent_Ip_Failure (filtered) created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. SYSTEM_VERSIONING — Usuario, Rol, Permiso, RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
-- Patrón: (a) agregar PERIOD FOR SYSTEM_TIME con columnas HIDDEN,
-- (b) activar SYSTEM_VERSIONING con HISTORY_RETENTION_PERIOD 10 YEARS,
-- (c) rebuild history con PAGE compression.
-- Tablas con datos: los registros existentes reciben ValidFrom = instante del ALTER.
-- ─── Usuario ───────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Usuario
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Usuario_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Usuario_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Usuario: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Usuario
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Usuario_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Usuario: SYSTEM_VERSIONING = ON (history: dbo.Usuario_History, retention: 10 years).';
END
ELSE
PRINT 'Usuario: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Usuario_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Usuario_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Usuario_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Usuario_History: rebuilt with PAGE compression.';
END
GO
-- ─── Rol ───────────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Rol
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Rol_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Rol_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Rol: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Rol
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Rol_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Rol: SYSTEM_VERSIONING = ON.';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rol_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Rol_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Rol_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Rol_History: rebuilt with PAGE compression.';
END
GO
-- ─── Permiso ───────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Permiso
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Permiso_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Permiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Permiso: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Permiso
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Permiso_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Permiso: SYSTEM_VERSIONING = ON.';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Permiso_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Permiso_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Permiso_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Permiso_History: rebuilt with PAGE compression.';
END
GO
-- ─── RolPermiso ────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.RolPermiso
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_RolPermiso_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_RolPermiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'RolPermiso: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.RolPermiso
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.RolPermiso_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'RolPermiso: SYSTEM_VERSIONING = ON.';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'RolPermiso_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'RolPermiso_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.RolPermiso_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'RolPermiso_History: rebuilt with PAGE compression.';
END
GO
PRINT '';
PRINT 'V010 applied successfully — audit infrastructure + temporal tables active.';
PRINT 'Next: runs in B2 onwards (Application.Audit abstractions).';
GO

View File

@@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
namespace SIGCM2.Api.Authorization;
@@ -12,21 +13,25 @@ namespace SIGCM2.Api.Authorization;
/// and IUsuarioRepository, resolves effective permissions via PermisoResolver,
/// and succeeds if at least one required permission matches (OR semantics).
/// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3).
/// UDT-010: emits SecurityEvent 'permission.denied' on rejection.
/// </summary>
public sealed class PermissionAuthorizationHandler
: AuthorizationHandler<RequirePermissionAttribute>
{
private readonly IRolPermisoRepository _rolPermisoRepo;
private readonly IUsuarioRepository _usuarioRepo;
private readonly ISecurityEventLogger _security;
private readonly ILogger<PermissionAuthorizationHandler> _logger;
public PermissionAuthorizationHandler(
IRolPermisoRepository rolPermisoRepo,
IUsuarioRepository usuarioRepo,
ISecurityEventLogger security,
ILogger<PermissionAuthorizationHandler> logger)
{
_rolPermisoRepo = rolPermisoRepo;
_usuarioRepo = usuarioRepo;
_security = security;
_logger = logger;
}
@@ -83,8 +88,17 @@ public sealed class PermissionAuthorizationHandler
}
// 8. Stash required permission for ForbiddenProblemDetailsHandler
var requiredPermission = requirement.PermissionCodes[0];
if (context.Resource is HttpContext httpContext)
httpContext.Items["RequiredPermission"] = requirement.PermissionCodes[0];
httpContext.Items["RequiredPermission"] = requiredPermission;
// 9. Emit SecurityEvent for the denial
var endpoint = (context.Resource as HttpContext)?.Request?.Path.Value;
var method = (context.Resource as HttpContext)?.Request?.Method;
await _security.LogAsync("permission.denied", "failure",
actorUserId: userId,
failureReason: $"missing_permission:{requiredPermission}",
metadata: new { permissionRequired = requiredPermission, endpoint, method });
context.Fail(new AuthorizationFailureReason(this,
$"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));

View File

@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Audit;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// UDT-010: Read-only endpoint for audit events. Requires administracion:auditoria:ver.
/// Cursor-based DESC pagination with 4 filter axes (actor/target/from/to).
/// Rich UI (drilldown, export CSV, timeline) is deferred to ADM-004.
/// </summary>
[ApiController]
[Route("api/v1/audit")]
public sealed class AuditController : ControllerBase
{
private readonly IAuditEventRepository _repo;
public AuditController(IAuditEventRepository repo)
{
_repo = repo;
}
/// <summary>Lists audit events with optional filters. Cursor-based DESC pagination.</summary>
[HttpGet("events")]
[RequirePermission("administracion:auditoria:ver")]
[ProducesResponseType(typeof(AuditEventPageResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetEvents(
[FromQuery] int? actorUserId = null,
[FromQuery] string? targetType = null,
[FromQuery] string? targetId = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
[FromQuery] string? cursor = null,
[FromQuery] int limit = 50,
CancellationToken ct = default)
{
if (limit < 1 || limit > 100)
return BadRequest(new { error = "limit must be between 1 and 100" });
if (from is not null && to is not null && from > to)
return BadRequest(new { error = "from must be <= to" });
var filter = new AuditEventFilter(
ActorUserId: actorUserId,
TargetType: targetType,
TargetId: targetId,
From: from,
To: to,
Cursor: cursor,
Limit: limit);
var result = await _repo.QueryAsync(filter, ct);
return Ok(new AuditEventPageResponse(result.Items, result.NextCursor));
}
}
/// <summary>UDT-010: Paginated response wrapper for GET /api/v1/audit/events.</summary>
public sealed record AuditEventPageResponse(
IReadOnlyList<AuditEventDto> Items,
string? NextCursor);

View File

@@ -0,0 +1,126 @@
using Dapper;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Api.HealthChecks;
/// <summary>
/// UDT-010 (#REQ-AUD-8): health check for audit infrastructure.
/// Validates:
/// - SYSTEM_VERSIONING is ON for Usuario/Rol/Permiso/RolPermiso.
/// - Monthly partitions exist for the next 3 months on AuditEvent + SecurityEvent.
/// - Last AuditEvent is recent enough (< 24h) — relaxed from 1h spec to accommodate
/// quiet dev/test environments; prod deployments should tighten to 1h via config.
/// - HISTORY_RETENTION_PERIOD matches 10 years for the 4 versioned catalog tables.
/// Returns Unhealthy with details when any check fails.
/// </summary>
public sealed class AuditHealthCheck : IHealthCheck
{
private readonly SqlConnectionFactory _factory;
public AuditHealthCheck(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(cancellationToken);
// 1. SYSTEM_VERSIONING checks
var versionedMissing = (await conn.QueryAsync<string>("""
SELECT t.name
FROM sys.tables t
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
AND t.temporal_type <> 2;
""")).ToList();
if (versionedMissing.Any())
{
return HealthCheckResult.Unhealthy(
$"SYSTEM_VERSIONING missing on: {string.Join(",", versionedMissing)}");
}
// 2. Partitions for next 3 months in both event tables
var now = DateTime.UtcNow;
var requiredBoundaries = new[]
{
new DateTime(now.Year, now.Month, 1).AddMonths(1),
new DateTime(now.Year, now.Month, 1).AddMonths(2),
new DateTime(now.Year, now.Month, 1).AddMonths(3),
};
foreach (var pfName in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
{
var values = (await conn.QueryAsync<DateTime>("""
SELECT CAST(prv.value AS DATETIME2(3))
FROM sys.partition_functions pf
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
WHERE pf.name = @Name;
""", new { Name = pfName })).ToHashSet();
foreach (var req in requiredBoundaries)
{
if (!values.Contains(req))
{
return HealthCheckResult.Unhealthy(
$"Partition boundary missing in {pfName}: {req:yyyy-MM-dd}");
}
}
}
// 3. Recent audit activity — lenient 24h to avoid false positives in quiet envs
var lastEventAt = await conn.ExecuteScalarAsync<DateTime?>(
"SELECT MAX(OccurredAt) FROM dbo.AuditEvent;");
var recentMessage = lastEventAt is null
? "no audit events yet (acceptable on fresh DB)"
: (now - lastEventAt.Value).TotalHours < 24
? "recent"
: $"stale: last event {(now - lastEventAt.Value).TotalHours:F1}h ago";
// 4. Retention period check.
// sys.tables.history_retention_period stores a signed int in UNITS defined by
// history_retention_period_unit: 1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR, -1=not applicable.
// V010 sets HISTORY_RETENTION_PERIOD = 10 YEARS → period=10, unit=6.
var retentionRows = (await conn.QueryAsync<(string TableName, int? Period, int? Unit)>("""
SELECT t.name AS TableName,
t.history_retention_period AS Period,
t.history_retention_period_unit AS Unit
FROM sys.tables t
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
AND t.temporal_type = 2;
""")).ToList();
var badRetention = retentionRows
.Where(r => !(r.Period == 10 && r.Unit == 6)) // not 10 YEARS
.Select(r => r.TableName)
.ToList();
var data = new Dictionary<string, object>
{
["versionedTables"] = "Usuario, Rol, Permiso, RolPermiso",
["lastAuditEvent"] = (object?)lastEventAt ?? "none",
["lastAuditEventStatus"] = recentMessage,
["retentionOk"] = badRetention.Count == 0,
};
if (badRetention.Any())
{
return HealthCheckResult.Degraded(
$"Retention != 10 YEARS for: {string.Join(",", badRetention)}",
data: data);
}
return HealthCheckResult.Healthy("audit infrastructure OK", data);
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("audit health check threw", ex);
}
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
namespace SIGCM2.Api.Middleware;
/// UDT-010 — post-auth middleware that reads the JWT "sub" claim and stores the
/// resolved ActorUserId in HttpContext.Items. Anonymous requests leave it unset.
/// ActorRoleId is reserved for a future batch (rol code → id resolution).
public sealed class AuditActorMiddleware
{
public const string ItemActorUserId = "audit:actorUserId";
private readonly RequestDelegate _next;
public AuditActorMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext ctx)
{
if (ctx.User.Identity?.IsAuthenticated == true)
{
var sub = ctx.User.FindFirst("sub")?.Value;
if (int.TryParse(sub, out var userId))
{
ctx.Items[ItemActorUserId] = userId;
}
}
await _next(ctx);
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Http;
namespace SIGCM2.Api.Middleware;
/// UDT-010 — pre-auth middleware that stamps every request with a correlation ID,
/// preserves one sent by the client via X-Correlation-Id, and exposes it on the response.
/// Also captures Ip + UserAgent for downstream IAuditContext consumers.
public sealed class CorrelationIdMiddleware
{
public const string HeaderName = "X-Correlation-Id";
public const string ItemCorrelationId = "audit:correlationId";
public const string ItemIp = "audit:ip";
public const string ItemUserAgent = "audit:userAgent";
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext ctx)
{
Guid correlationId;
if (ctx.Request.Headers.TryGetValue(HeaderName, out var incoming)
&& Guid.TryParse(incoming.ToString(), out var parsed))
{
correlationId = parsed;
}
else
{
correlationId = Guid.NewGuid();
}
ctx.Items[ItemCorrelationId] = correlationId;
ctx.Items[ItemIp] = ctx.Connection.RemoteIpAddress?.ToString();
ctx.Items[ItemUserAgent] = ctx.Request.Headers.UserAgent.ToString();
ctx.Response.OnStarting(() =>
{
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
return Task.CompletedTask;
});
// Also set immediately for testability — DefaultHttpContext does not trigger OnStarting
// in unit tests because no body is written through the pipeline.
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
await _next(ctx);
}
}

View File

@@ -2,8 +2,11 @@ using Microsoft.AspNetCore.Authorization;
using Serilog;
using Scalar.AspNetCore;
using SIGCM2.Api.Authorization;
using SIGCM2.Api.HealthChecks;
using SIGCM2.Api.Middleware;
using SIGCM2.Application;
using SIGCM2.Infrastructure;
using SIGCM2.Infrastructure.Audit.Jobs;
using SIGCM2.Api.Filters;
// Bootstrap logger — before DI is built
@@ -23,6 +26,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// UDT-010: Quartz.NET + 3 audit maintenance jobs (partition, retention, integrity).
// Disabled in Testing environment to keep integration tests deterministic.
if (!builder.Environment.IsEnvironment("Testing"))
builder.Services.AddAuditMaintenance(builder.Configuration);
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
builder.Services.AddAuthorization();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
@@ -37,6 +45,10 @@ builder.Services.AddControllers(opts =>
// OpenAPI / Scalar
builder.Services.AddOpenApi();
// UDT-010: Audit infrastructure health check
builder.Services.AddHealthChecks()
.AddCheck<AuditHealthCheck>("audit", tags: new[] { "audit" });
// CORS
var allowedOrigins = builder.Configuration
.GetSection("Cors:AllowedOrigins")
@@ -66,10 +78,21 @@ if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
app.UseHttpsRedirection();
app.UseCors();
// UDT-010: correlation id + ip/ua capture runs BEFORE auth so anonymous requests
// still get a correlation id and so logs can tie pre-auth events to the request.
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseAuthentication();
// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim.
app.UseMiddleware<AuditActorMiddleware>();
app.UseAuthorization();
app.MapControllers();
// UDT-010: /health/audit returns the audit check status (public but sparse data).
app.MapHealthChecks("/health/audit", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = r => r.Tags.Contains("audit"),
});
app.Run();
// Exposed for WebApplicationFactory in integration tests

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Application.Audit;
/// Read projection of dbo.AuditEvent for GET /api/v1/audit/events.
/// ActorUsername is resolved by join against dbo.Usuario at query time (denormalized in the DTO).
public sealed record AuditEventDto(
long Id,
DateTime OccurredAt,
int? ActorUserId,
string? ActorUsername,
string Action,
string TargetType,
string TargetId,
Guid? CorrelationId,
string? IpAddress,
string? Metadata);

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Audit;
/// Filter criteria for GET /api/v1/audit/events.
/// Cursor is opaque to the caller (base64-encoded {OccurredAt, Id} tuple for stable DESC pagination).
/// Limit is clamped between 1 and 100 at the validation layer; default 50 is the sane middle.
public sealed record AuditEventFilter(
int? ActorUserId,
string? TargetType,
string? TargetId,
DateTime? From,
DateTime? To,
string? Cursor,
int Limit = 50);

View File

@@ -0,0 +1,27 @@
namespace SIGCM2.Application.Audit;
/// Bound from appsettings section "Audit".
/// Extensibility model: ADDITIVE via `IConfiguration` array binding. Setting
/// `Audit:SanitizedKeys:N` at indices beyond the defaults APPENDS custom keys;
/// indices 0..10 OVERWRITE the defaults. To fully replace, use a `PostConfigure`
/// in DI. This mirrors the standard .NET array-binding quirk intentionally —
/// the default keys are security-critical and should not be silently dropped.
public sealed class AuditOptions
{
public const string SectionName = "Audit";
public string[] SanitizedKeys { get; set; } =
[
"password",
"passwordHash",
"token",
"refreshToken",
"accessToken",
"cvv",
"card",
"cardNumber",
"secret",
"apiKey",
"privateKey",
];
}

View File

@@ -0,0 +1,14 @@
namespace SIGCM2.Application.Audit;
/// Request-scoped context populated by the audit middleware pipeline:
/// CorrelationIdMiddleware (pre-auth) sets CorrelationId/Ip/UserAgent;
/// AuditActorMiddleware (post-auth) fills ActorUserId/ActorRoleId from JWT claims.
/// Consumed by IAuditLogger to enrich every AuditEvent emitted within the request.
public interface IAuditContext
{
int? ActorUserId { get; }
int? ActorRoleId { get; }
string? Ip { get; }
string? UserAgent { get; }
Guid CorrelationId { get; }
}

View File

@@ -0,0 +1,27 @@
namespace SIGCM2.Application.Audit;
public sealed record AuditEventQueryResult(
IReadOnlyList<AuditEventDto> Items,
string? NextCursor);
/// Persists and queries AuditEvent rows. Insert participates in any ambient
/// TransactionScope (single connection string enlistment — validated by B0 spike).
public interface IAuditEventRepository
{
Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
int? actorRoleId,
string action,
string targetType,
string targetId,
Guid? correlationId,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default);
Task<AuditEventQueryResult> QueryAsync(
AuditEventFilter filter,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Application.Audit;
/// Emits dbo.AuditEvent rows for domain-level actions (create/update/delete of business entities).
/// LogAsync enlists in the ambient TransactionScope — if the INSERT fails, the caller's command rolls back.
/// Metadata is sanitized (see AuditOptions.SanitizedKeys) before persisting.
/// If IAuditContext.ActorUserId is null while called from a user-scoped command, an
/// AuditContextMissingException is thrown — fail-closed by design.
public interface IAuditLogger
{
Task LogAsync(
string action,
string targetType,
string targetId,
object? metadata = null,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,20 @@
namespace SIGCM2.Application.Audit;
/// Emits dbo.SecurityEvent rows for auth/authorization events (login, logout, refresh, permission.denied).
/// Separate from IAuditLogger because:
/// - Volume is ~100× higher than AuditEvent (retention 5y vs 10y).
/// - Invoked from infrastructure layers (auth handlers, middlewares) where no ambient
/// business transaction exists — not enlisted in TransactionScope.
/// - Schema includes Result/FailureReason/AttemptedUsername/SessionId specific to security events.
public interface ISecurityEventLogger
{
Task LogAsync(
string action,
string result,
int? actorUserId = null,
string? attemptedUsername = null,
Guid? sessionId = null,
string? failureReason = null,
object? metadata = null,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Application.Audit;
/// Persists SecurityEvent rows. NOT enlisted in business TransactionScope — security
/// events are fire-and-forget writes from auth handlers and middleware.
public interface ISecurityEventRepository
{
Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
string? attemptedUsername,
Guid? sessionId,
string action,
string result,
string? failureReason,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default);
}

View File

@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -19,6 +20,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
private readonly IClientContext _clientContext;
private readonly AuthOptions _authOptions;
private readonly IRolPermisoRepository _rolPermisoRepository;
private readonly ISecurityEventLogger _security;
private readonly ILogger<LoginCommandHandler> _logger;
public LoginCommandHandler(
@@ -30,6 +32,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
IClientContext clientContext,
AuthOptions authOptions,
IRolPermisoRepository rolPermisoRepository,
ISecurityEventLogger security,
ILogger<LoginCommandHandler> logger)
{
_repository = repository;
@@ -40,6 +43,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
_clientContext = clientContext;
_authOptions = authOptions;
_rolPermisoRepository = rolPermisoRepository;
_security = security;
_logger = logger;
}
@@ -47,12 +51,30 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
{
var usuario = await _repository.GetByUsernameAsync(command.Username);
// Deliberately vague — never reveal which check failed
if (usuario is null || !usuario.Activo)
// Deliberately vague to the client — never reveal which check failed.
// Internally, SecurityEvent captures the precise FailureReason for ops.
if (usuario is null)
{
await _security.LogAsync("login", "failure",
actorUserId: null, attemptedUsername: command.Username,
failureReason: "user_not_found");
throw new InvalidCredentialsException();
}
if (!usuario.Activo)
{
await _security.LogAsync("login", "failure",
actorUserId: usuario.Id, attemptedUsername: command.Username,
failureReason: "user_inactive");
throw new InvalidCredentialsException();
}
if (!_hasher.Verify(command.Password, usuario.PasswordHash))
{
await _security.LogAsync("login", "failure",
actorUserId: usuario.Id, attemptedUsername: command.Username,
failureReason: "invalid_password");
throw new InvalidCredentialsException();
}
var accessToken = _jwtService.GenerateAccessToken(usuario);
@@ -83,6 +105,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray();
await _security.LogAsync("login", "success", actorUserId: usuario.Id);
return new LoginResponseDto(
AccessToken: accessToken,
RefreshToken: rawRefresh, // raw to client — never stored

View File

@@ -1,15 +1,18 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
namespace SIGCM2.Application.Auth.Logout;
public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, LogoutResponseDto>
{
private readonly IRefreshTokenRepository _refreshRepo;
private readonly ISecurityEventLogger _security;
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo)
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo, ISecurityEventLogger security)
{
_refreshRepo = refreshRepo;
_security = security;
}
public async Task<LogoutResponseDto> Handle(LogoutCommand command)
@@ -17,6 +20,7 @@ public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, Logout
// Revoke all active tokens for the user across all families.
// Idempotent: 0 rows affected is not an error.
await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, DateTime.UtcNow);
await _security.LogAsync("logout", "success", actorUserId: command.UsuarioId);
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
}
}

View File

@@ -1,6 +1,7 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;
@@ -15,6 +16,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
private readonly IRefreshTokenGenerator _refreshGenerator;
private readonly IClientContext _clientCtx;
private readonly AuthOptions _authOptions;
private readonly ISecurityEventLogger _security;
public RefreshCommandHandler(
IRefreshTokenRepository refreshRepo,
@@ -22,7 +24,8 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
IJwtService jwt,
IRefreshTokenGenerator refreshGenerator,
IClientContext clientCtx,
AuthOptions authOptions)
AuthOptions authOptions,
ISecurityEventLogger security)
{
_refreshRepo = refreshRepo;
_usuarioRepo = usuarioRepo;
@@ -30,6 +33,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
_refreshGenerator = refreshGenerator;
_clientCtx = clientCtx;
_authOptions = authOptions;
_security = security;
}
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
@@ -62,23 +66,44 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
if (stored.IsRevoked)
{
await _refreshRepo.RevokeFamilyAsync(stored.FamilyId, now);
await _security.LogAsync("refresh.reuse_detected", "failure",
actorUserId: stored.UsuarioId,
failureReason: "token_reused",
metadata: new { familyId = stored.FamilyId });
throw new TokenReuseDetectedException();
}
// 5. Absolute expiration check
if (stored.IsExpired(now))
{
await _security.LogAsync("refresh.issue", "failure",
actorUserId: stored.UsuarioId, failureReason: "token_expired");
throw new InvalidRefreshTokenException();
}
// 6. UsuarioId must match access token's sub claim
if (stored.UsuarioId != accessUserId)
{
await _security.LogAsync("refresh.issue", "failure",
actorUserId: stored.UsuarioId, failureReason: "sub_mismatch");
throw new InvalidRefreshTokenException();
}
// 7. Load current user (so access token has up-to-date claims)
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId)
?? throw new InvalidRefreshTokenException();
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId);
if (usuario is null)
{
await _security.LogAsync("refresh.issue", "failure",
actorUserId: stored.UsuarioId, failureReason: "user_not_found");
throw new InvalidRefreshTokenException();
}
if (!usuario.Activo)
{
await _security.LogAsync("refresh.issue", "failure",
actorUserId: usuario.Id, failureReason: "user_inactive");
throw new InvalidRefreshTokenException();
}
// 8. Rotate: create new token, persist, then revoke old
var newRaw = _refreshGenerator.Generate();
@@ -90,6 +115,9 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
// 9. Issue new access token
var newAccess = _jwt.GenerateAccessToken(usuario);
await _security.LogAsync("refresh.issue", "success", actorUserId: usuario.Id);
return new RefreshResponseDto(newAccess, newRaw, _authOptions.AccessTokenMinutes * 60);
}
}

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Domain.Exceptions;
@@ -10,15 +12,18 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
private readonly IRolRepository _rolRepository;
private readonly IPermisoRepository _permisoRepository;
private readonly IRolPermisoRepository _rolPermisoRepository;
private readonly IAuditLogger _audit;
public AssignPermisosToRolCommandHandler(
IRolRepository rolRepository,
IPermisoRepository permisoRepository,
IRolPermisoRepository rolPermisoRepository)
IRolPermisoRepository rolPermisoRepository,
IAuditLogger audit)
{
_rolRepository = rolRepository;
_permisoRepository = permisoRepository;
_rolPermisoRepository = rolPermisoRepository;
_audit = audit;
}
public async Task<IReadOnlyList<PermisoDto>> Handle(AssignPermisosToRolCommand command)
@@ -40,9 +45,28 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
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);
// Capture "before" snapshot for audit diff
var previousPermisos = await _rolPermisoRepository.GetByRolCodigoAsync(rol.Codigo);
var beforeCodigos = previousPermisos.Select(p => p.Codigo).OrderBy(c => c, StringComparer.Ordinal).ToArray();
var afterCodigos = permisos.Select(p => p.Codigo).OrderBy(c => c, StringComparer.Ordinal).ToArray();
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
// 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);
await _audit.LogAsync(
action: "rol.permisos_update",
targetType: "Rol",
targetId: rol.Id.ToString(),
metadata: new { before = beforeCodigos, after = afterCodigos });
tx.Complete();
}
// 4. Retornar el nuevo set asignado
return permisos

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -9,10 +11,12 @@ namespace SIGCM2.Application.Roles.Create;
public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand, RolCreatedDto>
{
private readonly IRolRepository _repository;
private readonly IAuditLogger _audit;
public CreateRolCommandHandler(IRolRepository repository)
public CreateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
{
_repository = repository;
_audit = audit;
}
public async Task<RolCreatedDto> Handle(CreateRolCommand command)
@@ -24,7 +28,23 @@ public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand,
throw new RolAlreadyExistsException(command.Codigo);
var rol = Rol.ForCreation(command.Codigo, command.Nombre, command.Descripcion);
var newId = await _repository.AddAsync(rol);
int newId;
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repository.AddAsync(rol);
await _audit.LogAsync(
action: "rol.create",
targetType: "Rol",
targetId: newId.ToString(),
metadata: new { after = new { rol.Codigo, rol.Nombre, rol.Descripcion } });
tx.Complete();
}
return new RolCreatedDto(
Id: newId,

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Domain.Exceptions;
@@ -8,10 +10,12 @@ namespace SIGCM2.Application.Roles.Deactivate;
public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolCommand, RolDto>
{
private readonly IRolRepository _repository;
private readonly IAuditLogger _audit;
public DeactivateRolCommandHandler(IRolRepository repository)
public DeactivateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
{
_repository = repository;
_audit = audit;
}
public async Task<RolDto> Handle(DeactivateRolCommand command)
@@ -23,10 +27,23 @@ public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolC
if (await _repository.HasActiveUsuariosAsync(command.Codigo))
throw new RolInUseException(command.Codigo);
var updated = await _repository.UpdateAsync(
existing.Codigo, existing.Nombre, existing.Descripcion, activo: false);
if (!updated)
throw new RolNotFoundException(command.Codigo);
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
var updated = await _repository.UpdateAsync(
existing.Codigo, existing.Nombre, existing.Descripcion, activo: false);
if (!updated)
throw new RolNotFoundException(command.Codigo);
await _audit.LogAsync(
action: "rol.deactivate",
targetType: "Rol",
targetId: existing.Id.ToString());
tx.Complete();
}
var rol = await _repository.GetByCodigoAsync(command.Codigo)
?? throw new RolNotFoundException(command.Codigo);

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Domain.Exceptions;
@@ -8,19 +10,42 @@ namespace SIGCM2.Application.Roles.Update;
public sealed class UpdateRolCommandHandler : ICommandHandler<UpdateRolCommand, RolDto>
{
private readonly IRolRepository _repository;
private readonly IAuditLogger _audit;
public UpdateRolCommandHandler(IRolRepository repository)
public UpdateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
{
_repository = repository;
_audit = audit;
}
public async Task<RolDto> Handle(UpdateRolCommand command)
{
var updated = await _repository.UpdateAsync(
command.Codigo, command.Nombre, command.Descripcion, command.Activo);
var before = await _repository.GetByCodigoAsync(command.Codigo)
?? throw new RolNotFoundException(command.Codigo);
if (!updated)
throw new RolNotFoundException(command.Codigo);
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
var updated = await _repository.UpdateAsync(
command.Codigo, command.Nombre, command.Descripcion, command.Activo);
if (!updated)
throw new RolNotFoundException(command.Codigo);
await _audit.LogAsync(
action: "rol.update",
targetType: "Rol",
targetId: before.Id.ToString(),
metadata: new
{
before = new { before.Nombre, before.Descripcion, before.Activo },
after = new { command.Nombre, command.Descripcion, command.Activo },
});
tx.Complete();
}
var rol = await _repository.GetByCodigoAsync(command.Codigo)
?? throw new RolNotFoundException(command.Codigo);

View File

@@ -1,6 +1,8 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Exceptions;
@@ -10,13 +12,16 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPas
{
private readonly IUsuarioRepository _repository;
private readonly IPasswordHasher _hasher;
private readonly IAuditLogger _audit;
public ChangeMyPasswordCommandHandler(
IUsuarioRepository repository,
IPasswordHasher hasher)
IPasswordHasher hasher,
IAuditLogger audit)
{
_repository = repository;
_hasher = hasher;
_audit = audit;
}
public async Task<Unit> Handle(ChangeMyPasswordCommand cmd)
@@ -28,9 +33,21 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPas
throw new InvalidOldPasswordException();
var newHash = _hasher.Hash(cmd.NewPassword);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repository.UpdatePasswordAsync(cmd.UsuarioId, newHash, mustChangePassword: false);
// TODO: audit — defer to ADM-004
await _audit.LogAsync(
action: "usuario.password_change",
targetType: "Usuario",
targetId: cmd.UsuarioId.ToString());
tx.Complete();
// NOTE: intentionally does NOT revoke own refresh tokens (spec REQ-BCP-05)
return Unit.Value;
}

View File

@@ -1,6 +1,8 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -10,13 +12,16 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler<CreateUsuarioC
{
private readonly IUsuarioRepository _repository;
private readonly IPasswordHasher _hasher;
private readonly IAuditLogger _audit;
public CreateUsuarioCommandHandler(
IUsuarioRepository repository,
IPasswordHasher hasher)
IPasswordHasher hasher,
IAuditLogger audit)
{
_repository = repository;
_hasher = hasher;
_audit = audit;
}
public async Task<UsuarioCreatedDto> Handle(CreateUsuarioCommand command)
@@ -37,9 +42,32 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler<CreateUsuarioC
email: command.Email,
rol: command.Rol);
// TODO: audit — record which admin created this user (defer to UDT-Audit)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repository.AddAsync(usuario);
// UDT-010 (closes follow-up #6): record admin who created this user.
await _audit.LogAsync(
action: "usuario.create",
targetType: "Usuario",
targetId: newId.ToString(),
metadata: new
{
after = new
{
usuario.Username,
usuario.Nombre,
usuario.Apellido,
usuario.Email,
usuario.Rol,
},
});
tx.Complete();
return new UsuarioCreatedDto(
Id: newId,
Username: usuario.Username,

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Domain.Exceptions;
@@ -10,13 +12,16 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler<Deactivate
{
private readonly IUsuarioRepository _repository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IAuditLogger _audit;
public DeactivateUsuarioCommandHandler(
IUsuarioRepository repository,
IRefreshTokenRepository refreshTokenRepository)
IRefreshTokenRepository refreshTokenRepository,
IAuditLogger audit)
{
_repository = repository;
_refreshTokenRepository = refreshTokenRepository;
_audit = audit;
}
public async Task<UsuarioDetailDto> Handle(DeactivateUsuarioCommand cmd)
@@ -39,10 +44,23 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler<Deactivate
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, false);
var now = DateTime.UtcNow;
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now);
// TODO: audit — defer to ADM-004
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now);
await _audit.LogAsync(
action: "usuario.deactivate",
targetType: "Usuario",
targetId: cmd.UsuarioId.ToString());
tx.Complete();
}
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
?? throw new UsuarioNotFoundException(cmd.UsuarioId);

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Exceptions;
@@ -15,15 +17,18 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler
private readonly IUsuarioRepository _usuarioRepo;
private readonly IRolPermisoRepository _rolPermisoRepo;
private readonly IPermisoRepository _permisoRepo;
private readonly IAuditLogger _audit;
public UpdateUsuarioPermisosOverridesCommandHandler(
IUsuarioRepository usuarioRepo,
IRolPermisoRepository rolPermisoRepo,
IPermisoRepository permisoRepo)
IPermisoRepository permisoRepo,
IAuditLogger audit)
{
_usuarioRepo = usuarioRepo;
_rolPermisoRepo = rolPermisoRepo;
_permisoRepo = permisoRepo;
_audit = audit;
}
public async Task<UsuarioPermisosDto> Handle(UpdateUsuarioPermisosOverridesCommand command)
@@ -53,11 +58,31 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler
// 4. Persist — use WithPermisosJson to get updated FechaModificacion
var newOverrides = new PermisosOverride(grant, deny);
var previousOverrides = PermisosOverride.FromJson(usuario.PermisosJson);
var updated = usuario.WithPermisosJson(newOverrides.ToJson());
await _usuarioRepo.UpdatePermisosJsonAsync(
updated.Id,
updated.PermisosJson,
updated.FechaModificacion!.Value);
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
await _usuarioRepo.UpdatePermisosJsonAsync(
updated.Id,
updated.PermisosJson,
updated.FechaModificacion!.Value);
await _audit.LogAsync(
action: "usuario.permisos_update",
targetType: "Usuario",
targetId: command.Id.ToString(),
metadata: new
{
before = new { grant = previousOverrides.Grant, deny = previousOverrides.Deny },
after = new { grant = grant, deny = deny },
});
tx.Complete();
}
// 5. Return updated effective set
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(updated.Rol);

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Domain.Exceptions;
@@ -9,10 +11,12 @@ namespace SIGCM2.Application.Usuarios.Reactivate;
public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>
{
private readonly IUsuarioRepository _repository;
private readonly IAuditLogger _audit;
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository)
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository, IAuditLogger audit)
{
_repository = repository;
_audit = audit;
}
public async Task<UsuarioDetailDto> Handle(ReactivateUsuarioCommand cmd)
@@ -31,9 +35,22 @@ public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<Reactivate
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, true);
var now = DateTime.UtcNow;
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
// TODO: audit — defer to ADM-004
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
await _audit.LogAsync(
action: "usuario.reactivate",
targetType: "Usuario",
targetId: cmd.UsuarioId.ToString());
tx.Complete();
}
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
?? throw new UsuarioNotFoundException(cmd.UsuarioId);

View File

@@ -1,6 +1,8 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Exceptions;
@@ -11,15 +13,18 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUs
private readonly IUsuarioRepository _repository;
private readonly IPasswordHasher _hasher;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IAuditLogger _audit;
public ResetUsuarioPasswordCommandHandler(
IUsuarioRepository repository,
IPasswordHasher hasher,
IRefreshTokenRepository refreshTokenRepository)
IRefreshTokenRepository refreshTokenRepository,
IAuditLogger audit)
{
_repository = repository;
_hasher = hasher;
_refreshTokenRepository = refreshTokenRepository;
_audit = audit;
}
public async Task<ResetUsuarioPasswordResponse> Handle(ResetUsuarioPasswordCommand cmd)
@@ -32,13 +37,25 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUs
?? throw new UsuarioNotFoundException(cmd.TargetId);
var temp = TempPasswordGenerator.Generate(12);
// SECURITY: NEVER log tempPassword
// SECURITY: NEVER log tempPassword — it is returned to the caller, never persisted.
var hash = _hasher.Hash(temp);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repository.UpdatePasswordAsync(cmd.TargetId, hash, mustChangePassword: true);
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.TargetId, DateTime.UtcNow);
// TODO: audit — defer to ADM-004
await _audit.LogAsync(
action: "usuario.password_reset",
targetType: "Usuario",
targetId: cmd.TargetId.ToString(),
metadata: new { targetId = cmd.TargetId }); // NO tempPassword in metadata
tx.Complete();
return new ResetUsuarioPasswordResponse(temp, MustChangeOnLogin: true);
}
}

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Domain.Exceptions;
@@ -11,15 +13,18 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler<UpdateUsuarioC
private readonly IUsuarioRepository _repository;
private readonly IRolRepository _rolRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IAuditLogger _audit;
public UpdateUsuarioCommandHandler(
IUsuarioRepository repository,
IRolRepository rolRepository,
IRefreshTokenRepository refreshTokenRepository)
IRefreshTokenRepository refreshTokenRepository,
IAuditLogger audit)
{
_repository = repository;
_rolRepository = rolRepository;
_refreshTokenRepository = refreshTokenRepository;
_audit = audit;
}
public async Task<UsuarioDetailDto> Handle(UpdateUsuarioCommand cmd)
@@ -48,17 +53,36 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler<UpdateUsuarioC
var fields = new UpdateUsuarioFields(cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo);
var now = DateTime.UtcNow;
await _repository.UpdateAsync(cmd.Id, fields, now);
// Revoke refresh tokens if rol changed or user deactivated
var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal);
var justDeactivated = target.Activo && !cmd.Activo;
if (rolChanged || justDeactivated)
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now);
await _repository.UpdateAsync(cmd.Id, fields, now);
// Revoke refresh tokens if rol changed or user deactivated
var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal);
var justDeactivated = target.Activo && !cmd.Activo;
if (rolChanged || justDeactivated)
{
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now);
}
await _audit.LogAsync(
action: "usuario.update",
targetType: "Usuario",
targetId: cmd.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.Apellido, target.Email, target.Rol, target.Activo },
after = new { cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo },
});
tx.Complete();
}
// TODO: audit — defer to ADM-004
// Post-commit read: outside the scope so SqlClient does not try to enlist a completed transaction.
var updated = await _repository.GetDetailAsync(cmd.Id)
?? throw new UsuarioNotFoundException(cmd.Id);

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Domain.Exceptions;
/// Thrown when IAuditLogger.LogAsync runs without ActorUserId in the audit context while the action
/// requires user attribution (i.e. not a system job). Fail-closed: better to break the command than
/// to persist an audit event with a missing actor.
public sealed class AuditContextMissingException : DomainException
{
public AuditContextMissingException()
: base("Audit context is missing ActorUserId for a user-scoped action. Ensure AuditActorMiddleware ran and the request is authenticated.")
{ }
}

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using SIGCM2.Application.Audit;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 — scoped IAuditContext implementation backed by HttpContext.Items entries
/// populated by the middleware pipeline (CorrelationIdMiddleware + AuditActorMiddleware).
/// Returns defaults (null / Guid.Empty) when no HttpContext is available.
public sealed class AuditContext : IAuditContext
{
private readonly IHttpContextAccessor _accessor;
public AuditContext(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
private HttpContext? Http => _accessor.HttpContext;
public int? ActorUserId =>
Http?.Items.TryGetValue("audit:actorUserId", out var v) == true ? v as int? : null;
// Reserved: the pipeline does not currently resolve rol code -> id. Logger-side resolution
// may populate this in a future batch.
public int? ActorRoleId =>
Http?.Items.TryGetValue("audit:actorRoleId", out var v) == true ? v as int? : null;
public string? Ip =>
Http?.Items.TryGetValue("audit:ip", out var v) == true ? v as string : null;
public string? UserAgent =>
Http?.Items.TryGetValue("audit:userAgent", out var v) == true ? v as string : null;
public Guid CorrelationId =>
Http?.Items.TryGetValue("audit:correlationId", out var v) == true
? (v as Guid?) ?? Guid.Empty
: Guid.Empty;
}

View File

@@ -0,0 +1,45 @@
using System.Text;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 — opaque cursor for AuditEvent DESC pagination per design #D-6.
/// Format: base64url(`{OccurredAt:O}|{Id}`). Parse returns null on malformed input
/// (callers treat it as "start from the top" — fail-open on invalid cursor).
public static class AuditEventCursor
{
public static string Encode(DateTime occurredAt, long id)
{
var raw = $"{occurredAt:O}|{id}";
var bytes = Encoding.UTF8.GetBytes(raw);
return Convert.ToBase64String(bytes);
}
public static (DateTime OccurredAt, long Id)? TryDecode(string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
return null;
try
{
var bytes = Convert.FromBase64String(cursor);
var raw = Encoding.UTF8.GetString(bytes);
var pipe = raw.IndexOf('|');
if (pipe <= 0 || pipe == raw.Length - 1)
return null;
var datePart = raw[..pipe];
var idPart = raw[(pipe + 1)..];
if (!DateTime.TryParse(datePart, null, System.Globalization.DateTimeStyles.RoundtripKind, out var occurredAt))
return null;
if (!long.TryParse(idPart, out var id))
return null;
return (occurredAt, id);
}
catch (FormatException)
{
return null;
}
}
}

View File

@@ -0,0 +1,139 @@
using Dapper;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit;
public sealed class AuditEventRepository : IAuditEventRepository
{
private readonly SqlConnectionFactory _factory;
public AuditEventRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
int? actorRoleId,
string action,
string targetType,
string targetId,
Guid? correlationId,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.AuditEvent
(OccurredAt, ActorUserId, ActorRoleId, Action, TargetType, TargetId,
CorrelationId, IpAddress, UserAgent, Metadata)
OUTPUT INSERTED.Id
VALUES
(@OccurredAt, @ActorUserId, @ActorRoleId, @Action, @TargetType, @TargetId,
@CorrelationId, @IpAddress, @UserAgent, @Metadata);
""";
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var cmd = new CommandDefinition(sql, new
{
OccurredAt = occurredAt,
ActorUserId = actorUserId,
ActorRoleId = actorRoleId,
Action = action,
TargetType = targetType,
TargetId = targetId,
CorrelationId = correlationId,
IpAddress = ipAddress,
UserAgent = userAgent,
Metadata = metadata,
}, cancellationToken: ct);
return await conn.ExecuteScalarAsync<long>(cmd);
}
public async Task<AuditEventQueryResult> QueryAsync(AuditEventFilter filter, CancellationToken ct = default)
{
var limit = Math.Clamp(filter.Limit, 1, 100);
var wheres = new List<string>();
var parameters = new DynamicParameters();
if (filter.ActorUserId is not null)
{
wheres.Add("e.ActorUserId = @ActorUserId");
parameters.Add("ActorUserId", filter.ActorUserId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.TargetType))
{
wheres.Add("e.TargetType = @TargetType");
parameters.Add("TargetType", filter.TargetType);
}
if (!string.IsNullOrWhiteSpace(filter.TargetId))
{
wheres.Add("e.TargetId = @TargetId");
parameters.Add("TargetId", filter.TargetId);
}
if (filter.From is not null)
{
wheres.Add("e.OccurredAt >= @FromDate");
parameters.Add("FromDate", filter.From.Value);
}
if (filter.To is not null)
{
wheres.Add("e.OccurredAt <= @ToDate");
parameters.Add("ToDate", filter.To.Value);
}
var cursor = AuditEventCursor.TryDecode(filter.Cursor);
if (cursor is not null)
{
// DESC pagination: rows strictly older than the cursor.
wheres.Add("(e.OccurredAt < @CursorOccurredAt OR (e.OccurredAt = @CursorOccurredAt AND e.Id < @CursorId))");
parameters.Add("CursorOccurredAt", cursor.Value.OccurredAt);
parameters.Add("CursorId", cursor.Value.Id);
}
parameters.Add("Limit", limit + 1); // fetch one extra to detect "more pages"
var whereClause = wheres.Count > 0 ? "WHERE " + string.Join(" AND ", wheres) : string.Empty;
var sql = $"""
SELECT TOP (@Limit)
e.Id,
e.OccurredAt,
e.ActorUserId,
u.Username AS ActorUsername,
e.Action,
e.TargetType,
e.TargetId,
e.CorrelationId,
e.IpAddress,
e.Metadata
FROM dbo.AuditEvent e
LEFT JOIN dbo.Usuario u ON u.Id = e.ActorUserId
{whereClause}
ORDER BY e.OccurredAt DESC, e.Id DESC;
""";
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var cmd = new CommandDefinition(sql, parameters, cancellationToken: ct);
var rows = (await conn.QueryAsync<AuditEventDto>(cmd)).ToList();
string? nextCursor = null;
if (rows.Count > limit)
{
// We fetched N+1; drop the overflow and emit the cursor from the Nth row.
var last = rows[limit - 1];
nextCursor = AuditEventCursor.Encode(last.OccurredAt, last.Id);
rows.RemoveAt(rows.Count - 1);
}
return new AuditEventQueryResult(rows, nextCursor);
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Options;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 — IAuditLogger implementation. Enriches from IAuditContext, sanitizes metadata,
/// persists via IAuditEventRepository. Fail-closed: throws AuditContextMissingException
/// when ActorUserId is null (system-emitted events should use a different path).
public sealed class AuditLogger : IAuditLogger
{
private readonly IAuditContext _context;
private readonly IAuditEventRepository _repo;
private readonly IOptions<AuditOptions> _options;
public AuditLogger(
IAuditContext context,
IAuditEventRepository repo,
IOptions<AuditOptions> options)
{
_context = context;
_repo = repo;
_options = options;
}
public async Task LogAsync(
string action,
string targetType,
string targetId,
object? metadata = null,
CancellationToken ct = default)
{
if (_context.ActorUserId is null)
throw new AuditContextMissingException();
var sanitized = metadata is null
? null
: JsonSanitizer.Sanitize(metadata, _options.Value.SanitizedKeys);
var correlationId = _context.CorrelationId == Guid.Empty
? (Guid?)null
: _context.CorrelationId;
await _repo.InsertAsync(
occurredAt: DateTime.UtcNow,
actorUserId: _context.ActorUserId,
actorRoleId: _context.ActorRoleId,
action: action,
targetType: targetType,
targetId: targetId,
correlationId: correlationId,
ipAddress: _context.Ip,
userAgent: _context.UserAgent,
metadata: sanitized,
ct: ct);
}
}

View File

@@ -0,0 +1,95 @@
using Dapper;
using Microsoft.Extensions.Logging;
using Quartz;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit.Jobs;
/// <summary>
/// UDT-010 (#REQ-AUD-6) — weekly integrity verification:
/// - Validates SYSTEM_VERSIONING is ON in all cataloged tables.
/// - Validates partitions exist for the next 3 months on both event tables.
/// - Emits a SecurityEvent 'system.integrity_alert' with Result=failure when any
/// check fails; logs success otherwise.
/// - Intended schedule: cron '0 0 1 ? * SUN' (every Sunday at 01:00 UTC).
/// </summary>
[DisallowConcurrentExecution]
public sealed class AuditIntegrityCheckJob : IJob
{
public const string CronSchedule = "0 0 1 ? * SUN";
private readonly SqlConnectionFactory _factory;
private readonly ISecurityEventLogger _security;
private readonly ILogger<AuditIntegrityCheckJob> _logger;
public AuditIntegrityCheckJob(
SqlConnectionFactory factory,
ISecurityEventLogger security,
ILogger<AuditIntegrityCheckJob> logger)
{
_factory = factory;
_security = security;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
var ct = context.CancellationToken;
var failures = new List<string>();
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
// 1. SYSTEM_VERSIONING still ON
var missing = (await conn.QueryAsync<string>("""
SELECT name FROM sys.tables
WHERE name IN ('Usuario','Rol','Permiso','RolPermiso') AND temporal_type <> 2;
""")).ToList();
if (missing.Any())
failures.Add($"system_versioning_missing:{string.Join(',', missing)}");
// 2. Next 3 months have partitions in both event tables
var now = DateTime.UtcNow;
var required = new[]
{
new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1),
new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2),
new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(3),
};
foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
{
var existingBoundaries = (await conn.QueryAsync<DateTime>("""
SELECT CAST(prv.value AS DATETIME2(3))
FROM sys.partition_functions pf
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
WHERE pf.name = @Name;
""", new { Name = pf })).ToHashSet();
foreach (var req in required)
{
if (!existingBoundaries.Contains(req))
failures.Add($"partition_missing:{pf}:{req:yyyy-MM-dd}");
}
}
if (failures.Any())
{
_logger.LogError("AuditIntegrityCheckJob detected {Count} integrity failures: {Failures}",
failures.Count, string.Join(" | ", failures));
await _security.LogAsync(
action: "system.integrity_alert",
result: "failure",
actorUserId: null,
failureReason: "integrity_check_failed",
metadata: new { failures, checkedAt = now },
ct: ct);
}
else
{
_logger.LogInformation("AuditIntegrityCheckJob completed — all checks passed");
}
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
namespace SIGCM2.Infrastructure.Audit.Jobs;
/// UDT-010 (#REQ-AUD-6) — DI extension to register Quartz + the 3 audit maintenance jobs.
/// Call from Program.cs: builder.Services.AddAuditMaintenance(builder.Configuration).
public static class AuditMaintenanceRegistration
{
public static IServiceCollection AddAuditMaintenance(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddQuartz(q =>
{
var partitionKey = new JobKey(nameof(AuditPartitionManagerJob));
q.AddJob<AuditPartitionManagerJob>(j => j.WithIdentity(partitionKey));
q.AddTrigger(t => t
.ForJob(partitionKey)
.WithIdentity($"{nameof(AuditPartitionManagerJob)}-trigger")
.WithCronSchedule(AuditPartitionManagerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
var retentionKey = new JobKey(nameof(AuditRetentionEnforcerJob));
q.AddJob<AuditRetentionEnforcerJob>(j => j.WithIdentity(retentionKey));
q.AddTrigger(t => t
.ForJob(retentionKey)
.WithIdentity($"{nameof(AuditRetentionEnforcerJob)}-trigger")
.WithCronSchedule(AuditRetentionEnforcerJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
var integrityKey = new JobKey(nameof(AuditIntegrityCheckJob));
q.AddJob<AuditIntegrityCheckJob>(j => j.WithIdentity(integrityKey));
q.AddTrigger(t => t
.ForJob(integrityKey)
.WithIdentity($"{nameof(AuditIntegrityCheckJob)}-trigger")
.WithCronSchedule(AuditIntegrityCheckJob.CronSchedule, x => x.InTimeZone(TimeZoneInfo.Utc)));
});
services.AddQuartzHostedService(opts => opts.WaitForJobsToComplete = true);
return services;
}
}

View File

@@ -0,0 +1,64 @@
using Dapper;
using Microsoft.Extensions.Logging;
using Quartz;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit.Jobs;
/// <summary>
/// UDT-010 (#REQ-AUD-6) — monthly maintenance:
/// - Extends the forward boundary of pf_AuditEvent_Monthly and pf_SecurityEvent_Monthly
/// so the next month always has a partition ready (SPLIT RANGE).
/// - Intended schedule: cron '0 0 2 1 * ?' (day 1 each month at 02:00 UTC).
/// - Idempotent: only splits if the target boundary does not yet exist.
/// </summary>
[DisallowConcurrentExecution]
public sealed class AuditPartitionManagerJob : IJob
{
public const string CronSchedule = "0 0 2 1 * ?";
private readonly SqlConnectionFactory _factory;
private readonly ILogger<AuditPartitionManagerJob> _logger;
public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger<AuditPartitionManagerJob> logger)
{
_factory = factory;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
var ct = context.CancellationToken;
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
// Target: boundary for "next month + 1" (so the next month is always pre-created and we
// keep at least one boundary ahead after the rotation).
var now = DateTime.UtcNow;
var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
var affected = 0;
foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
{
var exists = await conn.ExecuteScalarAsync<int>("""
SELECT COUNT(*)
FROM sys.partition_functions pf
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target;
""", new { Name = pf, Target = target });
if (exists == 0)
{
// Parameterized partition function name would require dynamic SQL; whitelisted above.
var sql = $"ALTER PARTITION FUNCTION {pf}() SPLIT RANGE (@Target);";
await conn.ExecuteAsync(sql, new { Target = target });
affected++;
_logger.LogInformation("Partition boundary {Boundary:yyyy-MM-dd} added to {Function}", target, pf);
}
}
_logger.LogInformation(
"AuditPartitionManagerJob completed — {Affected} partition function(s) extended (target: {Target:yyyy-MM-dd})",
affected, target);
}
}

View File

@@ -0,0 +1,57 @@
using Dapper;
using Microsoft.Extensions.Logging;
using Quartz;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit.Jobs;
/// <summary>
/// UDT-010 (#REQ-AUD-6, #REQ-SEC-5) — annual retention enforcement:
/// - Purges rows from dbo.AuditEvent older than 10 years.
/// - Purges rows from dbo.SecurityEvent older than 5 years.
/// - Temporal history tables are purged automatically by the engine via
/// HISTORY_RETENTION_PERIOD = 10 YEARS configured in V010.
/// - Intended schedule: cron '0 0 3 1 1 ?' (Jan 1 at 03:00 UTC).
///
/// Row-based DELETE is the conservative choice for the first generation of this
/// job — avoids requiring filegroup switching logic. When volumes warrant, the
/// job can be upgraded to SWITCH OUT + DROP for partition-level drop.
/// </summary>
[DisallowConcurrentExecution]
public sealed class AuditRetentionEnforcerJob : IJob
{
public const string CronSchedule = "0 0 3 1 1 ?";
private readonly SqlConnectionFactory _factory;
private readonly ILogger<AuditRetentionEnforcerJob> _logger;
public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger<AuditRetentionEnforcerJob> logger)
{
_factory = factory;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
var ct = context.CancellationToken;
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var now = DateTime.UtcNow;
var auditCutoff = now.AddYears(-10);
var securityCutoff = now.AddYears(-5);
var auditDeleted = await conn.ExecuteAsync(
"DELETE FROM dbo.AuditEvent WHERE OccurredAt < @Cutoff;",
new { Cutoff = auditCutoff });
var securityDeleted = await conn.ExecuteAsync(
"DELETE FROM dbo.SecurityEvent WHERE OccurredAt < @Cutoff;",
new { Cutoff = securityCutoff });
_logger.LogInformation(
"AuditRetentionEnforcerJob completed — AuditEvent purged {AuditDeleted} rows (< {AuditCutoff:yyyy-MM-dd}), " +
"SecurityEvent purged {SecurityDeleted} rows (< {SecurityCutoff:yyyy-MM-dd})",
auditDeleted, auditCutoff, securityDeleted, securityCutoff);
}
}

View File

@@ -0,0 +1,63 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 (#REQ-AUD-5): serializes a metadata object to JSON and strips keys in the blacklist
/// at every nesting level. Case-insensitive match. Null input → null output (never throws).
/// Produces valid JSON consumable by AuditEvent.Metadata (ISJSON=1 constraint).
public static class JsonSanitizer
{
/// Serializes <paramref name="obj"/> to JSON and removes any property whose key matches
/// (case-insensitively) an entry in <paramref name="blacklist"/>. Recursive into nested
/// objects and arrays. Returns null if the input is null.
public static string? Sanitize(object? obj, IReadOnlyCollection<string> blacklist)
{
if (obj is null) return null;
var node = JsonSerializer.SerializeToNode(obj);
if (node is null) return null;
if (blacklist.Count > 0)
{
var blacklistLower = blacklist
.Select(k => k.ToLowerInvariant())
.ToHashSet();
Strip(node, blacklistLower);
}
return node.ToJsonString();
}
private static void Strip(JsonNode node, HashSet<string> blacklistLower)
{
switch (node)
{
case JsonObject obj:
var keysToRemove = obj
.Where(kvp => blacklistLower.Contains(kvp.Key.ToLowerInvariant()))
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
obj.Remove(key);
foreach (var kvp in obj.ToList())
{
if (kvp.Value is not null)
Strip(kvp.Value, blacklistLower);
}
break;
case JsonArray arr:
foreach (var item in arr)
{
if (item is not null)
Strip(item, blacklistLower);
}
break;
// JsonValue: scalar, nothing to strip
}
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Options;
using SIGCM2.Application.Audit;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 — ISecurityEventLogger implementation. Unlike AuditLogger this is NOT
/// fail-closed on missing actor: login failures have no ActorUserId by design.
/// Ip/UserAgent are pulled from IAuditContext when available (null in pre-auth paths).
public sealed class SecurityEventLogger : ISecurityEventLogger
{
private readonly ISecurityEventRepository _repo;
private readonly IAuditContext _context;
private readonly IOptions<AuditOptions> _options;
public SecurityEventLogger(
ISecurityEventRepository repo,
IAuditContext context,
IOptions<AuditOptions> options)
{
_repo = repo;
_context = context;
_options = options;
}
public async Task LogAsync(
string action,
string result,
int? actorUserId = null,
string? attemptedUsername = null,
Guid? sessionId = null,
string? failureReason = null,
object? metadata = null,
CancellationToken ct = default)
{
var sanitized = metadata is null
? null
: JsonSanitizer.Sanitize(metadata, _options.Value.SanitizedKeys);
await _repo.InsertAsync(
occurredAt: DateTime.UtcNow,
actorUserId: actorUserId,
attemptedUsername: attemptedUsername,
sessionId: sessionId,
action: action,
result: result,
failureReason: failureReason,
ipAddress: _context.Ip,
userAgent: _context.UserAgent,
metadata: sanitized,
ct: ct);
}
}

View File

@@ -0,0 +1,58 @@
using Dapper;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Infrastructure.Audit;
public sealed class SecurityEventRepository : ISecurityEventRepository
{
private readonly SqlConnectionFactory _factory;
public SecurityEventRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<long> InsertAsync(
DateTime occurredAt,
int? actorUserId,
string? attemptedUsername,
Guid? sessionId,
string action,
string result,
string? failureReason,
string? ipAddress,
string? userAgent,
string? metadata,
CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.SecurityEvent
(OccurredAt, ActorUserId, AttemptedUsername, SessionId, Action, Result,
FailureReason, IpAddress, UserAgent, Metadata)
OUTPUT INSERTED.Id
VALUES
(@OccurredAt, @ActorUserId, @AttemptedUsername, @SessionId, @Action, @Result,
@FailureReason, @IpAddress, @UserAgent, @Metadata);
""";
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(ct);
var cmd = new CommandDefinition(sql, new
{
OccurredAt = occurredAt,
ActorUserId = actorUserId,
AttemptedUsername = attemptedUsername,
SessionId = sessionId,
Action = action,
Result = result,
FailureReason = failureReason,
IpAddress = ipAddress,
UserAgent = userAgent,
Metadata = metadata,
}, cancellationToken: ct);
return await conn.ExecuteScalarAsync<long>(cmd);
}
}

View File

@@ -8,6 +8,7 @@ using Microsoft.IdentityModel.Tokens;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Auth;
using SIGCM2.Infrastructure.Http;
using SIGCM2.Infrastructure.Messaging;
@@ -68,6 +69,14 @@ public static class DependencyInjection
services.AddHttpContextAccessor();
services.AddScoped<IClientContext, ClientContext>();
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
services.AddScoped<IAuditLogger, SIGCM2.Infrastructure.Audit.AuditLogger>();
services.AddScoped<ISecurityEventLogger, SIGCM2.Infrastructure.Audit.SecurityEventLogger>();
// Dispatcher
services.AddScoped<IDispatcher, Dispatcher>();

View File

@@ -14,6 +14,7 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Quartz.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>

71
src/web/src/api/audit.ts Normal file
View File

@@ -0,0 +1,71 @@
import { axiosClient } from '@/api/axiosClient'
/**
* Auditoría — UDT-010 Batch 12
*
* Cliente Axios para GET /api/v1/audit/events.
* Endpoint cursor-paginated (NO offset). Permiso requerido: administracion:auditoria:ver.
*/
/** Shape del evento devuelto por el backend (B5/B6). */
export interface AuditEventDto {
id: number
/** ISO datetime (UTC) */
occurredAt: string
actorUserId: number | null
actorUsername: string | null
action: string
targetType: string
targetId: string
correlationId: string | null
ipAddress: string | null
/** JSON string serializado (opcional) */
metadata: string | null
}
/** Respuesta cursor-paginated: items + cursor opaco para la siguiente página. */
export interface AuditEventsPage {
items: AuditEventDto[]
nextCursor: string | null
}
/** Filtro de consulta. Todos opcionales; `cursor` pagina; `limit` ≤ 100. */
export interface AuditEventsFilter {
actor?: number
targetType?: string
targetId?: string
/** ISO datetime inclusive desde */
from?: string
/** ISO datetime inclusive hasta */
to?: string
cursor?: string
limit?: number
}
/**
* Llama GET /api/v1/audit/events con los filtros dados.
* Omite params undefined/empty del querystring.
*/
export async function listAuditEvents(
filter: AuditEventsFilter = {},
): Promise<AuditEventsPage> {
const params = new URLSearchParams()
if (filter.actor !== undefined) params.set('actor', String(filter.actor))
if (filter.targetType !== undefined && filter.targetType !== '')
params.set('targetType', filter.targetType)
if (filter.targetId !== undefined && filter.targetId !== '')
params.set('targetId', filter.targetId)
if (filter.from !== undefined && filter.from !== '')
params.set('from', filter.from)
if (filter.to !== undefined && filter.to !== '') params.set('to', filter.to)
if (filter.cursor !== undefined && filter.cursor !== '')
params.set('cursor', filter.cursor)
if (filter.limit !== undefined) params.set('limit', String(filter.limit))
const response = await axiosClient.get<AuditEventsPage>(
'/api/v1/audit/events',
{ params },
)
return response.data
}

View File

@@ -9,6 +9,7 @@ import {
Users,
ShieldCheck,
KeyRound,
FileClock,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react'
@@ -23,6 +24,8 @@ interface NavItem {
href: string
icon: React.ElementType
disabled?: boolean
/** Si se define, el item solo se muestra si el user tiene este permiso. */
requiredPermission?: string
}
const navItems: NavItem[] = [
@@ -38,6 +41,12 @@ const adminItems: NavItem[] = [
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
{
label: 'Auditoría',
href: '/admin/audit',
icon: FileClock,
requiredPermission: 'administracion:auditoria:ver',
},
]
interface SidebarNavProps {
@@ -120,14 +129,20 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
{isAdmin && (
<>
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
{adminItems.map((item) => (
<NavRow
key={item.href}
item={item}
collapsed={collapsed}
active={isItemActive(item)}
/>
))}
{adminItems
.filter(
(item) =>
!item.requiredPermission ||
user?.permisos.includes(item.requiredPermission),
)
.map((item) => (
<NavRow
key={item.href}
item={item}
collapsed={collapsed}
active={isItemActive(item)}
/>
))}
</>
)}
</nav>

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import {
listAuditEvents,
type AuditEventsFilter,
type AuditEventsPage,
} from '@/api/audit'
export const auditEventsQueryKey = (filter: AuditEventsFilter) =>
['audit', 'events', filter] as const
/**
* Hook TanStack Query para `listAuditEvents`.
*
* Cursor pagination: el caller pasa `cursor` (o `undefined` para la primera
* página) dentro de `filter`. Cada "página" es una query independiente —
* NO usamos `useInfiniteQuery` porque el UI usa un botón "Cargar más" que
* simplemente re-consulta con el último `nextCursor`.
*/
export function useAuditEvents(filter: AuditEventsFilter) {
return useQuery<AuditEventsPage>({
queryKey: auditEventsQueryKey(filter),
queryFn: () => listAuditEvents(filter),
staleTime: 15_000,
})
}

View File

@@ -0,0 +1,160 @@
import { useState, type FormEvent } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
/** Filtros crudos del form (todos strings para binding directo del input). */
export interface AuditFiltersValue {
actor: string
targetType: string
targetId: string
from: string
to: string
}
export const EMPTY_FILTERS: AuditFiltersValue = {
actor: '',
targetType: '',
targetId: '',
from: '',
to: '',
}
interface AuditFiltersProps {
/** Valor inicial (útil cuando el padre mantiene el estado). */
initialValue?: AuditFiltersValue
onApply: (value: AuditFiltersValue) => void
onReset: () => void
}
/**
* 4 filtros + 2 fechas (from/to). Submit explícito vía botón "Aplicar".
*
* NO hace debounce — el usuario aprieta "Aplicar" o "Limpiar".
* Eso evita pedidos intermedios cuando se escribe un GUID largo en targetId.
*/
export function AuditFilters({
initialValue = EMPTY_FILTERS,
onApply,
onReset,
}: AuditFiltersProps) {
const [value, setValue] = useState<AuditFiltersValue>(initialValue)
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
onApply(value)
}
function handleReset() {
setValue(EMPTY_FILTERS)
onReset()
}
return (
<form
onSubmit={handleSubmit}
className="surface p-4 space-y-4"
aria-label="Filtros de auditoría"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label htmlFor="audit-actor">Usuario (ID)</Label>
<Input
id="audit-actor"
type="number"
inputMode="numeric"
min={1}
value={value.actor}
onChange={(e) => setValue((v) => ({ ...v, actor: e.target.value }))}
placeholder="Ej: 42"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-target-type">Tipo de entidad</Label>
<Input
id="audit-target-type"
type="text"
value={value.targetType}
onChange={(e) =>
setValue((v) => ({ ...v, targetType: e.target.value }))
}
placeholder="Ej: User, Role, Permission"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-target-id">ID de entidad</Label>
<Input
id="audit-target-id"
type="text"
value={value.targetId}
onChange={(e) =>
setValue((v) => ({ ...v, targetId: e.target.value }))
}
placeholder="Ej: 123 o un GUID"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-from">Desde</Label>
<Input
id="audit-from"
type="datetime-local"
value={value.from}
onChange={(e) => setValue((v) => ({ ...v, from: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-to">Hasta</Label>
<Input
id="audit-to"
type="datetime-local"
value={value.to}
onChange={(e) => setValue((v) => ({ ...v, to: e.target.value }))}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="ghost" onClick={handleReset}>
Limpiar
</Button>
<Button type="submit">Aplicar filtros</Button>
</div>
</form>
)
}
/**
* Convierte los valores del form (strings) al shape `AuditEventsFilter`
* que espera el cliente de API.
*
* - `actor` vacío o NaN → omitido
* - `from`/`to` vienen del `datetime-local` (local time, sin timezone).
* Los convertimos a ISO UTC vía `new Date(...).toISOString()`.
* - Strings vacíos → omitidos.
*/
export function toApiFilter(
value: AuditFiltersValue,
): import('@/api/audit').AuditEventsFilter {
const out: import('@/api/audit').AuditEventsFilter = {}
if (value.actor.trim() !== '') {
const n = Number(value.actor)
if (Number.isFinite(n) && n > 0) out.actor = Math.floor(n)
}
if (value.targetType.trim() !== '') out.targetType = value.targetType.trim()
if (value.targetId.trim() !== '') out.targetId = value.targetId.trim()
if (value.from.trim() !== '') {
const d = new Date(value.from)
if (!Number.isNaN(d.getTime())) out.from = d.toISOString()
}
if (value.to.trim() !== '') {
const d = new Date(value.to)
if (!Number.isNaN(d.getTime())) out.to = d.toISOString()
}
return out
}

View File

@@ -0,0 +1,285 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { Copy } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import type { AuditEventDto, AuditEventsFilter } from '@/api/audit'
import { useAuditEvents } from '@/features/admin/audit/useAuditEvents'
import {
AuditFilters,
EMPTY_FILTERS,
toApiFilter,
type AuditFiltersValue,
} from './AuditFilters'
/** Formatea un ISO datetime a hora local AR (dd/mm/yyyy HH:mm:ss). */
function formatOccurredAt(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('es-AR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/** Copia texto al clipboard con fallback + toast. */
async function copyToClipboard(text: string, label: string): Promise<void> {
try {
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === 'function'
) {
await navigator.clipboard.writeText(text)
} else {
// Fallback jsdom / navegadores viejos
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.position = 'absolute'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
toast.success(`${label} copiado al portapapeles`)
} catch {
toast.error(`No se pudo copiar ${label.toLowerCase()}`)
}
}
export function AuditPage() {
// Filtros confirmados (los que se mandan a la API). Se actualizan al Aplicar.
const [filters, setFilters] = useState<AuditFiltersValue>(EMPTY_FILTERS)
// Cursor actual (undefined = primera página).
const [cursor, setCursor] = useState<string | undefined>(undefined)
// Acumulador de items entre páginas ("cargar más").
const [accumulated, setAccumulated] = useState<AuditEventDto[]>([])
const apiFilter = useMemo<AuditEventsFilter>(() => {
const f = toApiFilter(filters)
return cursor ? { ...f, cursor } : f
}, [filters, cursor])
const { data, isLoading, isFetching, isError } = useAuditEvents(apiFilter)
// Acumular items cuando llegan. Si es la primera página (cursor=undefined)
// reseteamos; si hay cursor, appendeamos.
useEffect(() => {
if (!data) return
if (cursor === undefined) {
setAccumulated(data.items)
} else {
setAccumulated((prev) => {
// Evitar dobles appends en StrictMode: si el último batch ya incluye
// este primer id, asumimos que ya fue appendeado.
const firstNew = data.items[0]
if (
firstNew &&
prev.length > 0 &&
prev[prev.length - 1]?.id === firstNew.id
) {
return prev
}
return [...prev, ...data.items]
})
}
}, [data, cursor])
const handleApply = useCallback((value: AuditFiltersValue) => {
setFilters(value)
setCursor(undefined)
setAccumulated([])
}, [])
const handleReset = useCallback(() => {
setFilters(EMPTY_FILTERS)
setCursor(undefined)
setAccumulated([])
}, [])
const handleLoadMore = useCallback(() => {
if (data?.nextCursor) {
setCursor(data.nextCursor)
}
}, [data])
const columns = useMemo<ColumnDef<AuditEventDto>[]>(
() => [
{
accessorKey: 'occurredAt',
header: 'Fecha',
cell: ({ row }) => (
<span className="font-mono text-xs text-foreground">
{formatOccurredAt(row.original.occurredAt)}
</span>
),
meta: { priority: 'high' },
},
{
accessorKey: 'actorUsername',
header: 'Usuario',
cell: ({ row }) => {
const u = row.original.actorUsername
if (!u) {
return (
<span className="text-muted-foreground italic">
sistema
</span>
)
}
return <span className="font-mono text-xs">{u}</span>
},
meta: { priority: 'high' },
},
{
accessorKey: 'action',
header: 'Acción',
cell: ({ row }) => (
<Badge variant="secondary" className="font-mono text-[11px]">
{row.original.action}
</Badge>
),
meta: { priority: 'high' },
},
{
id: 'target',
header: 'Entidad',
cell: ({ row }) => (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">
{row.original.targetType}
</span>
<span className="font-mono text-[11px] text-muted-foreground break-all">
{row.original.targetId}
</span>
</div>
),
meta: { priority: 'medium' },
},
{
accessorKey: 'ipAddress',
header: 'IP',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.ipAddress ?? '—'}
</span>
),
meta: { priority: 'low' },
},
{
accessorKey: 'correlationId',
header: 'Correlation',
cell: ({ row }) => {
const cid = row.original.correlationId
if (!cid) return <span className="text-muted-foreground"></span>
const short = cid.length > 10 ? `${cid.slice(0, 8)}` : cid
return (
<div className="flex items-center gap-1">
<span
className="font-mono text-[11px] text-muted-foreground"
title={cid}
>
{short}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void copyToClipboard(cid, 'Correlation ID')
}}
aria-label="Copiar correlation ID"
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Copy className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent>Copiar correlation ID</TooltipContent>
</Tooltip>
</div>
)
},
meta: { priority: 'low' },
},
],
[],
)
const hasMore = Boolean(data?.nextCursor)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-xl font-semibold text-foreground">
Auditoría
</h1>
<p className="text-sm text-muted-foreground">
Historial de eventos del sistema. Resultados paginados por cursor.
</p>
</div>
</div>
<AuditFilters
initialValue={filters}
onApply={handleApply}
onReset={handleReset}
/>
{isError ? (
<div
role="alert"
className="surface p-4 text-sm text-destructive"
>
No se pudieron cargar los eventos de auditoría. Intentá de nuevo.
</div>
) : (
<DataTable
columns={columns}
data={accumulated}
getRowId={(row) => String(row.id)}
isLoading={isLoading && accumulated.length === 0}
emptyMessage="Sin resultados — no se encontraron eventos con los filtros seleccionados."
/>
)}
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-muted-foreground">
{accumulated.length > 0
? `${accumulated.length} evento${accumulated.length !== 1 ? 's' : ''} cargado${accumulated.length !== 1 ? 's' : ''}`
: ''}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasMore || isFetching}
onClick={handleLoadMore}
aria-label="Cargar más eventos"
>
{isFetching && cursor !== undefined ? 'Cargando…' : 'Cargar más'}
</Button>
</div>
{/*
TODOs para ADM-004:
- Drill-down del evento (modal con metadata JSON formatted)
- Export CSV de los resultados filtrados
- Timeline visualization por entidad
*/}
</div>
)
}

View File

@@ -12,6 +12,7 @@ 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 { AuditPage } from './pages/admin/audit/AuditPage'
import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -154,6 +155,15 @@ export function AppRoutes() {
}
/>
<Route
path="/admin/audit"
element={
<ProtectedPage requiredPermissions={['administracion:auditoria:ver']}>
<AuditPage />
</ProtectedPage>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -0,0 +1,254 @@
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
} 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 { TooltipProvider } from '../../../../components/ui/tooltip'
import { AuditPage } from '../../../../pages/admin/audit/AuditPage'
const API_URL = 'http://localhost:5000'
// Sonner toast is mocked so we can assert it was called without rendering.
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
function makeEvent(id: number, overrides: Record<string, unknown> = {}) {
return {
id,
occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
actorUserId: 1,
actorUsername: `user${id}`,
action: 'user.created',
targetType: 'User',
targetId: String(100 + id),
correlationId: `corr-${id}`,
ipAddress: '10.0.0.1',
metadata: null,
...overrides,
}
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderPage() {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={qc}>
<TooltipProvider>
<MemoryRouter initialEntries={['/admin/audit']}>
<AuditPage />
</MemoryRouter>
</TooltipProvider>
</QueryClientProvider>,
)
}
describe('AuditPage', () => {
it('renders the table with rows returned by the API', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1), makeEvent(2), makeEvent(3)],
nextCursor: null,
}),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText('user1')).toBeInTheDocument(),
)
expect(screen.getByText('user2')).toBeInTheDocument()
expect(screen.getByText('user3')).toBeInTheDocument()
// Action badge present
const badges = screen.getAllByText('user.created')
expect(badges.length).toBe(3)
})
it('shows empty state when no items are returned', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({ items: [], nextCursor: null }),
),
)
renderPage()
await waitFor(() =>
expect(
screen.getByText(/sin resultados|no se encontraron eventos/i),
).toBeInTheDocument(),
)
})
it('applies filters on submit — sends actor + targetType as query params', async () => {
const requests: string[] = []
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
requests.push(request.url)
return HttpResponse.json({ items: [], nextCursor: null })
}),
)
const u = userEvent.setup()
renderPage()
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
const actorInput = screen.getByLabelText(/usuario \(id\)/i)
await u.type(actorInput, '42')
const targetTypeInput = screen.getByLabelText(/tipo de entidad/i)
await u.type(targetTypeInput, 'User')
const applyBtn = screen.getByRole('button', { name: /aplicar filtros/i })
await u.click(applyBtn)
await waitFor(() => {
const withFilters = requests.find(
(url) => url.includes('actor=42') && url.includes('targetType=User'),
)
expect(withFilters).toBeTruthy()
})
})
it('"Cargar más" is disabled when nextCursor is null', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1)],
nextCursor: null,
}),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText('user1')).toBeInTheDocument(),
)
const loadMoreBtn = screen.getByRole('button', {
name: /cargar más/i,
})
expect(loadMoreBtn).toBeDisabled()
})
it('"Cargar más" fetches next page with cursor and appends rows', async () => {
const requests: string[] = []
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
requests.push(request.url)
const url = new URL(request.url)
const cursor = url.searchParams.get('cursor')
if (cursor === 'cursor-page-2') {
return HttpResponse.json({
items: [makeEvent(10), makeEvent(11)],
nextCursor: null,
})
}
return HttpResponse.json({
items: [makeEvent(1), makeEvent(2)],
nextCursor: 'cursor-page-2',
})
}),
)
const u = userEvent.setup()
renderPage()
await waitFor(() =>
expect(screen.getByText('user1')).toBeInTheDocument(),
)
expect(screen.getByText('user2')).toBeInTheDocument()
const loadMoreBtn = screen.getByRole('button', {
name: /cargar más/i,
})
expect(loadMoreBtn).not.toBeDisabled()
await u.click(loadMoreBtn)
// Second request with cursor param
await waitFor(() => {
const paged = requests.find((url) =>
url.includes('cursor=cursor-page-2'),
)
expect(paged).toBeTruthy()
})
// Appended rows appear alongside originals
await waitFor(() =>
expect(screen.getByText('user10')).toBeInTheDocument(),
)
expect(screen.getByText('user11')).toBeInTheDocument()
// Original rows still visible (append, not replace)
expect(screen.getByText('user1')).toBeInTheDocument()
})
it('shows "sistema" placeholder when actorUsername is null', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1, { actorUsername: null, actorUserId: null })],
nextCursor: null,
}),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText('sistema')).toBeInTheDocument(),
)
})
it('"Limpiar" clears the form inputs', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({ items: [], nextCursor: null }),
),
)
const u = userEvent.setup()
renderPage()
const actorInput = screen.getByLabelText(
/usuario \(id\)/i,
) as HTMLInputElement
await u.type(actorInput, '42')
expect(actorInput.value).toBe('42')
await u.click(screen.getByRole('button', { name: /limpiar/i }))
// Form field cleared after reset
expect(actorInput.value).toBe('')
})
})

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { useAuditEvents } from '../../../../features/admin/audit/useAuditEvents'
const API_URL = 'http://localhost:5000'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function createWrapper() {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children)
}
function makeEvent(id: number) {
return {
id,
occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
actorUserId: 1,
actorUsername: 'admin',
action: 'user.created',
targetType: 'User',
targetId: String(100 + id),
correlationId: `corr-${id}`,
ipAddress: '10.0.0.1',
metadata: null,
}
}
describe('useAuditEvents', () => {
it('fetches the first page (no cursor) and returns items + nextCursor', async () => {
server.use(
http.get(`${API_URL}/api/v1/audit/events`, () =>
HttpResponse.json({
items: [makeEvent(1), makeEvent(2)],
nextCursor: 'cursor-page-2',
}),
),
)
const { result } = renderHook(() => useAuditEvents({}), {
wrapper: createWrapper(),
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.items).toHaveLength(2)
expect(result.current.data?.nextCursor).toBe('cursor-page-2')
})
it('forwards filters as query params (actor, targetType, targetId, from, to)', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], nextCursor: null })
}),
)
const { result } = renderHook(
() =>
useAuditEvents({
actor: 42,
targetType: 'User',
targetId: 'abc-123',
from: '2026-04-01T00:00:00Z',
to: '2026-04-16T23:59:59Z',
}),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('actor=42')
expect(capturedUrl).toContain('targetType=User')
expect(capturedUrl).toContain('targetId=abc-123')
expect(capturedUrl).toContain('from=2026-04-01')
expect(capturedUrl).toContain('to=2026-04-16')
})
it('forwards cursor param for successive pages', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({
items: [makeEvent(3)],
nextCursor: null,
})
}),
)
const { result } = renderHook(
() => useAuditEvents({ cursor: 'cursor-page-2' }),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('cursor=cursor-page-2')
})
it('omits undefined/empty filters from the querystring', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], nextCursor: null })
}),
)
const { result } = renderHook(
() => useAuditEvents({ actor: 1, targetType: '' }),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('actor=1')
expect(capturedUrl).not.toContain('targetType=')
expect(capturedUrl).not.toContain('from=')
expect(capturedUrl).not.toContain('to=')
expect(capturedUrl).not.toContain('cursor=')
})
})

View File

@@ -0,0 +1,87 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using SIGCM2.Api.Middleware;
using Xunit;
namespace SIGCM2.Api.Tests.Audit;
/// UDT-010 Batch 4 — AuditActorMiddleware unit tests (Strict TDD).
/// Reads ActorUserId from the JWT "sub" claim after auth middleware populates HttpContext.User.
public sealed class AuditActorMiddlewareTests
{
[Fact]
public async Task Invoke_AuthenticatedUserWithSubClaim_SetsActorUserId()
{
var ctx = new DefaultHttpContext();
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("sub", "42"),
new Claim("rol", "admin"),
}, authenticationType: "Bearer"));
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
ctx.Items["audit:actorUserId"].Should().Be(42);
}
[Fact]
public async Task Invoke_AnonymousRequest_LeavesActorUserIdNull()
{
var ctx = new DefaultHttpContext();
// User is an unauthenticated ClaimsPrincipal by default
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
ctx.Items.TryGetValue("audit:actorUserId", out var value).Should().BeFalse();
value.Should().BeNull();
}
[Fact]
public async Task Invoke_AuthenticatedWithoutSubClaim_LeavesActorUserIdNull()
{
var ctx = new DefaultHttpContext();
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("name", "admin"),
}, authenticationType: "Bearer"));
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
ctx.Items.TryGetValue("audit:actorUserId", out _).Should().BeFalse();
}
[Fact]
public async Task Invoke_SubClaimIsNonNumeric_LeavesActorUserIdNull()
{
var ctx = new DefaultHttpContext();
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("sub", "not-an-int"),
}, authenticationType: "Bearer"));
var mw = new AuditActorMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
ctx.Items.TryGetValue("audit:actorUserId", out _).Should().BeFalse();
}
[Fact]
public async Task Invoke_CallsNextDelegate()
{
var ctx = new DefaultHttpContext();
var nextCalled = false;
var mw = new AuditActorMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await mw.InvokeAsync(ctx);
nextCalled.Should().BeTrue();
}
}

View File

@@ -0,0 +1,126 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.DependencyInjection;
using SIGCM2.Api.Controllers;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Domain.Entities;
using SIGCM2.TestSupport;
using Xunit;
namespace SIGCM2.Api.Tests.Audit;
/// UDT-010 Batch 10 — AuditController integration tests.
[Collection("ApiIntegration")]
public sealed class AuditControllerTests : IClassFixture<TestWebAppFactory>
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private readonly TestWebAppFactory _factory;
public AuditControllerTests(TestWebAppFactory factory)
{
_factory = factory;
}
private async Task<(HttpClient client, int adminId)> AuthedAdminClientAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
await conn.ExecuteAsync("DELETE FROM dbo.AuditEvent;");
var adminId = await conn.QuerySingleAsync<int>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
var client = _factory.CreateClient();
var jwt = _factory.Services.GetRequiredService<IJwtService>();
var token = jwt.GenerateAccessToken(new Usuario(
id: adminId, username: "admin", passwordHash: "x",
nombre: "Admin", apellido: "Sys", email: null,
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return (client, adminId);
}
[Fact]
public async Task GetEvents_WithoutPermission_Returns403()
{
var client = _factory.CreateClient();
var jwt = _factory.Services.GetRequiredService<IJwtService>();
// Use a role without administracion:auditoria:ver (cajero only has ventas:contado:*)
var operadorToken = jwt.GenerateAccessToken(new Usuario(
id: 9999, username: "opx", passwordHash: "x",
nombre: "X", apellido: "Y", email: null,
rol: "cajero", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operadorToken);
var response = await client.GetAsync("/api/v1/audit/events");
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task GetEvents_WithoutAuth_Returns401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/audit/events");
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task GetEvents_AuthenticatedAdmin_ReturnsAuditEvents()
{
var (client, adminId) = await AuthedAdminClientAsync();
// Seed 3 events directly
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
for (var i = 0; i < 3; i++)
{
await conn.ExecuteAsync("""
INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId)
VALUES (@O, @A, @Ac, 'Usuario', @T);
""", new
{
O = DateTime.UtcNow.AddSeconds(-i),
A = adminId,
Ac = $"test.seed{i}",
T = i.ToString(),
});
}
var response = await client.GetAsync("/api/v1/audit/events?targetType=Usuario");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<AuditEventPageResponse>();
body.Should().NotBeNull();
body!.Items.Should().HaveCount(3);
body.Items.Should().OnlyContain(e => e.TargetType == "Usuario");
}
[Fact]
public async Task GetEvents_InvalidLimit_Returns400()
{
var (client, _) = await AuthedAdminClientAsync();
var response = await client.GetAsync("/api/v1/audit/events?limit=0");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var response2 = await client.GetAsync("/api/v1/audit/events?limit=101");
response2.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task GetEvents_FromGreaterThanTo_Returns400()
{
var (client, _) = await AuthedAdminClientAsync();
var response = await client.GetAsync(
"/api/v1/audit/events?from=2026-05-01T00:00:00Z&to=2026-04-01T00:00:00Z");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}

View File

@@ -0,0 +1,30 @@
using System.Net;
using FluentAssertions;
using SIGCM2.TestSupport;
using Xunit;
namespace SIGCM2.Api.Tests.Audit;
/// UDT-010 Batch 10 — /health/audit integration smoke.
[Collection("ApiIntegration")]
public sealed class AuditHealthCheckTests : IClassFixture<TestWebAppFactory>
{
private readonly TestWebAppFactory _factory;
public AuditHealthCheckTests(TestWebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task HealthAudit_WithInfraApplied_ReturnsHealthy()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/health/audit");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadAsStringAsync();
body.Should().Contain("Healthy");
}
}

View File

@@ -0,0 +1,100 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using SIGCM2.Api.Middleware;
using Xunit;
namespace SIGCM2.Api.Tests.Audit;
/// UDT-010 Batch 4 — CorrelationIdMiddleware unit tests (Strict TDD).
/// Validates #REQ-AUD-9 (CorrelationId in response header) and population of
/// HttpContext.Items entries consumed by AuditContext.
public sealed class CorrelationIdMiddlewareTests
{
private const string HeaderName = "X-Correlation-Id";
[Fact]
public async Task Invoke_HeaderAbsent_GeneratesNewCorrelationId()
{
var ctx = new DefaultHttpContext();
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
ctx.Items.TryGetValue("audit:correlationId", out var value).Should().BeTrue();
value.Should().BeOfType<Guid>();
((Guid)value!).Should().NotBe(Guid.Empty);
}
[Fact]
public async Task Invoke_HeaderPresent_UsesClientProvidedCorrelationId()
{
var expected = Guid.NewGuid();
var ctx = new DefaultHttpContext();
ctx.Request.Headers[HeaderName] = expected.ToString("D");
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
ctx.Items["audit:correlationId"].Should().Be(expected);
}
[Fact]
public async Task Invoke_HeaderIsMalformed_GeneratesNewCorrelationId()
{
var ctx = new DefaultHttpContext();
ctx.Request.Headers[HeaderName] = "not-a-guid";
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
var stored = (Guid)ctx.Items["audit:correlationId"]!;
stored.Should().NotBe(Guid.Empty);
}
[Fact]
public async Task Invoke_SetsResponseHeader_WithCorrelationId()
{
var ctx = new DefaultHttpContext();
ctx.Response.Body = new MemoryStream();
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
// OnStarting callbacks fire when response starts — simulate by writing
await ctx.Response.Body.FlushAsync();
// For DefaultHttpContext + MemoryStream, the OnStarting hook must fire when body writes start.
// We assert the header is present after invoking a manual start-write.
ctx.Response.Headers.TryGetValue(HeaderName, out var headerValue).Should().BeTrue();
Guid.TryParse(headerValue.ToString(), out _).Should().BeTrue();
}
[Fact]
public async Task Invoke_SetsIpAndUserAgentInItems()
{
var ctx = new DefaultHttpContext();
ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.20.30.40");
ctx.Request.Headers.UserAgent = "test-agent/1.0";
var mw = new CorrelationIdMiddleware(_ => Task.CompletedTask);
await mw.InvokeAsync(ctx);
ctx.Items["audit:ip"].Should().Be("10.20.30.40");
ctx.Items["audit:userAgent"].Should().Be("test-agent/1.0");
}
[Fact]
public async Task Invoke_CallsNextDelegate()
{
var ctx = new DefaultHttpContext();
var nextCalled = false;
var mw = new CorrelationIdMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await mw.InvokeAsync(ctx);
nextCalled.Should().BeTrue();
}
}

View File

@@ -0,0 +1,59 @@
using System.Transactions;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
namespace SIGCM2.Api.Tests.Audit;
/// UDT-010 Batch 0 — anti-MSDTC spike. Validates design decision #D-1:
/// TransactionScope with AsyncFlowEnabled must NOT escalate to MSDTC when
/// multiple SqlConnections share a single connection string. If this fails,
/// UDT-010 must pivot to explicit IUnitOfWork.
[Collection("ApiIntegration")]
public sealed class TransactionScopeSpikeTests
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
[Fact]
public async Task TransactionScope_DoesNotEscalateToMSDTC_WithSingleConnectionString()
{
var txOptions = new TransactionOptions
{
IsolationLevel = IsolationLevel.ReadCommitted
};
using var tx = new TransactionScope(
TransactionScopeOption.Required,
txOptions,
TransactionScopeAsyncFlowOption.Enabled);
await using (var conn1 = new SqlConnection(ConnectionString))
{
await conn1.OpenAsync();
using var cmd1 = conn1.CreateCommand();
cmd1.CommandText = "SELECT 1";
var result1 = await cmd1.ExecuteScalarAsync();
result1.Should().Be(1);
}
await using (var conn2 = new SqlConnection(ConnectionString))
{
await conn2.OpenAsync();
using var cmd2 = conn2.CreateCommand();
cmd2.CommandText = "SELECT 1";
var result2 = await cmd2.ExecuteScalarAsync();
result2.Should().Be(1);
}
var current = Transaction.Current;
current.Should().NotBeNull("TransactionScope must set an ambient transaction");
current!.TransactionInformation.DistributedIdentifier
.Should().Be(Guid.Empty,
"SQL Server with a single connection string must NOT escalate to MSDTC. " +
"If this assertion fails, UDT-010 design must pivot to explicit IUnitOfWork " +
"(see sdd/udt-010-auditoria-trazabilidad/design #D-1).");
tx.Complete();
}
}

View File

@@ -0,0 +1,150 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
namespace SIGCM2.Api.Tests.Audit;
/// UDT-010 Batch 1 — V010 migration integration smoke tests.
/// Validates:
/// REQ-AUD-1: SYSTEM_VERSIONING active on catalog entities (smoke: Usuario + Usuario_History query).
/// REQ-AUD-2: dbo.AuditEvent exists, accepts valid INSERT, CHECK constraints reject invalid data.
/// REQ-SEC-1: dbo.SecurityEvent exists, CK_SecurityEvent_Result rejects invalid Result.
[Collection("ApiIntegration")]
public sealed class V010MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
public V010MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
{
// Depend on the factory so SqlTestFixture.InitializeAsync runs (validates V010 applied + resets DB).
}
[Fact]
public async Task AuditEvent_Insert_WithValidData_Persists()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var correlationId = Guid.NewGuid();
var insertedId = await conn.ExecuteScalarAsync<long>("""
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, CorrelationId, Metadata)
VALUES (@ActorUserId, @Action, @TargetType, @TargetId, @CorrelationId, @Metadata);
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);
""",
new
{
ActorUserId = 1,
Action = "usuario.create",
TargetType = "Usuario",
TargetId = "42",
CorrelationId = correlationId,
Metadata = """{"after":{"username":"juan"}}"""
});
insertedId.Should().BeGreaterThan(0);
var roundtrip = await conn.QuerySingleAsync<(string Action, string TargetType, string TargetId, Guid? CorrelationId)>(
"SELECT Action, TargetType, TargetId, CorrelationId FROM dbo.AuditEvent WHERE Id = @Id",
new { Id = insertedId });
roundtrip.Action.Should().Be("usuario.create");
roundtrip.TargetType.Should().Be("Usuario");
roundtrip.TargetId.Should().Be("42");
roundtrip.CorrelationId.Should().Be(correlationId);
}
[Fact]
public async Task AuditEvent_Insert_WithInvalidActionFormat_FailsCheckConstraint()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var act = async () => await conn.ExecuteAsync("""
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId)
VALUES (1, 'invalid_no_dot', 'Usuario', '1');
""");
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_AuditEvent_Action"));
}
[Fact]
public async Task AuditEvent_Insert_WithNonJsonMetadata_FailsCheckConstraint()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var act = async () => await conn.ExecuteAsync("""
INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, Metadata)
VALUES (1, 'usuario.create', 'Usuario', '1', 'not-json');
""");
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_AuditEvent_Metadata"));
}
[Fact]
public async Task SecurityEvent_Insert_WithInvalidResult_FailsCheckConstraint()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var act = async () => await conn.ExecuteAsync("""
INSERT INTO dbo.SecurityEvent (ActorUserId, Action, Result)
VALUES (1, 'login', 'neutral');
""");
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_SecurityEvent_Result"));
}
[Fact]
public async Task Usuario_SystemVersioning_IsActive_And_TemporalQueryReturnsHistory()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
// Seed: insert a scratch user and wait long enough for ValidFrom to be in the past
var username = "b1spike_" + Guid.NewGuid().ToString("N")[..8];
var newId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson)
VALUES (@u, 'hash', 'Temp', 'User', 'admin', '{"grant":[],"deny":[]}');
SELECT CAST(SCOPE_IDENTITY() AS INT);
""", new { u = username });
// Capture a timestamp *after* creation but *before* update
await Task.Delay(50);
var snapshotTime = DateTime.UtcNow;
await Task.Delay(50);
// Update the email
await conn.ExecuteAsync(
"UPDATE dbo.Usuario SET Email = @e WHERE Id = @id",
new { e = "new@example.com", id = newId });
// Temporal query: state at snapshotTime should NOT yet have the new email
var historicalEmail = await conn.ExecuteScalarAsync<string?>("""
SELECT Email FROM dbo.Usuario
FOR SYSTEM_TIME AS OF @ts
WHERE Id = @id
""", new { ts = snapshotTime, id = newId });
historicalEmail.Should().BeNull("the historical snapshot predates the email update");
// Current state HAS the new email
var currentEmail = await conn.ExecuteScalarAsync<string?>(
"SELECT Email FROM dbo.Usuario WHERE Id = @id",
new { id = newId });
currentEmail.Should().Be("new@example.com");
// Usuario_History must have at least one row for this user
var historyCount = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM dbo.Usuario_History WHERE Id = @id",
new { id = newId });
historyCount.Should().BeGreaterThan(0);
// Cleanup: Respawn will reset between test runs, but this test could run alone — best-effort delete.
await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Id = @id", new { id = newId });
}
}

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Api.Tests.Authorization;
@@ -18,6 +19,7 @@ public sealed class PermissionAuthorizationHandlerTests
{
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
private readonly PermissionAuthorizationHandler _handler;
public PermissionAuthorizationHandlerTests()
@@ -29,6 +31,7 @@ public sealed class PermissionAuthorizationHandlerTests
_handler = new PermissionAuthorizationHandler(
_rolPermisoRepo,
_usuarioRepo,
_security,
NullLogger<PermissionAuthorizationHandler>.Instance);
}

View File

@@ -0,0 +1,179 @@
using FluentAssertions;
using NSubstitute;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Exceptions;
using Xunit;
namespace SIGCM2.Application.Tests.Audit;
/// UDT-010 Batch 2 — shape contract tests for audit abstractions.
/// These tests fail to compile if the interface/DTO/exception shape drifts from #D-8.
public sealed class AuditAbstractionsTests
{
[Fact]
public void IAuditContext_ExposesExpectedReadOnlyProperties()
{
var ctx = Substitute.For<IAuditContext>();
ctx.ActorUserId.Returns(42);
ctx.ActorRoleId.Returns(7);
ctx.Ip.Returns("127.0.0.1");
ctx.UserAgent.Returns("test-agent/1.0");
var corrId = Guid.NewGuid();
ctx.CorrelationId.Returns(corrId);
ctx.ActorUserId.Should().Be(42);
ctx.ActorRoleId.Should().Be(7);
ctx.Ip.Should().Be("127.0.0.1");
ctx.UserAgent.Should().Be("test-agent/1.0");
ctx.CorrelationId.Should().Be(corrId);
}
[Fact]
public void IAuditContext_AllowsNullActorAndConnectionMetadata()
{
var ctx = Substitute.For<IAuditContext>();
ctx.ActorUserId.Returns((int?)null);
ctx.ActorRoleId.Returns((int?)null);
ctx.Ip.Returns((string?)null);
ctx.UserAgent.Returns((string?)null);
ctx.ActorUserId.Should().BeNull();
ctx.ActorRoleId.Should().BeNull();
ctx.Ip.Should().BeNull();
ctx.UserAgent.Should().BeNull();
}
[Fact]
public async Task IAuditLogger_LogAsync_ExposesExpectedSignature()
{
var logger = Substitute.For<IAuditLogger>();
await logger.LogAsync(
action: "usuario.create",
targetType: "Usuario",
targetId: "42",
metadata: new { username = "juan" },
ct: CancellationToken.None);
await logger.Received(1).LogAsync(
"usuario.create",
"Usuario",
"42",
Arg.Any<object>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task IAuditLogger_LogAsync_AllowsNullMetadata()
{
var logger = Substitute.For<IAuditLogger>();
await logger.LogAsync("usuario.deactivate", "Usuario", "42");
await logger.Received(1).LogAsync(
"usuario.deactivate",
"Usuario",
"42",
null,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ISecurityEventLogger_LogAsync_ExposesFullSignatureForLoginFailure()
{
var logger = Substitute.For<ISecurityEventLogger>();
var sessionId = Guid.NewGuid();
await logger.LogAsync(
action: "login",
result: "failure",
actorUserId: null,
attemptedUsername: "juan",
sessionId: sessionId,
failureReason: "invalid_password",
metadata: new { ip = "1.2.3.4" },
ct: CancellationToken.None);
await logger.Received(1).LogAsync(
"login", "failure", null, "juan", sessionId, "invalid_password",
Arg.Any<object>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ISecurityEventLogger_LogAsync_MinimalSuccessCall()
{
var logger = Substitute.For<ISecurityEventLogger>();
await logger.LogAsync("logout", "success", actorUserId: 42);
await logger.Received(1).LogAsync(
"logout", "success", 42, null, null, null, null, Arg.Any<CancellationToken>());
}
[Fact]
public void AuditContextMissingException_IsDomainException_WithFixedMessage()
{
var ex = new AuditContextMissingException();
ex.Should().BeAssignableTo<DomainException>();
ex.Message.Should().NotBeNullOrWhiteSpace();
ex.Message.Should().Contain("ActorUserId");
}
[Fact]
public void AuditOptions_HasSanitizedKeysDefault()
{
var opts = new AuditOptions();
opts.SanitizedKeys.Should().NotBeNull();
opts.SanitizedKeys.Should().Contain(new[] { "password", "passwordHash", "token", "refreshToken" });
}
[Fact]
public void AuditEventDto_IsReadonlyRecord()
{
var dto = new AuditEventDto(
Id: 1,
OccurredAt: DateTime.UtcNow,
ActorUserId: 42,
ActorUsername: "admin",
Action: "usuario.create",
TargetType: "Usuario",
TargetId: "99",
CorrelationId: Guid.NewGuid(),
IpAddress: "1.2.3.4",
Metadata: """{"after":{"username":"juan"}}""");
dto.Id.Should().Be(1);
dto.Action.Should().Be("usuario.create");
dto.TargetType.Should().Be("Usuario");
}
[Fact]
public void AuditEventFilter_DefaultLimitIsReasonable()
{
var f = new AuditEventFilter(
ActorUserId: null,
TargetType: null,
TargetId: null,
From: null,
To: null,
Cursor: null);
f.Limit.Should().BeInRange(1, 100);
}
[Fact]
public void AuditEventFilter_AcceptsExplicitLimitWithinBounds()
{
var f = new AuditEventFilter(
ActorUserId: 42,
TargetType: "Usuario",
TargetId: "99",
From: DateTime.UtcNow.AddDays(-7),
To: DateTime.UtcNow,
Cursor: "opaque-cursor",
Limit: 100);
f.Limit.Should().Be(100);
f.ActorUserId.Should().Be(42);
}
}

View File

@@ -3,6 +3,7 @@ using NSubstitute;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Auth;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Domain.Entities;
@@ -20,6 +21,7 @@ public class LoginCommandHandlerTests
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
private readonly ILogger<LoginCommandHandler> _logger = Substitute.For<ILogger<LoginCommandHandler>>();
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
private readonly LoginCommandHandler _handler;
@@ -42,7 +44,7 @@ public class LoginCommandHandlerTests
_handler = new LoginCommandHandler(
_repository, _hasher, _jwtService,
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
_rolPermisoRepo, _logger);
_rolPermisoRepo, _security, _logger);
}
// Scenario: valid credentials → returns token response with usuario populated

View File

@@ -1,5 +1,6 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Auth.Logout;
namespace SIGCM2.Application.Tests.Auth.Logout;
@@ -7,11 +8,12 @@ namespace SIGCM2.Application.Tests.Auth.Logout;
public class LogoutCommandHandlerTests
{
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
private readonly LogoutCommandHandler _handler;
public LogoutCommandHandlerTests()
{
_handler = new LogoutCommandHandler(_refreshRepo);
_handler = new LogoutCommandHandler(_refreshRepo, _security);
}
[Fact]

View File

@@ -4,6 +4,7 @@ using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Auth;
using SIGCM2.Application.Auth.Refresh;
using SIGCM2.Domain.Entities;
@@ -19,6 +20,7 @@ public class RefreshCommandHandlerTests
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
private readonly IRefreshTokenGenerator _generator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
private readonly RefreshCommandHandler _handler;
@@ -34,7 +36,7 @@ public class RefreshCommandHandlerTests
_generator.Generate().Returns("new_raw_token_value_xyz");
_handler = new RefreshCommandHandler(
_refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions);
_refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions, _security);
}
// Helper: build an active stored RefreshToken with a matching principal

View File

@@ -0,0 +1,89 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using NSubstitute;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 4 — AuditContext (scoped IAuditContext impl) unit tests.
/// Reads audit fields from HttpContext.Items populated by CorrelationIdMiddleware + AuditActorMiddleware.
public sealed class AuditContextTests
{
private static (AuditContext ctx, DefaultHttpContext http) Build()
{
var http = new DefaultHttpContext();
var accessor = Substitute.For<IHttpContextAccessor>();
accessor.HttpContext.Returns(http);
return (new AuditContext(accessor), http);
}
[Fact]
public void ReadsActorUserIdFromItems()
{
var (ctx, http) = Build();
http.Items["audit:actorUserId"] = 42;
ctx.ActorUserId.Should().Be(42);
}
[Fact]
public void ActorUserId_IsNull_WhenNotPresent()
{
var (ctx, _) = Build();
ctx.ActorUserId.Should().BeNull();
}
[Fact]
public void ReadsIpAndUserAgentFromItems()
{
var (ctx, http) = Build();
http.Items["audit:ip"] = "10.20.30.40";
http.Items["audit:userAgent"] = "ua/1.0";
ctx.Ip.Should().Be("10.20.30.40");
ctx.UserAgent.Should().Be("ua/1.0");
}
[Fact]
public void ReadsCorrelationIdFromItems()
{
var (ctx, http) = Build();
var id = Guid.NewGuid();
http.Items["audit:correlationId"] = id;
ctx.CorrelationId.Should().Be(id);
}
[Fact]
public void CorrelationId_IsEmpty_WhenNotPresent()
{
var (ctx, _) = Build();
ctx.CorrelationId.Should().Be(Guid.Empty);
}
[Fact]
public void AllFields_AreNull_WhenHttpContextIsNull()
{
var accessor = Substitute.For<IHttpContextAccessor>();
accessor.HttpContext.Returns((HttpContext?)null);
var ctx = new AuditContext(accessor);
ctx.ActorUserId.Should().BeNull();
ctx.ActorRoleId.Should().BeNull();
ctx.Ip.Should().BeNull();
ctx.UserAgent.Should().BeNull();
ctx.CorrelationId.Should().Be(Guid.Empty);
}
[Fact]
public void ActorRoleId_IsNull_Always_InB4()
{
// B4 middleware does not resolve rol code -> id; future batches may.
var (ctx, http) = Build();
http.Items["audit:actorUserId"] = 42;
ctx.ActorRoleId.Should().BeNull();
}
}

View File

@@ -0,0 +1,228 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit;
using SIGCM2.Infrastructure.Persistence;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 5 — AuditEventRepository integration tests against SIGCM2_Test.
/// Validates insert + cursor-paginated DESC query with all filter permutations.
[Collection("Database")]
public sealed class AuditEventRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private AuditEventRepository _repo = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
// Clean slate for this test class: wipe prior audit events. Respawn does this too
// between test classes but inside a class tests share state.
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;");
var factory = new SqlConnectionFactory(ConnectionString);
_repo = new AuditEventRepository(factory);
}
public async Task DisposeAsync()
{
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;");
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task InsertAsync_PersistsAllFields_AndReturnsId()
{
var occurredAt = DateTime.UtcNow;
var correlationId = Guid.NewGuid();
var id = await _repo.InsertAsync(
occurredAt: occurredAt,
actorUserId: 42,
actorRoleId: 7,
action: "usuario.create",
targetType: "Usuario",
targetId: "99",
correlationId: correlationId,
ipAddress: "1.2.3.4",
userAgent: "ua/1.0",
metadata: """{"after":{"username":"juan"}}""");
id.Should().BeGreaterThan(0);
var roundtrip = await _connection.QuerySingleAsync<(int? ActorUserId, int? ActorRoleId, string Action, Guid? CorrelationId, string? IpAddress)>(
"SELECT ActorUserId, ActorRoleId, Action, CorrelationId, IpAddress FROM dbo.AuditEvent WHERE Id = @Id",
new { Id = id });
roundtrip.ActorUserId.Should().Be(42);
roundtrip.ActorRoleId.Should().Be(7);
roundtrip.Action.Should().Be("usuario.create");
roundtrip.CorrelationId.Should().Be(correlationId);
roundtrip.IpAddress.Should().Be("1.2.3.4");
}
[Fact]
public async Task QueryAsync_NoFilters_ReturnsAllInDescendingOrder()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await Seed(3, t0);
var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, 50));
result.Items.Should().HaveCount(3);
result.Items.Select(x => x.Action).Should().ContainInOrder("test.2", "test.1", "test.0");
result.NextCursor.Should().BeNull();
}
[Fact]
public async Task QueryAsync_FilterByActor_ReturnsOnlyMatching()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "1", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(1), 2, null, "test.1", "Usuario", "2", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "3", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(ActorUserId: 1,
TargetType: null, TargetId: null, From: null, To: null, Cursor: null, Limit: 50));
result.Items.Should().HaveCount(2);
result.Items.Select(x => x.ActorUserId).Should().OnlyContain(a => a == 1);
}
[Fact]
public async Task QueryAsync_FilterByTarget_ReturnsOnlyMatching()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "42", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(1), 1, null, "test.1", "Cliente", "99", null, null, null, null);
await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "42", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(null,
TargetType: "Usuario", TargetId: "42", From: null, To: null, Cursor: null, Limit: 50));
result.Items.Should().HaveCount(2);
result.Items.Should().OnlyContain(x => x.TargetType == "Usuario" && x.TargetId == "42");
}
[Fact]
public async Task QueryAsync_FilterByDateRange_RespectsFromAndTo()
{
var t0 = new DateTime(2026, 4, 10, 12, 0, 0, DateTimeKind.Utc);
await _repo.InsertAsync(t0, 1, null, "test.0", "X", "1", null, null, null, null);
await _repo.InsertAsync(t0.AddDays(3), 1, null, "test.1", "X", "2", null, null, null, null);
await _repo.InsertAsync(t0.AddDays(6), 1, null, "test.2", "X", "3", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null,
From: t0.AddDays(1), To: t0.AddDays(5), Cursor: null, Limit: 50));
result.Items.Should().HaveCount(1);
result.Items[0].Action.Should().Be("test.1");
}
[Fact]
public async Task QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable()
{
var t0 = DateTime.UtcNow.AddMinutes(-10);
await Seed(5, t0);
var page1 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, Limit: 2));
page1.Items.Should().HaveCount(2);
page1.NextCursor.Should().NotBeNull();
var page2 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page1.NextCursor, Limit: 2));
page2.Items.Should().HaveCount(2);
page2.NextCursor.Should().NotBeNull();
var page3 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page2.NextCursor, Limit: 2));
page3.Items.Should().HaveCount(1);
page3.NextCursor.Should().BeNull();
// Across pages, all 5 events are visited exactly once (no overlap, no gap).
var allActions = page1.Items.Concat(page2.Items).Concat(page3.Items).Select(x => x.Action).ToList();
allActions.Should().HaveCount(5).And.OnlyHaveUniqueItems();
}
[Fact]
public async Task QueryAsync_MalformedCursor_TreatedAsNoCursor()
{
var t0 = DateTime.UtcNow.AddSeconds(-30);
await Seed(2, t0);
var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, "not-a-valid-cursor", 50));
result.Items.Should().HaveCount(2);
}
[Fact]
public async Task QueryAsync_JoinsUsuario_PopulatesActorUsername()
{
var adminId = await _connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'");
if (adminId is null)
{
// Seed admin if not present (Respawn wiped it)
adminId = await _connection.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson)
VALUES ('admin', 'hash', 'Admin', 'Sistema', 'admin', '{"grant":[],"deny":[]}');
SELECT CAST(SCOPE_IDENTITY() AS INT);
""");
}
await _repo.InsertAsync(DateTime.UtcNow, adminId, null, "usuario.create", "Usuario", "99", null, null, null, null);
var result = await _repo.QueryAsync(new AuditEventFilter(adminId, null, null, null, null, null, 50));
result.Items.Should().ContainSingle()
.Which.ActorUsername.Should().Be("admin");
}
[Fact]
public async Task AuditEventCursor_EncodeDecode_RoundTrips()
{
var now = DateTime.UtcNow;
var encoded = AuditEventCursor.Encode(now, 12345);
var decoded = AuditEventCursor.TryDecode(encoded);
decoded.Should().NotBeNull();
decoded!.Value.Id.Should().Be(12345);
// DateTime roundtrip via "O" format preserves ticks-level precision.
decoded.Value.OccurredAt.Should().BeCloseTo(now, TimeSpan.FromTicks(1));
}
[Fact]
public void AuditEventCursor_TryDecode_ReturnsNullForMalformed()
{
AuditEventCursor.TryDecode(null).Should().BeNull();
AuditEventCursor.TryDecode("").Should().BeNull();
AuditEventCursor.TryDecode("not-base64!!!").Should().BeNull();
AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("no-pipe"))).Should().BeNull();
AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("|123"))).Should().BeNull();
AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("2026-04-16|"))).Should().BeNull();
}
private async Task Seed(int count, DateTime baseTime)
{
for (var i = 0; i < count; i++)
{
await _repo.InsertAsync(
occurredAt: baseTime.AddSeconds(i),
actorUserId: 1,
actorRoleId: null,
action: $"test.{i}",
targetType: "Usuario",
targetId: i.ToString(),
correlationId: null,
ipAddress: null,
userAgent: null,
metadata: null);
}
}
}

View File

@@ -0,0 +1,123 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Quartz;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit.Jobs;
using SIGCM2.Infrastructure.Persistence;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 11 — audit maintenance jobs integration tests.
/// Executes each IJob directly (no Quartz scheduler needed) against SIGCM2_Test.
[Collection("Database")]
public sealed class AuditJobsTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private SqlConnectionFactory _factory = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
_factory = new SqlConnectionFactory(ConnectionString);
}
public async Task DisposeAsync()
{
await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent; DELETE FROM dbo.SecurityEvent;");
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
private static IJobExecutionContext MockContext()
{
var ctx = Substitute.For<IJobExecutionContext>();
ctx.CancellationToken.Returns(CancellationToken.None);
return ctx;
}
[Fact]
public async Task PartitionManager_ExtendsFunctionForward_Idempotent()
{
var job = new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance);
// First run: ensure the target boundary exists
await job.Execute(MockContext());
// Compute target as the job does
var now = DateTime.UtcNow;
var target = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(2);
foreach (var pf in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
{
var count = await _connection.ExecuteScalarAsync<int>("""
SELECT COUNT(*)
FROM sys.partition_functions pf
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
WHERE pf.name = @Name AND CAST(prv.value AS DATETIME2(3)) = @Target;
""", new { Name = pf, Target = target });
count.Should().Be(1, $"{pf} should have boundary {target:yyyy-MM-dd}");
}
// Second run must not throw (idempotent)
await job.Execute(MockContext());
}
[Fact]
public async Task RetentionEnforcer_PurgesAuditEventOlderThan10Years_AndSecurityOlderThan5Years()
{
await _connection.ExecuteAsync("""
INSERT INTO dbo.AuditEvent (OccurredAt, ActorUserId, Action, TargetType, TargetId)
VALUES
(@Ancient, 1, 'x.y', 'T', '1'), -- should be purged
(@Recent, 1, 'x.y', 'T', '2'); -- should stay
INSERT INTO dbo.SecurityEvent (OccurredAt, ActorUserId, Action, Result)
VALUES
(@Ancient5, 1, 'login', 'success'), -- should be purged
(@Recent, 1, 'login', 'success'); -- should stay
""", new
{
Ancient = DateTime.UtcNow.AddYears(-11),
Recent = DateTime.UtcNow.AddDays(-1),
Ancient5 = DateTime.UtcNow.AddYears(-6),
});
var job = new AuditRetentionEnforcerJob(_factory, NullLogger<AuditRetentionEnforcerJob>.Instance);
await job.Execute(MockContext());
var auditCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.AuditEvent;");
var securityCount = await _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.SecurityEvent;");
auditCount.Should().Be(1);
securityCount.Should().Be(1);
}
[Fact]
public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent()
{
var security = Substitute.For<ISecurityEventLogger>();
var job = new AuditIntegrityCheckJob(_factory, security, NullLogger<AuditIntegrityCheckJob>.Instance);
// Ensure partition manager has run first so next-3-months exist
await new AuditPartitionManagerJob(_factory, NullLogger<AuditPartitionManagerJob>.Instance).Execute(MockContext());
await job.Execute(MockContext());
await security.DidNotReceive().LogAsync(
action: "system.integrity_alert",
result: Arg.Any<string>(),
actorUserId: Arg.Any<int?>(),
attemptedUsername: Arg.Any<string?>(),
sessionId: Arg.Any<Guid?>(),
failureReason: Arg.Any<string?>(),
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,154 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using NSubstitute;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Infrastructure.Audit;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 6 — AuditLogger unit tests (Strict TDD).
/// Covers #REQ-AUD-3/4/5: enriches from IAuditContext, fail-closed on missing actor,
/// sanitizes metadata via JsonSanitizer + AuditOptions.
public sealed class AuditLoggerTests
{
private static AuditLogger Build(
IAuditContext? context = null,
IAuditEventRepository? repo = null,
AuditOptions? options = null)
{
context ??= Substitute.For<IAuditContext>();
repo ??= Substitute.For<IAuditEventRepository>();
options ??= new AuditOptions();
var optsWrapper = Options.Create(options);
return new AuditLogger(context, repo, optsWrapper);
}
[Fact]
public async Task LogAsync_WithAllContext_PassesEnrichedValuesToRepo()
{
var context = Substitute.For<IAuditContext>();
var correlationId = Guid.NewGuid();
context.ActorUserId.Returns(42);
context.ActorRoleId.Returns(7);
context.Ip.Returns("10.0.0.5");
context.UserAgent.Returns("ua/1.0");
context.CorrelationId.Returns(correlationId);
var repo = Substitute.For<IAuditEventRepository>();
var logger = Build(context, repo);
await logger.LogAsync("usuario.create", "Usuario", "99",
metadata: new { username = "juan" });
await repo.Received(1).InsertAsync(
Arg.Any<DateTime>(),
42,
7,
"usuario.create",
"Usuario",
"99",
correlationId,
"10.0.0.5",
"ua/1.0",
Arg.Is<string?>(m => m != null && m.Contains("\"username\"") && m.Contains("juan")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task LogAsync_WithoutActorUserId_ThrowsAuditContextMissingException()
{
var context = Substitute.For<IAuditContext>();
context.ActorUserId.Returns((int?)null);
context.CorrelationId.Returns(Guid.NewGuid());
var repo = Substitute.For<IAuditEventRepository>();
var logger = Build(context, repo);
var act = async () => await logger.LogAsync("usuario.create", "Usuario", "1");
await act.Should().ThrowAsync<AuditContextMissingException>();
await repo.DidNotReceive().InsertAsync(
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task LogAsync_SanitizesMetadata_StripsBlacklistedKeys()
{
var context = Substitute.For<IAuditContext>();
context.ActorUserId.Returns(1);
var repo = Substitute.For<IAuditEventRepository>();
var logger = Build(context, repo);
await logger.LogAsync("usuario.update", "Usuario", "1",
metadata: new { password = "secret", email = "e@x" });
await repo.Received(1).InsertAsync(
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Is<string?>(m => m != null && !m.Contains("\"password\"") && m.Contains("\"email\"")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task LogAsync_NullMetadata_PassesNullToRepo()
{
var context = Substitute.For<IAuditContext>();
context.ActorUserId.Returns(1);
var repo = Substitute.For<IAuditEventRepository>();
var logger = Build(context, repo);
await logger.LogAsync("usuario.deactivate", "Usuario", "1");
await repo.Received(1).InsertAsync(
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
"usuario.deactivate", "Usuario", "1",
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Is<string?>(m => m == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task LogAsync_RepositoryThrows_ExceptionBubblesUp()
{
var context = Substitute.For<IAuditContext>();
context.ActorUserId.Returns(1);
var repo = Substitute.For<IAuditEventRepository>();
repo.InsertAsync(
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns<long>(_ => throw new InvalidOperationException("simulated db failure"));
var logger = Build(context, repo);
var act = async () => await logger.LogAsync("usuario.create", "Usuario", "1");
// Fail-closed: exception MUST bubble to caller (caller's TransactionScope will roll back).
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("simulated db failure");
}
[Fact]
public async Task LogAsync_UsesCustomSanitizedKeys_FromOptions()
{
var context = Substitute.For<IAuditContext>();
context.ActorUserId.Returns(1);
var repo = Substitute.For<IAuditEventRepository>();
var logger = Build(context, repo, new AuditOptions { SanitizedKeys = new[] { "internalId" } });
await logger.LogAsync("x.y", "T", "1", metadata: new { internalId = "secret", visible = "ok" });
await repo.Received(1).InsertAsync(
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<int?>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Is<string?>(m => m != null && !m.Contains("\"internalId\"") && m.Contains("\"visible\"")),
Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,71 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 3 — AuditOptions binding smoke tests.
/// Validates that AddInfrastructure binds AuditOptions from the "Audit" config section
/// and falls back to the POCO defaults when the section is absent.
public sealed class AuditOptionsBindingTests
{
private static IServiceProvider BuildProvider(IEnumerable<KeyValuePair<string, string?>>? overrides = null)
{
// Minimum required config for AddInfrastructure to succeed.
var inMemory = new Dictionary<string, string?>
{
["ConnectionStrings:SqlServer"] = "Server=nowhere;Database=x;Integrated Security=true;",
["Jwt:Issuer"] = "test",
["Jwt:Audience"] = "test",
["Jwt:AccessTokenMinutes"] = "60",
["Jwt:RefreshTokenDays"] = "7",
["Jwt:PrivateKeyPath"] = "unused-in-this-test.pem",
["Jwt:PublicKeyPath"] = "unused-in-this-test.pem",
};
if (overrides is not null)
foreach (var kv in overrides)
inMemory[kv.Key] = kv.Value;
var config = new ConfigurationBuilder().AddInMemoryCollection(inMemory).Build();
var services = new ServiceCollection();
services.AddInfrastructure(config);
return services.BuildServiceProvider();
}
[Fact]
public void AuditOptions_WithoutConfigSection_UsesPocoDefaults()
{
using var sp = (ServiceProvider)BuildProvider();
var opts = sp.GetRequiredService<IOptions<AuditOptions>>().Value;
opts.SanitizedKeys.Should().Contain("password");
opts.SanitizedKeys.Should().Contain("refreshToken");
opts.SanitizedKeys.Should().Contain("apiKey");
}
[Fact]
public void AuditOptions_WithConfigSection_AddsToDefaults_PerIConfigurationArrayBinding()
{
// IConfiguration array binding is ADDITIVE, not REPLACE: config values at indices 0..N
// overwrite those indices but defaults beyond N are preserved. This is intended for
// AuditOptions — extensibility is additive (append, not replace).
using var sp = (ServiceProvider)BuildProvider(new[]
{
new KeyValuePair<string, string?>("Audit:SanitizedKeys:11", "customSecret"),
new KeyValuePair<string, string?>("Audit:SanitizedKeys:12", "internalToken"),
});
var opts = sp.GetRequiredService<IOptions<AuditOptions>>().Value;
// The 11 defaults remain + the 2 extras appear at the configured indices.
opts.SanitizedKeys.Should().Contain("password"); // default survived
opts.SanitizedKeys.Should().Contain("customSecret"); // appended via config
opts.SanitizedKeys.Should().Contain("internalToken"); // appended via config
}
}

View File

@@ -0,0 +1,173 @@
using FluentAssertions;
using SIGCM2.Infrastructure.Audit;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 3 — JsonSanitizer unit tests (Strict TDD).
/// Validates #REQ-AUD-5: metadata MUST be stripped of blacklisted keys before persisting.
public sealed class JsonSanitizerTests
{
private static readonly string[] DefaultBlacklist =
[
"password", "passwordHash", "token", "refreshToken",
"accessToken", "cvv", "card", "cardNumber",
"secret", "apiKey", "privateKey",
];
[Fact]
public void Sanitize_NullInput_ReturnsNull()
{
var result = JsonSanitizer.Sanitize(null, DefaultBlacklist);
result.Should().BeNull();
}
[Fact]
public void Sanitize_EmptyBlacklist_PreservesAllKeys()
{
var result = JsonSanitizer.Sanitize(new { password = "x", username = "y" }, []);
result.Should().NotBeNull();
result.Should().Contain("\"password\"").And.Contain("\"username\"");
}
[Fact]
public void Sanitize_FlatObject_RemovesBlacklistedKeysAndKeepsOthers()
{
var result = JsonSanitizer.Sanitize(
new { password = "secret1", username = "juan", email = "j@x.com" },
DefaultBlacklist);
result.Should().NotBeNull();
result.Should().NotContain("\"password\"");
result.Should().NotContain("secret1");
result.Should().Contain("\"username\":\"juan\"");
result.Should().Contain("\"email\":\"j@x.com\"");
}
[Fact]
public void Sanitize_NestedObject_RemovesBlacklistedKeysAtEveryLevel()
{
var input = new
{
user = new { password = "x", email = "y@z.com" },
payload = new { nested = new { token = "abc", safe = "keep" } },
};
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
result.Should().NotContain("\"password\"").And.NotContain("\"token\"")
.And.NotContain("\"x\"").And.NotContain("abc");
result.Should().Contain("\"email\":\"y@z.com\"");
result.Should().Contain("\"safe\":\"keep\"");
}
[Fact]
public void Sanitize_ArrayOfObjects_StripsBlacklistedFromEachElement()
{
var input = new
{
items = new object[]
{
new { password = "a", name = "first" },
new { token = "b", name = "second" },
new { name = "third" },
},
};
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
result.Should().NotContain("\"password\"").And.NotContain("\"token\"");
result.Should().Contain("\"name\":\"first\"");
result.Should().Contain("\"name\":\"second\"");
result.Should().Contain("\"name\":\"third\"");
}
[Fact]
public void Sanitize_CaseInsensitiveMatching_StripsAllVariants()
{
var input = new Dictionary<string, object?>
{
["Password"] = "a",
["PASSWORD"] = "b",
["pAsSwOrD"] = "c",
["username"] = "keep",
};
var result = JsonSanitizer.Sanitize(input, new[] { "password" });
result.Should().NotBeNull();
result.Should().NotContain("\"a\"").And.NotContain("\"b\"").And.NotContain("\"c\"");
result.Should().Contain("\"username\":\"keep\"");
}
[Fact]
public void Sanitize_PreservesPrimitives_Numbers_Bools_Null()
{
var input = new { count = 42, flag = true, missing = (string?)null, password = "secret" };
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
result.Should().Contain("\"count\":42");
result.Should().Contain("\"flag\":true");
result.Should().Contain("\"missing\":null");
result.Should().NotContain("\"password\"");
}
[Fact]
public void Sanitize_ProducesValidJson()
{
var input = new { password = "x", user = new { token = "y", name = "z" } };
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
// Round-trip parse validates shape
var parsed = System.Text.Json.JsonDocument.Parse(result!);
parsed.RootElement.TryGetProperty("password", out _).Should().BeFalse();
parsed.RootElement.GetProperty("user").TryGetProperty("token", out _).Should().BeFalse();
parsed.RootElement.GetProperty("user").GetProperty("name").GetString().Should().Be("z");
}
[Fact]
public void Sanitize_AlreadySerializedJsonString_IsTreatedAsStringNotObject()
{
// Callers that want to sanitize an already-serialized JSON string must parse+re-sanitize
// themselves — the sanitizer treats a string argument as a JSON string primitive.
var result = JsonSanitizer.Sanitize("""{"password":"x"}""", DefaultBlacklist);
result.Should().NotBeNull();
// Round-trip parse proves the result is a JSON string primitive (not an object).
var parsed = System.Text.Json.JsonDocument.Parse(result!);
parsed.RootElement.ValueKind.Should().Be(System.Text.Json.JsonValueKind.String);
parsed.RootElement.GetString().Should().Be("""{"password":"x"}""");
}
[Fact]
public void Sanitize_AuditOptionsDefaultKeys_AreAllEffective()
{
// Defensive: AuditOptions.SanitizedKeys must match what tests expect.
var opts = new SIGCM2.Application.Audit.AuditOptions();
var input = new
{
password = "1", passwordHash = "2", token = "3", refreshToken = "4",
accessToken = "5", cvv = "6", card = "7", cardNumber = "8",
secret = "9", apiKey = "10", privateKey = "11",
keep = "yes",
};
var result = JsonSanitizer.Sanitize(input, opts.SanitizedKeys);
result.Should().NotBeNull();
foreach (var bad in new[] { "password", "passwordHash", "token", "refreshToken",
"accessToken", "cvv", "card", "cardNumber", "secret", "apiKey", "privateKey" })
{
result.Should().NotContain($"\"{bad}\"", because: $"'{bad}' is in default blacklist");
}
result.Should().Contain("\"keep\":\"yes\"");
}
}

View File

@@ -0,0 +1,101 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using NSubstitute;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 6 — SecurityEventLogger unit tests.
/// NOT fail-closed: security events are fire-and-forget writes; actor may be null
/// for login failures.
public sealed class SecurityEventLoggerTests
{
private static SecurityEventLogger Build(
ISecurityEventRepository? repo = null,
IAuditContext? context = null,
AuditOptions? options = null)
{
repo ??= Substitute.For<ISecurityEventRepository>();
context ??= Substitute.For<IAuditContext>();
options ??= new AuditOptions();
return new SecurityEventLogger(repo, context, Options.Create(options));
}
[Fact]
public async Task LogAsync_LoginSuccess_PassesActorAndIpFromContext()
{
var repo = Substitute.For<ISecurityEventRepository>();
var context = Substitute.For<IAuditContext>();
context.Ip.Returns("1.2.3.4");
context.UserAgent.Returns("ua");
var logger = Build(repo, context);
await logger.LogAsync("login", "success", actorUserId: 42);
await repo.Received(1).InsertAsync(
Arg.Any<DateTime>(),
42,
Arg.Is<string?>(s => s == null), // attemptedUsername
Arg.Is<Guid?>(g => g == null), // sessionId
"login", "success",
Arg.Is<string?>(s => s == null), // failureReason
"1.2.3.4",
"ua",
Arg.Is<string?>(s => s == null), // metadata
Arg.Any<CancellationToken>());
}
[Fact]
public async Task LogAsync_LoginFailure_SupportsNullActorAndAttemptedUsername()
{
var repo = Substitute.For<ISecurityEventRepository>();
var logger = Build(repo);
await logger.LogAsync("login", "failure",
actorUserId: null,
attemptedUsername: "juan",
failureReason: "invalid_password");
await repo.Received(1).InsertAsync(
Arg.Any<DateTime>(),
Arg.Is<int?>(i => i == null),
"juan",
Arg.Is<Guid?>(g => g == null),
"login", "failure",
"invalid_password",
Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Is<string?>(s => s == null),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task LogAsync_SanitizesMetadata()
{
var repo = Substitute.For<ISecurityEventRepository>();
var logger = Build(repo);
await logger.LogAsync("login", "failure", attemptedUsername: "x",
metadata: new { token = "leaked", ip = "1.1.1.1" });
await repo.Received(1).InsertAsync(
Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<Guid?>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<string?>(), Arg.Any<string?>(),
Arg.Is<string?>(m => m != null && !m.Contains("\"token\"") && m.Contains("\"ip\"")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task LogAsync_DoesNotThrow_WhenActorAndAttemptedUsernameAreBothNull()
{
// This is a legitimate use case (e.g. permission.denied emitted by middleware with an expired token).
var repo = Substitute.For<ISecurityEventRepository>();
var logger = Build(repo);
var act = async () => await logger.LogAsync("permission.denied", "failure");
await act.Should().NotThrowAsync();
}
}

View File

@@ -0,0 +1,101 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using SIGCM2.Infrastructure.Audit;
using SIGCM2.Infrastructure.Persistence;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 5 — SecurityEventRepository integration tests against SIGCM2_Test.
[Collection("Database")]
public sealed class SecurityEventRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private SecurityEventRepository _repo = null!;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
await _connection.ExecuteAsync("DELETE FROM dbo.SecurityEvent;");
var factory = new SqlConnectionFactory(ConnectionString);
_repo = new SecurityEventRepository(factory);
}
public async Task DisposeAsync()
{
await _connection.ExecuteAsync("DELETE FROM dbo.SecurityEvent;");
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task InsertAsync_LoginSuccess_PersistsAllFields()
{
var sessionId = Guid.NewGuid();
var occurredAt = DateTime.UtcNow;
var id = await _repo.InsertAsync(
occurredAt: occurredAt,
actorUserId: 42,
attemptedUsername: null,
sessionId: sessionId,
action: "login",
result: "success",
failureReason: null,
ipAddress: "1.2.3.4",
userAgent: "ua/1.0",
metadata: """{"route":"/login"}""");
id.Should().BeGreaterThan(0);
var row = await _connection.QuerySingleAsync<(int? ActorUserId, Guid? SessionId, string Action, string Result, string? FailureReason)>(
"SELECT ActorUserId, SessionId, Action, Result, FailureReason FROM dbo.SecurityEvent WHERE Id = @Id",
new { Id = id });
row.ActorUserId.Should().Be(42);
row.SessionId.Should().Be(sessionId);
row.Action.Should().Be("login");
row.Result.Should().Be("success");
row.FailureReason.Should().BeNull();
}
[Fact]
public async Task InsertAsync_LoginFailure_SupportsNullActorAndAttemptedUsername()
{
var id = await _repo.InsertAsync(
occurredAt: DateTime.UtcNow,
actorUserId: null,
attemptedUsername: "juan",
sessionId: null,
action: "login",
result: "failure",
failureReason: "invalid_password",
ipAddress: "1.2.3.4",
userAgent: null,
metadata: null);
id.Should().BeGreaterThan(0);
var row = await _connection.QuerySingleAsync<(int? ActorUserId, string? AttemptedUsername, string Result, string? FailureReason)>(
"SELECT ActorUserId, AttemptedUsername, Result, FailureReason FROM dbo.SecurityEvent WHERE Id = @Id",
new { Id = id });
row.ActorUserId.Should().BeNull();
row.AttemptedUsername.Should().Be("juan");
row.Result.Should().Be("failure");
row.FailureReason.Should().Be("invalid_password");
}
[Fact]
public async Task InsertAsync_InvalidResult_FailsCheckConstraint()
{
var act = async () => await _repo.InsertAsync(
DateTime.UtcNow, null, null, null, "login", "neutral", null, null, null, null);
await act.Should().ThrowAsync<SqlException>()
.Where(e => e.Message.Contains("CK_SecurityEvent_Result"));
}
}

View File

@@ -36,6 +36,11 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
]
});

View File

@@ -19,7 +19,9 @@ public class RolRepositoryTests : IAsyncLifetime
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
// Clean Usuario first (FK), then custom Rol codes created by tests.
// Clean RefreshToken first (FK to Usuario), then Usuario (FK to Rol), then custom Rol codes.
// Residual RefreshTokens from prior test suites would violate FK_RefreshToken_Usuario otherwise.
await _connection.ExecuteAsync("DELETE FROM dbo.RefreshToken;");
await _connection.ExecuteAsync("DELETE FROM dbo.Usuario;");
await _connection.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo NOT IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');");
// Ensure canonical Rol seeds exist (idempotent — previous test classes may have wiped them via Respawn).

View File

@@ -28,6 +28,11 @@ public class UsuarioRepositoryTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
]
});

View File

@@ -32,6 +32,11 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
]
});

View File

@@ -31,6 +31,11 @@ public sealed class V009MigrationTests : IAsyncLifetime
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
]
});

View File

@@ -1,5 +1,6 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -11,11 +12,12 @@ public class AssignPermisosToRolCommandHandlerTests
private readonly IRolRepository _rolRepository = Substitute.For<IRolRepository>();
private readonly IPermisoRepository _permisoRepository = Substitute.For<IPermisoRepository>();
private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For<IRolPermisoRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly AssignPermisosToRolCommandHandler _handler;
public AssignPermisosToRolCommandHandlerTests()
{
_handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository);
_handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository, _audit);
}
private static Rol MakeRol(int id, string codigo) =>

View File

@@ -1,5 +1,6 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Roles.Create;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -9,13 +10,14 @@ namespace SIGCM2.Application.Tests.Roles.Create;
public class CreateRolCommandHandlerTests
{
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly CreateRolCommandHandler _handler;
private static CreateRolCommand ValidCommand() => new("cajero_senior", "Cajero Senior", "Con más permisos");
public CreateRolCommandHandlerTests()
{
_handler = new CreateRolCommandHandler(_repository);
_handler = new CreateRolCommandHandler(_repository, _audit);
}
[Fact]

View File

@@ -1,5 +1,6 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Roles.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -9,6 +10,7 @@ namespace SIGCM2.Application.Tests.Roles.Deactivate;
public class DeactivateRolCommandHandlerTests
{
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly DeactivateRolCommandHandler _handler;
private static Rol RolActive(string codigo, int id = 10)
@@ -19,7 +21,7 @@ public class DeactivateRolCommandHandlerTests
public DeactivateRolCommandHandlerTests()
{
_handler = new DeactivateRolCommandHandler(_repository);
_handler = new DeactivateRolCommandHandler(_repository, _audit);
}
[Fact]

View File

@@ -1,5 +1,6 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Roles.Update;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -9,11 +10,12 @@ namespace SIGCM2.Application.Tests.Roles.Update;
public class UpdateRolCommandHandlerTests
{
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly UpdateRolCommandHandler _handler;
public UpdateRolCommandHandlerTests()
{
_handler = new UpdateRolCommandHandler(_repository);
_handler = new UpdateRolCommandHandler(_repository, _audit);
}
[Fact]

View File

@@ -1,6 +1,7 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Usuarios.ChangeMyPassword;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -12,11 +13,12 @@ public class ChangeMyPasswordCommandHandlerTests
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly ChangeMyPasswordCommandHandler _handler;
public ChangeMyPasswordCommandHandlerTests()
{
_handler = new ChangeMyPasswordCommandHandler(_repo, _hasher);
_handler = new ChangeMyPasswordCommandHandler(_repo, _hasher, _audit);
}
private static Usuario MakeUser(int id = 1, bool mustChangePassword = false)

View File

@@ -1,6 +1,7 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -11,6 +12,7 @@ public class CreateUsuarioCommandHandlerTests
{
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly CreateUsuarioCommandHandler _handler;
private static CreateUsuarioCommand ValidCommand() => new(
@@ -23,7 +25,7 @@ public class CreateUsuarioCommandHandlerTests
public CreateUsuarioCommandHandlerTests()
{
_handler = new CreateUsuarioCommandHandler(_repository, _hasher);
_handler = new CreateUsuarioCommandHandler(_repository, _hasher, _audit);
}
// ── exists → throws ──────────────────────────────────────────────────────

View File

@@ -1,5 +1,6 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.Deactivate;
using SIGCM2.Domain.Entities;
@@ -11,11 +12,12 @@ public class DeactivateUsuarioCommandHandlerTests
{
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly DeactivateUsuarioCommandHandler _handler;
public DeactivateUsuarioCommandHandlerTests()
{
_handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo);
_handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo, _audit);
_repo.CountActiveAdminsAsync(Arg.Any<CancellationToken>()).Returns(2);
}

View File

@@ -1,5 +1,6 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Domain.Entities;
@@ -10,11 +11,12 @@ namespace SIGCM2.Application.Tests.Usuarios;
public class ReactivateUsuarioCommandHandlerTests
{
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly ReactivateUsuarioCommandHandler _handler;
public ReactivateUsuarioCommandHandlerTests()
{
_handler = new ReactivateUsuarioCommandHandler(_repo);
_handler = new ReactivateUsuarioCommandHandler(_repo, _audit);
}
private static Usuario MakeUser(int id = 5, bool activo = false)

View File

@@ -1,6 +1,7 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Usuarios.ResetPassword;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
@@ -12,11 +13,12 @@ public class ResetUsuarioPasswordCommandHandlerTests
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly ResetUsuarioPasswordCommandHandler _handler;
public ResetUsuarioPasswordCommandHandlerTests()
{
_handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo);
_handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo, _audit);
_hasher.Hash(Arg.Any<string>()).Returns(args => "$2a$12$hashof_" + args[0]);
}

View File

@@ -2,6 +2,7 @@ using FluentValidation;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.Update;
using SIGCM2.Domain.Entities;
@@ -14,11 +15,12 @@ public class UpdateUsuarioCommandHandlerTests
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
private readonly IRolRepository _rolRepo = Substitute.For<IRolRepository>();
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly UpdateUsuarioCommandHandler _handler;
public UpdateUsuarioCommandHandlerTests()
{
_handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo);
_handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo, _audit);
// Default: rol exists and is active
_rolRepo.ExistsActiveByCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(true);

View File

@@ -32,16 +32,25 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows
await EnsureV009SchemaAsync();
// V010 (UDT-010): verify audit infrastructure + temporal tables are active.
// Applied manually via: sqlcmd ... -i database/migrations/V010__audit_infrastructure.sql
await EnsureV010SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
// Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog.
// *_History tables: UDT-010 system-versioned — Respawn cannot DELETE them directly (engine rejects).
TablesToIgnore =
[
new Respawn.Graph.Table("dbo", "Rol"),
new Respawn.Graph.Table("dbo", "Permiso"),
new Respawn.Graph.Table("dbo", "RolPermiso"),
new Respawn.Graph.Table("dbo", "Usuario_History"),
new Respawn.Graph.Table("dbo", "Rol_History"),
new Respawn.Graph.Table("dbo", "Permiso_History"),
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
]
});
@@ -232,6 +241,29 @@ public sealed class SqlTestFixture : IAsyncLifetime
await _connection.ExecuteAsync(sql);
}
/// <summary>
/// UDT-010 (V010): verifies that the audit infrastructure is present.
/// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition
/// function/scheme creation requires the full script). If missing, fails with a clear
/// message pointing to the migration script.
/// </summary>
private async Task EnsureV010SchemaAsync()
{
const string check = """
SELECT
CAST(CASE WHEN OBJECT_ID('dbo.AuditEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasAuditEvent,
CAST(CASE WHEN OBJECT_ID('dbo.SecurityEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasSecurityEvent,
CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2) THEN 1 ELSE 0 END AS BIT) AS UsuarioVersioned
""";
var result = await _connection.QuerySingleAsync<(bool HasAuditEvent, bool HasSecurityEvent, bool UsuarioVersioned)>(check);
if (!result.HasAuditEvent || !result.HasSecurityEvent || !result.UsuarioVersioned)
{
throw new InvalidOperationException(
"V010 audit infrastructure is not applied in the test database. " +
"Run: sqlcmd -S <server> -d SIGCM2_Test -U <user> -P <pass> -i database/migrations/V010__audit_infrastructure.sql");
}
}
/// <summary>
/// Applies V009 schema changes idempotently to the test database.
/// Mirrors V009__activate_permisos_overrides.sql.