UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14
@@ -15,6 +15,7 @@
|
|||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
|
||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
||||||
|
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<!-- Test dependencies -->
|
<!-- Test dependencies -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
85
database/README.md
Normal file
85
database/README.md
Normal 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}`
|
||||||
183
database/migrations/V010_ROLLBACK.sql
Normal file
183
database/migrations/V010_ROLLBACK.sql
Normal 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
|
||||||
434
database/migrations/V010__audit_infrastructure.sql
Normal file
434
database/migrations/V010__audit_infrastructure.sql
Normal 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
|
||||||
@@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
namespace SIGCM2.Api.Authorization;
|
namespace SIGCM2.Api.Authorization;
|
||||||
@@ -12,21 +13,25 @@ namespace SIGCM2.Api.Authorization;
|
|||||||
/// and IUsuarioRepository, resolves effective permissions via PermisoResolver,
|
/// and IUsuarioRepository, resolves effective permissions via PermisoResolver,
|
||||||
/// and succeeds if at least one required permission matches (OR semantics).
|
/// and succeeds if at least one required permission matches (OR semantics).
|
||||||
/// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3).
|
/// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3).
|
||||||
|
/// UDT-010: emits SecurityEvent 'permission.denied' on rejection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PermissionAuthorizationHandler
|
public sealed class PermissionAuthorizationHandler
|
||||||
: AuthorizationHandler<RequirePermissionAttribute>
|
: AuthorizationHandler<RequirePermissionAttribute>
|
||||||
{
|
{
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||||
private readonly IUsuarioRepository _usuarioRepo;
|
private readonly IUsuarioRepository _usuarioRepo;
|
||||||
|
private readonly ISecurityEventLogger _security;
|
||||||
private readonly ILogger<PermissionAuthorizationHandler> _logger;
|
private readonly ILogger<PermissionAuthorizationHandler> _logger;
|
||||||
|
|
||||||
public PermissionAuthorizationHandler(
|
public PermissionAuthorizationHandler(
|
||||||
IRolPermisoRepository rolPermisoRepo,
|
IRolPermisoRepository rolPermisoRepo,
|
||||||
IUsuarioRepository usuarioRepo,
|
IUsuarioRepository usuarioRepo,
|
||||||
|
ISecurityEventLogger security,
|
||||||
ILogger<PermissionAuthorizationHandler> logger)
|
ILogger<PermissionAuthorizationHandler> logger)
|
||||||
{
|
{
|
||||||
_rolPermisoRepo = rolPermisoRepo;
|
_rolPermisoRepo = rolPermisoRepo;
|
||||||
_usuarioRepo = usuarioRepo;
|
_usuarioRepo = usuarioRepo;
|
||||||
|
_security = security;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +88,17 @@ public sealed class PermissionAuthorizationHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Stash required permission for ForbiddenProblemDetailsHandler
|
// 8. Stash required permission for ForbiddenProblemDetailsHandler
|
||||||
|
var requiredPermission = requirement.PermissionCodes[0];
|
||||||
if (context.Resource is HttpContext httpContext)
|
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,
|
context.Fail(new AuthorizationFailureReason(this,
|
||||||
$"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));
|
$"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));
|
||||||
|
|||||||
63
src/api/SIGCM2.Api/Controllers/AuditController.cs
Normal file
63
src/api/SIGCM2.Api/Controllers/AuditController.cs
Normal 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);
|
||||||
126
src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
Normal file
126
src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs
Normal file
32
src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs
Normal file
51
src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using SIGCM2.Api.Authorization;
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Api.HealthChecks;
|
||||||
|
using SIGCM2.Api.Middleware;
|
||||||
using SIGCM2.Application;
|
using SIGCM2.Application;
|
||||||
using SIGCM2.Infrastructure;
|
using SIGCM2.Infrastructure;
|
||||||
|
using SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
using SIGCM2.Api.Filters;
|
using SIGCM2.Api.Filters;
|
||||||
|
|
||||||
// Bootstrap logger — before DI is built
|
// Bootstrap logger — before DI is built
|
||||||
@@ -23,6 +26,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
|||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
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
|
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||||
@@ -37,6 +45,10 @@ builder.Services.AddControllers(opts =>
|
|||||||
// OpenAPI / Scalar
|
// OpenAPI / Scalar
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
// UDT-010: Audit infrastructure health check
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddCheck<AuditHealthCheck>("audit", tags: new[] { "audit" });
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
var allowedOrigins = builder.Configuration
|
var allowedOrigins = builder.Configuration
|
||||||
.GetSection("Cors:AllowedOrigins")
|
.GetSection("Cors:AllowedOrigins")
|
||||||
@@ -66,10 +78,21 @@ if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseCors();
|
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();
|
app.UseAuthentication();
|
||||||
|
// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim.
|
||||||
|
app.UseMiddleware<AuditActorMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
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();
|
app.Run();
|
||||||
|
|
||||||
// Exposed for WebApplicationFactory in integration tests
|
// Exposed for WebApplicationFactory in integration tests
|
||||||
|
|||||||
15
src/api/SIGCM2.Application/Audit/AuditEventDto.cs
Normal file
15
src/api/SIGCM2.Application/Audit/AuditEventDto.cs
Normal 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);
|
||||||
13
src/api/SIGCM2.Application/Audit/AuditEventFilter.cs
Normal file
13
src/api/SIGCM2.Application/Audit/AuditEventFilter.cs
Normal 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);
|
||||||
27
src/api/SIGCM2.Application/Audit/AuditOptions.cs
Normal file
27
src/api/SIGCM2.Application/Audit/AuditOptions.cs
Normal 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",
|
||||||
|
];
|
||||||
|
}
|
||||||
14
src/api/SIGCM2.Application/Audit/IAuditContext.cs
Normal file
14
src/api/SIGCM2.Application/Audit/IAuditContext.cs
Normal 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; }
|
||||||
|
}
|
||||||
27
src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs
Normal file
27
src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
16
src/api/SIGCM2.Application/Audit/IAuditLogger.cs
Normal file
16
src/api/SIGCM2.Application/Audit/IAuditLogger.cs
Normal 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);
|
||||||
|
}
|
||||||
20
src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs
Normal file
20
src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs
Normal 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);
|
||||||
|
}
|
||||||
19
src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs
Normal file
19
src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -19,6 +20,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
private readonly IClientContext _clientContext;
|
private readonly IClientContext _clientContext;
|
||||||
private readonly AuthOptions _authOptions;
|
private readonly AuthOptions _authOptions;
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||||
|
private readonly ISecurityEventLogger _security;
|
||||||
private readonly ILogger<LoginCommandHandler> _logger;
|
private readonly ILogger<LoginCommandHandler> _logger;
|
||||||
|
|
||||||
public LoginCommandHandler(
|
public LoginCommandHandler(
|
||||||
@@ -30,6 +32,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
IClientContext clientContext,
|
IClientContext clientContext,
|
||||||
AuthOptions authOptions,
|
AuthOptions authOptions,
|
||||||
IRolPermisoRepository rolPermisoRepository,
|
IRolPermisoRepository rolPermisoRepository,
|
||||||
|
ISecurityEventLogger security,
|
||||||
ILogger<LoginCommandHandler> logger)
|
ILogger<LoginCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
@@ -40,6 +43,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
_clientContext = clientContext;
|
_clientContext = clientContext;
|
||||||
_authOptions = authOptions;
|
_authOptions = authOptions;
|
||||||
_rolPermisoRepository = rolPermisoRepository;
|
_rolPermisoRepository = rolPermisoRepository;
|
||||||
|
_security = security;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +51,30 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
{
|
{
|
||||||
var usuario = await _repository.GetByUsernameAsync(command.Username);
|
var usuario = await _repository.GetByUsernameAsync(command.Username);
|
||||||
|
|
||||||
// Deliberately vague — never reveal which check failed
|
// Deliberately vague to the client — never reveal which check failed.
|
||||||
if (usuario is null || !usuario.Activo)
|
// 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();
|
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))
|
if (!_hasher.Verify(command.Password, usuario.PasswordHash))
|
||||||
|
{
|
||||||
|
await _security.LogAsync("login", "failure",
|
||||||
|
actorUserId: usuario.Id, attemptedUsername: command.Username,
|
||||||
|
failureReason: "invalid_password");
|
||||||
throw new InvalidCredentialsException();
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
|
||||||
var accessToken = _jwtService.GenerateAccessToken(usuario);
|
var accessToken = _jwtService.GenerateAccessToken(usuario);
|
||||||
|
|
||||||
@@ -83,6 +105,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
|
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
|
||||||
var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
||||||
|
|
||||||
|
await _security.LogAsync("login", "success", actorUserId: usuario.Id);
|
||||||
|
|
||||||
return new LoginResponseDto(
|
return new LoginResponseDto(
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: rawRefresh, // raw to client — never stored
|
RefreshToken: rawRefresh, // raw to client — never stored
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Auth.Logout;
|
namespace SIGCM2.Application.Auth.Logout;
|
||||||
|
|
||||||
public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, LogoutResponseDto>
|
public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, LogoutResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IRefreshTokenRepository _refreshRepo;
|
private readonly IRefreshTokenRepository _refreshRepo;
|
||||||
|
private readonly ISecurityEventLogger _security;
|
||||||
|
|
||||||
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo)
|
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo, ISecurityEventLogger security)
|
||||||
{
|
{
|
||||||
_refreshRepo = refreshRepo;
|
_refreshRepo = refreshRepo;
|
||||||
|
_security = security;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LogoutResponseDto> Handle(LogoutCommand command)
|
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.
|
// Revoke all active tokens for the user across all families.
|
||||||
// Idempotent: 0 rows affected is not an error.
|
// Idempotent: 0 rows affected is not an error.
|
||||||
await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, DateTime.UtcNow);
|
await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, DateTime.UtcNow);
|
||||||
|
await _security.LogAsync("logout", "success", actorUserId: command.UsuarioId);
|
||||||
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
|
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
using SIGCM2.Domain.Security;
|
using SIGCM2.Domain.Security;
|
||||||
@@ -15,6 +16,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
private readonly IRefreshTokenGenerator _refreshGenerator;
|
private readonly IRefreshTokenGenerator _refreshGenerator;
|
||||||
private readonly IClientContext _clientCtx;
|
private readonly IClientContext _clientCtx;
|
||||||
private readonly AuthOptions _authOptions;
|
private readonly AuthOptions _authOptions;
|
||||||
|
private readonly ISecurityEventLogger _security;
|
||||||
|
|
||||||
public RefreshCommandHandler(
|
public RefreshCommandHandler(
|
||||||
IRefreshTokenRepository refreshRepo,
|
IRefreshTokenRepository refreshRepo,
|
||||||
@@ -22,7 +24,8 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
IJwtService jwt,
|
IJwtService jwt,
|
||||||
IRefreshTokenGenerator refreshGenerator,
|
IRefreshTokenGenerator refreshGenerator,
|
||||||
IClientContext clientCtx,
|
IClientContext clientCtx,
|
||||||
AuthOptions authOptions)
|
AuthOptions authOptions,
|
||||||
|
ISecurityEventLogger security)
|
||||||
{
|
{
|
||||||
_refreshRepo = refreshRepo;
|
_refreshRepo = refreshRepo;
|
||||||
_usuarioRepo = usuarioRepo;
|
_usuarioRepo = usuarioRepo;
|
||||||
@@ -30,6 +33,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
_refreshGenerator = refreshGenerator;
|
_refreshGenerator = refreshGenerator;
|
||||||
_clientCtx = clientCtx;
|
_clientCtx = clientCtx;
|
||||||
_authOptions = authOptions;
|
_authOptions = authOptions;
|
||||||
|
_security = security;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
|
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
|
||||||
@@ -62,23 +66,44 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
if (stored.IsRevoked)
|
if (stored.IsRevoked)
|
||||||
{
|
{
|
||||||
await _refreshRepo.RevokeFamilyAsync(stored.FamilyId, now);
|
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();
|
throw new TokenReuseDetectedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Absolute expiration check
|
// 5. Absolute expiration check
|
||||||
if (stored.IsExpired(now))
|
if (stored.IsExpired(now))
|
||||||
|
{
|
||||||
|
await _security.LogAsync("refresh.issue", "failure",
|
||||||
|
actorUserId: stored.UsuarioId, failureReason: "token_expired");
|
||||||
throw new InvalidRefreshTokenException();
|
throw new InvalidRefreshTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
// 6. UsuarioId must match access token's sub claim
|
// 6. UsuarioId must match access token's sub claim
|
||||||
if (stored.UsuarioId != accessUserId)
|
if (stored.UsuarioId != accessUserId)
|
||||||
|
{
|
||||||
|
await _security.LogAsync("refresh.issue", "failure",
|
||||||
|
actorUserId: stored.UsuarioId, failureReason: "sub_mismatch");
|
||||||
throw new InvalidRefreshTokenException();
|
throw new InvalidRefreshTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Load current user (so access token has up-to-date claims)
|
// 7. Load current user (so access token has up-to-date claims)
|
||||||
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId)
|
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId);
|
||||||
?? throw new InvalidRefreshTokenException();
|
if (usuario is null)
|
||||||
|
{
|
||||||
|
await _security.LogAsync("refresh.issue", "failure",
|
||||||
|
actorUserId: stored.UsuarioId, failureReason: "user_not_found");
|
||||||
|
throw new InvalidRefreshTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
if (!usuario.Activo)
|
if (!usuario.Activo)
|
||||||
|
{
|
||||||
|
await _security.LogAsync("refresh.issue", "failure",
|
||||||
|
actorUserId: usuario.Id, failureReason: "user_inactive");
|
||||||
throw new InvalidRefreshTokenException();
|
throw new InvalidRefreshTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
// 8. Rotate: create new token, persist, then revoke old
|
// 8. Rotate: create new token, persist, then revoke old
|
||||||
var newRaw = _refreshGenerator.Generate();
|
var newRaw = _refreshGenerator.Generate();
|
||||||
@@ -90,6 +115,9 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
|
|
||||||
// 9. Issue new access token
|
// 9. Issue new access token
|
||||||
var newAccess = _jwt.GenerateAccessToken(usuario);
|
var newAccess = _jwt.GenerateAccessToken(usuario);
|
||||||
|
|
||||||
|
await _security.LogAsync("refresh.issue", "success", actorUserId: usuario.Id);
|
||||||
|
|
||||||
return new RefreshResponseDto(newAccess, newRaw, _authOptions.AccessTokenMinutes * 60);
|
return new RefreshResponseDto(newAccess, newRaw, _authOptions.AccessTokenMinutes * 60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Permisos.Dtos;
|
using SIGCM2.Application.Permisos.Dtos;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -10,15 +12,18 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
|
|||||||
private readonly IRolRepository _rolRepository;
|
private readonly IRolRepository _rolRepository;
|
||||||
private readonly IPermisoRepository _permisoRepository;
|
private readonly IPermisoRepository _permisoRepository;
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public AssignPermisosToRolCommandHandler(
|
public AssignPermisosToRolCommandHandler(
|
||||||
IRolRepository rolRepository,
|
IRolRepository rolRepository,
|
||||||
IPermisoRepository permisoRepository,
|
IPermisoRepository permisoRepository,
|
||||||
IRolPermisoRepository rolPermisoRepository)
|
IRolPermisoRepository rolPermisoRepository,
|
||||||
|
IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_rolRepository = rolRepository;
|
_rolRepository = rolRepository;
|
||||||
_permisoRepository = permisoRepository;
|
_permisoRepository = permisoRepository;
|
||||||
_rolPermisoRepository = rolPermisoRepository;
|
_rolPermisoRepository = rolPermisoRepository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<PermisoDto>> Handle(AssignPermisosToRolCommand command)
|
public async Task<IReadOnlyList<PermisoDto>> Handle(AssignPermisosToRolCommand command)
|
||||||
@@ -40,9 +45,28 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
|
|||||||
throw new PermisoNotFoundException(missing);
|
throw new PermisoNotFoundException(missing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Reemplazar el set (DELETE+INSERT en transacción dentro del repo)
|
// Capture "before" snapshot for audit diff
|
||||||
var permisoIds = permisos.Select(p => p.Id);
|
var previousPermisos = await _rolPermisoRepository.GetByRolCodigoAsync(rol.Codigo);
|
||||||
await _rolPermisoRepository.ReplaceForRolAsync(rol.Id, permisoIds);
|
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
|
// 4. Retornar el nuevo set asignado
|
||||||
return permisos
|
return permisos
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Roles.Dtos;
|
using SIGCM2.Application.Roles.Dtos;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -9,10 +11,12 @@ namespace SIGCM2.Application.Roles.Create;
|
|||||||
public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand, RolCreatedDto>
|
public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand, RolCreatedDto>
|
||||||
{
|
{
|
||||||
private readonly IRolRepository _repository;
|
private readonly IRolRepository _repository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public CreateRolCommandHandler(IRolRepository repository)
|
public CreateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RolCreatedDto> Handle(CreateRolCommand command)
|
public async Task<RolCreatedDto> Handle(CreateRolCommand command)
|
||||||
@@ -24,7 +28,23 @@ public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand,
|
|||||||
throw new RolAlreadyExistsException(command.Codigo);
|
throw new RolAlreadyExistsException(command.Codigo);
|
||||||
|
|
||||||
var rol = Rol.ForCreation(command.Codigo, command.Nombre, command.Descripcion);
|
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(
|
return new RolCreatedDto(
|
||||||
Id: newId,
|
Id: newId,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Roles.Dtos;
|
using SIGCM2.Application.Roles.Dtos;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -8,10 +10,12 @@ namespace SIGCM2.Application.Roles.Deactivate;
|
|||||||
public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolCommand, RolDto>
|
public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolCommand, RolDto>
|
||||||
{
|
{
|
||||||
private readonly IRolRepository _repository;
|
private readonly IRolRepository _repository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public DeactivateRolCommandHandler(IRolRepository repository)
|
public DeactivateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RolDto> Handle(DeactivateRolCommand command)
|
public async Task<RolDto> Handle(DeactivateRolCommand command)
|
||||||
@@ -23,10 +27,23 @@ public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolC
|
|||||||
if (await _repository.HasActiveUsuariosAsync(command.Codigo))
|
if (await _repository.HasActiveUsuariosAsync(command.Codigo))
|
||||||
throw new RolInUseException(command.Codigo);
|
throw new RolInUseException(command.Codigo);
|
||||||
|
|
||||||
var updated = await _repository.UpdateAsync(
|
using (var tx = new TransactionScope(
|
||||||
existing.Codigo, existing.Nombre, existing.Descripcion, activo: false);
|
TransactionScopeOption.Required,
|
||||||
if (!updated)
|
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||||
throw new RolNotFoundException(command.Codigo);
|
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)
|
var rol = await _repository.GetByCodigoAsync(command.Codigo)
|
||||||
?? throw new RolNotFoundException(command.Codigo);
|
?? throw new RolNotFoundException(command.Codigo);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Roles.Dtos;
|
using SIGCM2.Application.Roles.Dtos;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -8,19 +10,42 @@ namespace SIGCM2.Application.Roles.Update;
|
|||||||
public sealed class UpdateRolCommandHandler : ICommandHandler<UpdateRolCommand, RolDto>
|
public sealed class UpdateRolCommandHandler : ICommandHandler<UpdateRolCommand, RolDto>
|
||||||
{
|
{
|
||||||
private readonly IRolRepository _repository;
|
private readonly IRolRepository _repository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public UpdateRolCommandHandler(IRolRepository repository)
|
public UpdateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RolDto> Handle(UpdateRolCommand command)
|
public async Task<RolDto> Handle(UpdateRolCommand command)
|
||||||
{
|
{
|
||||||
var updated = await _repository.UpdateAsync(
|
var before = await _repository.GetByCodigoAsync(command.Codigo)
|
||||||
command.Codigo, command.Nombre, command.Descripcion, command.Activo);
|
?? throw new RolNotFoundException(command.Codigo);
|
||||||
|
|
||||||
if (!updated)
|
using (var tx = new TransactionScope(
|
||||||
throw new RolNotFoundException(command.Codigo);
|
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)
|
var rol = await _repository.GetByCodigoAsync(command.Codigo)
|
||||||
?? throw new RolNotFoundException(command.Codigo);
|
?? throw new RolNotFoundException(command.Codigo);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -10,13 +12,16 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPas
|
|||||||
{
|
{
|
||||||
private readonly IUsuarioRepository _repository;
|
private readonly IUsuarioRepository _repository;
|
||||||
private readonly IPasswordHasher _hasher;
|
private readonly IPasswordHasher _hasher;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public ChangeMyPasswordCommandHandler(
|
public ChangeMyPasswordCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
IPasswordHasher hasher)
|
IPasswordHasher hasher,
|
||||||
|
IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Unit> Handle(ChangeMyPasswordCommand cmd)
|
public async Task<Unit> Handle(ChangeMyPasswordCommand cmd)
|
||||||
@@ -28,9 +33,21 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPas
|
|||||||
throw new InvalidOldPasswordException();
|
throw new InvalidOldPasswordException();
|
||||||
|
|
||||||
var newHash = _hasher.Hash(cmd.NewPassword);
|
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);
|
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)
|
// NOTE: intentionally does NOT revoke own refresh tokens (spec REQ-BCP-05)
|
||||||
return Unit.Value;
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -10,13 +12,16 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler<CreateUsuarioC
|
|||||||
{
|
{
|
||||||
private readonly IUsuarioRepository _repository;
|
private readonly IUsuarioRepository _repository;
|
||||||
private readonly IPasswordHasher _hasher;
|
private readonly IPasswordHasher _hasher;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public CreateUsuarioCommandHandler(
|
public CreateUsuarioCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
IPasswordHasher hasher)
|
IPasswordHasher hasher,
|
||||||
|
IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UsuarioCreatedDto> Handle(CreateUsuarioCommand command)
|
public async Task<UsuarioCreatedDto> Handle(CreateUsuarioCommand command)
|
||||||
@@ -37,9 +42,32 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler<CreateUsuarioC
|
|||||||
email: command.Email,
|
email: command.Email,
|
||||||
rol: command.Rol);
|
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);
|
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(
|
return new UsuarioCreatedDto(
|
||||||
Id: newId,
|
Id: newId,
|
||||||
Username: usuario.Username,
|
Username: usuario.Username,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Usuarios.GetById;
|
using SIGCM2.Application.Usuarios.GetById;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -10,13 +12,16 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler<Deactivate
|
|||||||
{
|
{
|
||||||
private readonly IUsuarioRepository _repository;
|
private readonly IUsuarioRepository _repository;
|
||||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public DeactivateUsuarioCommandHandler(
|
public DeactivateUsuarioCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
IRefreshTokenRepository refreshTokenRepository)
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
|
IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_refreshTokenRepository = refreshTokenRepository;
|
_refreshTokenRepository = refreshTokenRepository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UsuarioDetailDto> Handle(DeactivateUsuarioCommand cmd)
|
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 fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, false);
|
||||||
var now = DateTime.UtcNow;
|
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)
|
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
|
||||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -15,15 +17,18 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler
|
|||||||
private readonly IUsuarioRepository _usuarioRepo;
|
private readonly IUsuarioRepository _usuarioRepo;
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||||
private readonly IPermisoRepository _permisoRepo;
|
private readonly IPermisoRepository _permisoRepo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public UpdateUsuarioPermisosOverridesCommandHandler(
|
public UpdateUsuarioPermisosOverridesCommandHandler(
|
||||||
IUsuarioRepository usuarioRepo,
|
IUsuarioRepository usuarioRepo,
|
||||||
IRolPermisoRepository rolPermisoRepo,
|
IRolPermisoRepository rolPermisoRepo,
|
||||||
IPermisoRepository permisoRepo)
|
IPermisoRepository permisoRepo,
|
||||||
|
IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_usuarioRepo = usuarioRepo;
|
_usuarioRepo = usuarioRepo;
|
||||||
_rolPermisoRepo = rolPermisoRepo;
|
_rolPermisoRepo = rolPermisoRepo;
|
||||||
_permisoRepo = permisoRepo;
|
_permisoRepo = permisoRepo;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UsuarioPermisosDto> Handle(UpdateUsuarioPermisosOverridesCommand command)
|
public async Task<UsuarioPermisosDto> Handle(UpdateUsuarioPermisosOverridesCommand command)
|
||||||
@@ -53,11 +58,31 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler
|
|||||||
|
|
||||||
// 4. Persist — use WithPermisosJson to get updated FechaModificacion
|
// 4. Persist — use WithPermisosJson to get updated FechaModificacion
|
||||||
var newOverrides = new PermisosOverride(grant, deny);
|
var newOverrides = new PermisosOverride(grant, deny);
|
||||||
|
var previousOverrides = PermisosOverride.FromJson(usuario.PermisosJson);
|
||||||
var updated = usuario.WithPermisosJson(newOverrides.ToJson());
|
var updated = usuario.WithPermisosJson(newOverrides.ToJson());
|
||||||
await _usuarioRepo.UpdatePermisosJsonAsync(
|
|
||||||
updated.Id,
|
using (var tx = new TransactionScope(
|
||||||
updated.PermisosJson,
|
TransactionScopeOption.Required,
|
||||||
updated.FechaModificacion!.Value);
|
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
|
// 5. Return updated effective set
|
||||||
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(updated.Rol);
|
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(updated.Rol);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Usuarios.GetById;
|
using SIGCM2.Application.Usuarios.GetById;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -9,10 +11,12 @@ namespace SIGCM2.Application.Usuarios.Reactivate;
|
|||||||
public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>
|
public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>
|
||||||
{
|
{
|
||||||
private readonly IUsuarioRepository _repository;
|
private readonly IUsuarioRepository _repository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository)
|
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository, IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UsuarioDetailDto> Handle(ReactivateUsuarioCommand cmd)
|
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 fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, true);
|
||||||
var now = DateTime.UtcNow;
|
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)
|
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
|
||||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
@@ -11,15 +13,18 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUs
|
|||||||
private readonly IUsuarioRepository _repository;
|
private readonly IUsuarioRepository _repository;
|
||||||
private readonly IPasswordHasher _hasher;
|
private readonly IPasswordHasher _hasher;
|
||||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public ResetUsuarioPasswordCommandHandler(
|
public ResetUsuarioPasswordCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
IPasswordHasher hasher,
|
IPasswordHasher hasher,
|
||||||
IRefreshTokenRepository refreshTokenRepository)
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
|
IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
_refreshTokenRepository = refreshTokenRepository;
|
_refreshTokenRepository = refreshTokenRepository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ResetUsuarioPasswordResponse> Handle(ResetUsuarioPasswordCommand cmd)
|
public async Task<ResetUsuarioPasswordResponse> Handle(ResetUsuarioPasswordCommand cmd)
|
||||||
@@ -32,13 +37,25 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUs
|
|||||||
?? throw new UsuarioNotFoundException(cmd.TargetId);
|
?? throw new UsuarioNotFoundException(cmd.TargetId);
|
||||||
|
|
||||||
var temp = TempPasswordGenerator.Generate(12);
|
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);
|
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 _repository.UpdatePasswordAsync(cmd.TargetId, hash, mustChangePassword: true);
|
||||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.TargetId, DateTime.UtcNow);
|
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);
|
return new ResetUsuarioPasswordResponse(temp, MustChangeOnLogin: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Transactions;
|
||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Usuarios.GetById;
|
using SIGCM2.Application.Usuarios.GetById;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -11,15 +13,18 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler<UpdateUsuarioC
|
|||||||
private readonly IUsuarioRepository _repository;
|
private readonly IUsuarioRepository _repository;
|
||||||
private readonly IRolRepository _rolRepository;
|
private readonly IRolRepository _rolRepository;
|
||||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
public UpdateUsuarioCommandHandler(
|
public UpdateUsuarioCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
IRolRepository rolRepository,
|
IRolRepository rolRepository,
|
||||||
IRefreshTokenRepository refreshTokenRepository)
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
|
IAuditLogger audit)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_rolRepository = rolRepository;
|
_rolRepository = rolRepository;
|
||||||
_refreshTokenRepository = refreshTokenRepository;
|
_refreshTokenRepository = refreshTokenRepository;
|
||||||
|
_audit = audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UsuarioDetailDto> Handle(UpdateUsuarioCommand cmd)
|
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 fields = new UpdateUsuarioFields(cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo);
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
await _repository.UpdateAsync(cmd.Id, fields, now);
|
|
||||||
|
|
||||||
// Revoke refresh tokens if rol changed or user deactivated
|
using (var tx = new TransactionScope(
|
||||||
var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal);
|
TransactionScopeOption.Required,
|
||||||
var justDeactivated = target.Activo && !cmd.Activo;
|
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||||
if (rolChanged || justDeactivated)
|
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)
|
var updated = await _repository.GetDetailAsync(cmd.Id)
|
||||||
?? throw new UsuarioNotFoundException(cmd.Id);
|
?? throw new UsuarioNotFoundException(cmd.Id);
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
{ }
|
||||||
|
}
|
||||||
38
src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs
Normal file
38
src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs
Normal 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;
|
||||||
|
}
|
||||||
45
src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs
Normal file
45
src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs
Normal file
139
src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs
Normal file
57
src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs
Normal file
63
src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs
Normal file
52
src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Auth;
|
using SIGCM2.Application.Auth;
|
||||||
using SIGCM2.Infrastructure.Http;
|
using SIGCM2.Infrastructure.Http;
|
||||||
using SIGCM2.Infrastructure.Messaging;
|
using SIGCM2.Infrastructure.Messaging;
|
||||||
@@ -68,6 +69,14 @@ public static class DependencyInjection
|
|||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddScoped<IClientContext, ClientContext>();
|
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
|
// Dispatcher
|
||||||
services.AddScoped<IDispatcher, Dispatcher>();
|
services.AddScoped<IDispatcher, Dispatcher>();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
71
src/web/src/api/audit.ts
Normal file
71
src/web/src/api/audit.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
FileClock,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -23,6 +24,8 @@ interface NavItem {
|
|||||||
href: string
|
href: string
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
/** Si se define, el item solo se muestra si el user tiene este permiso. */
|
||||||
|
requiredPermission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
@@ -38,6 +41,12 @@ const adminItems: NavItem[] = [
|
|||||||
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
||||||
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||||
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
|
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
|
||||||
|
{
|
||||||
|
label: 'Auditoría',
|
||||||
|
href: '/admin/audit',
|
||||||
|
icon: FileClock,
|
||||||
|
requiredPermission: 'administracion:auditoria:ver',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
@@ -120,14 +129,20 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
||||||
{adminItems.map((item) => (
|
{adminItems
|
||||||
<NavRow
|
.filter(
|
||||||
key={item.href}
|
(item) =>
|
||||||
item={item}
|
!item.requiredPermission ||
|
||||||
collapsed={collapsed}
|
user?.permisos.includes(item.requiredPermission),
|
||||||
active={isItemActive(item)}
|
)
|
||||||
/>
|
.map((item) => (
|
||||||
))}
|
<NavRow
|
||||||
|
key={item.href}
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={isItemActive(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
25
src/web/src/features/admin/audit/useAuditEvents.ts
Normal file
25
src/web/src/features/admin/audit/useAuditEvents.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
160
src/web/src/pages/admin/audit/AuditFilters.tsx
Normal file
160
src/web/src/pages/admin/audit/AuditFilters.tsx
Normal 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
|
||||||
|
}
|
||||||
285
src/web/src/pages/admin/audit/AuditPage.tsx
Normal file
285
src/web/src/pages/admin/audit/AuditPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { RolesPage } from './features/roles/pages/RolesPage'
|
|||||||
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
||||||
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||||
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
||||||
|
import { AuditPage } from './pages/admin/audit/AuditPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
254
src/web/src/tests/features/admin/audit/AuditPage.test.tsx
Normal file
254
src/web/src/tests/features/admin/audit/AuditPage.test.tsx
Normal 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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
137
src/web/src/tests/features/admin/audit/useAuditEvents.test.ts
Normal file
137
src/web/src/tests/features/admin/audit/useAuditEvents.test.ts
Normal 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=')
|
||||||
|
})
|
||||||
|
})
|
||||||
87
tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs
Normal file
87
tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
126
tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs
Normal file
126
tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs
Normal file
30
tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
100
tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs
Normal file
100
tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs
Normal file
59
tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
150
tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs
Normal file
150
tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Api.Authorization;
|
using SIGCM2.Api.Authorization;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
namespace SIGCM2.Api.Tests.Authorization;
|
namespace SIGCM2.Api.Tests.Authorization;
|
||||||
@@ -18,6 +19,7 @@ public sealed class PermissionAuthorizationHandlerTests
|
|||||||
{
|
{
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
||||||
private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
|
private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
|
||||||
|
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
|
||||||
private readonly PermissionAuthorizationHandler _handler;
|
private readonly PermissionAuthorizationHandler _handler;
|
||||||
|
|
||||||
public PermissionAuthorizationHandlerTests()
|
public PermissionAuthorizationHandlerTests()
|
||||||
@@ -29,6 +31,7 @@ public sealed class PermissionAuthorizationHandlerTests
|
|||||||
_handler = new PermissionAuthorizationHandler(
|
_handler = new PermissionAuthorizationHandler(
|
||||||
_rolPermisoRepo,
|
_rolPermisoRepo,
|
||||||
_usuarioRepo,
|
_usuarioRepo,
|
||||||
|
_security,
|
||||||
NullLogger<PermissionAuthorizationHandler>.Instance);
|
NullLogger<PermissionAuthorizationHandler>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
179
tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs
Normal file
179
tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using NSubstitute;
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Auth;
|
using SIGCM2.Application.Auth;
|
||||||
using SIGCM2.Application.Auth.Login;
|
using SIGCM2.Application.Auth.Login;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
@@ -20,6 +21,7 @@ public class LoginCommandHandlerTests
|
|||||||
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
|
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
|
||||||
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
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 ILogger<LoginCommandHandler> _logger = Substitute.For<ILogger<LoginCommandHandler>>();
|
||||||
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
|
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
|
||||||
private readonly LoginCommandHandler _handler;
|
private readonly LoginCommandHandler _handler;
|
||||||
@@ -42,7 +44,7 @@ public class LoginCommandHandlerTests
|
|||||||
_handler = new LoginCommandHandler(
|
_handler = new LoginCommandHandler(
|
||||||
_repository, _hasher, _jwtService,
|
_repository, _hasher, _jwtService,
|
||||||
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
|
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
|
||||||
_rolPermisoRepo, _logger);
|
_rolPermisoRepo, _security, _logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario: valid credentials → returns token response with usuario populated
|
// Scenario: valid credentials → returns token response with usuario populated
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Auth.Logout;
|
using SIGCM2.Application.Auth.Logout;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Tests.Auth.Logout;
|
namespace SIGCM2.Application.Tests.Auth.Logout;
|
||||||
@@ -7,11 +8,12 @@ namespace SIGCM2.Application.Tests.Auth.Logout;
|
|||||||
public class LogoutCommandHandlerTests
|
public class LogoutCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||||
|
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
|
||||||
private readonly LogoutCommandHandler _handler;
|
private readonly LogoutCommandHandler _handler;
|
||||||
|
|
||||||
public LogoutCommandHandlerTests()
|
public LogoutCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new LogoutCommandHandler(_refreshRepo);
|
_handler = new LogoutCommandHandler(_refreshRepo, _security);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using NSubstitute.ExceptionExtensions;
|
|||||||
using SIGCM2.Application.Abstractions;
|
using SIGCM2.Application.Abstractions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Auth;
|
using SIGCM2.Application.Auth;
|
||||||
using SIGCM2.Application.Auth.Refresh;
|
using SIGCM2.Application.Auth.Refresh;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
@@ -19,6 +20,7 @@ public class RefreshCommandHandlerTests
|
|||||||
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
|
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
|
||||||
private readonly IRefreshTokenGenerator _generator = Substitute.For<IRefreshTokenGenerator>();
|
private readonly IRefreshTokenGenerator _generator = Substitute.For<IRefreshTokenGenerator>();
|
||||||
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
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 AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
|
||||||
private readonly RefreshCommandHandler _handler;
|
private readonly RefreshCommandHandler _handler;
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ public class RefreshCommandHandlerTests
|
|||||||
_generator.Generate().Returns("new_raw_token_value_xyz");
|
_generator.Generate().Returns("new_raw_token_value_xyz");
|
||||||
|
|
||||||
_handler = new RefreshCommandHandler(
|
_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
|
// Helper: build an active stored RefreshToken with a matching principal
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,11 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "Rol"),
|
new Respawn.Graph.Table("dbo", "Rol"),
|
||||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
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"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ public class RolRepositoryTests : IAsyncLifetime
|
|||||||
_connection = new SqlConnection(ConnectionString);
|
_connection = new SqlConnection(ConnectionString);
|
||||||
await _connection.OpenAsync();
|
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.Usuario;");
|
||||||
await _connection.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo NOT IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');");
|
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).
|
// Ensure canonical Rol seeds exist (idempotent — previous test classes may have wiped them via Respawn).
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ public class UsuarioRepositoryTests : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "Rol"),
|
new Respawn.Graph.Table("dbo", "Rol"),
|
||||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
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"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "Rol"),
|
new Respawn.Graph.Table("dbo", "Rol"),
|
||||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
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"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public sealed class V009MigrationTests : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "Rol"),
|
new Respawn.Graph.Table("dbo", "Rol"),
|
||||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
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"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Permisos.Assign;
|
using SIGCM2.Application.Permisos.Assign;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -11,11 +12,12 @@ public class AssignPermisosToRolCommandHandlerTests
|
|||||||
private readonly IRolRepository _rolRepository = Substitute.For<IRolRepository>();
|
private readonly IRolRepository _rolRepository = Substitute.For<IRolRepository>();
|
||||||
private readonly IPermisoRepository _permisoRepository = Substitute.For<IPermisoRepository>();
|
private readonly IPermisoRepository _permisoRepository = Substitute.For<IPermisoRepository>();
|
||||||
private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For<IRolPermisoRepository>();
|
private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For<IRolPermisoRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly AssignPermisosToRolCommandHandler _handler;
|
private readonly AssignPermisosToRolCommandHandler _handler;
|
||||||
|
|
||||||
public AssignPermisosToRolCommandHandlerTests()
|
public AssignPermisosToRolCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository);
|
_handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository, _audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Rol MakeRol(int id, string codigo) =>
|
private static Rol MakeRol(int id, string codigo) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Roles.Create;
|
using SIGCM2.Application.Roles.Create;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -9,13 +10,14 @@ namespace SIGCM2.Application.Tests.Roles.Create;
|
|||||||
public class CreateRolCommandHandlerTests
|
public class CreateRolCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly CreateRolCommandHandler _handler;
|
private readonly CreateRolCommandHandler _handler;
|
||||||
|
|
||||||
private static CreateRolCommand ValidCommand() => new("cajero_senior", "Cajero Senior", "Con más permisos");
|
private static CreateRolCommand ValidCommand() => new("cajero_senior", "Cajero Senior", "Con más permisos");
|
||||||
|
|
||||||
public CreateRolCommandHandlerTests()
|
public CreateRolCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new CreateRolCommandHandler(_repository);
|
_handler = new CreateRolCommandHandler(_repository, _audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Roles.Deactivate;
|
using SIGCM2.Application.Roles.Deactivate;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -9,6 +10,7 @@ namespace SIGCM2.Application.Tests.Roles.Deactivate;
|
|||||||
public class DeactivateRolCommandHandlerTests
|
public class DeactivateRolCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly DeactivateRolCommandHandler _handler;
|
private readonly DeactivateRolCommandHandler _handler;
|
||||||
|
|
||||||
private static Rol RolActive(string codigo, int id = 10)
|
private static Rol RolActive(string codigo, int id = 10)
|
||||||
@@ -19,7 +21,7 @@ public class DeactivateRolCommandHandlerTests
|
|||||||
|
|
||||||
public DeactivateRolCommandHandlerTests()
|
public DeactivateRolCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new DeactivateRolCommandHandler(_repository);
|
_handler = new DeactivateRolCommandHandler(_repository, _audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Roles.Update;
|
using SIGCM2.Application.Roles.Update;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -9,11 +10,12 @@ namespace SIGCM2.Application.Tests.Roles.Update;
|
|||||||
public class UpdateRolCommandHandlerTests
|
public class UpdateRolCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly UpdateRolCommandHandler _handler;
|
private readonly UpdateRolCommandHandler _handler;
|
||||||
|
|
||||||
public UpdateRolCommandHandlerTests()
|
public UpdateRolCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new UpdateRolCommandHandler(_repository);
|
_handler = new UpdateRolCommandHandler(_repository, _audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -12,11 +13,12 @@ public class ChangeMyPasswordCommandHandlerTests
|
|||||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly ChangeMyPasswordCommandHandler _handler;
|
private readonly ChangeMyPasswordCommandHandler _handler;
|
||||||
|
|
||||||
public ChangeMyPasswordCommandHandlerTests()
|
public ChangeMyPasswordCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new ChangeMyPasswordCommandHandler(_repo, _hasher);
|
_handler = new ChangeMyPasswordCommandHandler(_repo, _hasher, _audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Usuario MakeUser(int id = 1, bool mustChangePassword = false)
|
private static Usuario MakeUser(int id = 1, bool mustChangePassword = false)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Usuarios.Create;
|
using SIGCM2.Application.Usuarios.Create;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -11,6 +12,7 @@ public class CreateUsuarioCommandHandlerTests
|
|||||||
{
|
{
|
||||||
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
|
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
|
||||||
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly CreateUsuarioCommandHandler _handler;
|
private readonly CreateUsuarioCommandHandler _handler;
|
||||||
|
|
||||||
private static CreateUsuarioCommand ValidCommand() => new(
|
private static CreateUsuarioCommand ValidCommand() => new(
|
||||||
@@ -23,7 +25,7 @@ public class CreateUsuarioCommandHandlerTests
|
|||||||
|
|
||||||
public CreateUsuarioCommandHandlerTests()
|
public CreateUsuarioCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new CreateUsuarioCommandHandler(_repository, _hasher);
|
_handler = new CreateUsuarioCommandHandler(_repository, _hasher, _audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── exists → throws ──────────────────────────────────────────────────────
|
// ── exists → throws ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Usuarios.Deactivate;
|
using SIGCM2.Application.Usuarios.Deactivate;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
@@ -11,11 +12,12 @@ public class DeactivateUsuarioCommandHandlerTests
|
|||||||
{
|
{
|
||||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly DeactivateUsuarioCommandHandler _handler;
|
private readonly DeactivateUsuarioCommandHandler _handler;
|
||||||
|
|
||||||
public DeactivateUsuarioCommandHandlerTests()
|
public DeactivateUsuarioCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo);
|
_handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo, _audit);
|
||||||
_repo.CountActiveAdminsAsync(Arg.Any<CancellationToken>()).Returns(2);
|
_repo.CountActiveAdminsAsync(Arg.Any<CancellationToken>()).Returns(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Usuarios.Reactivate;
|
using SIGCM2.Application.Usuarios.Reactivate;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
@@ -10,11 +11,12 @@ namespace SIGCM2.Application.Tests.Usuarios;
|
|||||||
public class ReactivateUsuarioCommandHandlerTests
|
public class ReactivateUsuarioCommandHandlerTests
|
||||||
{
|
{
|
||||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly ReactivateUsuarioCommandHandler _handler;
|
private readonly ReactivateUsuarioCommandHandler _handler;
|
||||||
|
|
||||||
public ReactivateUsuarioCommandHandlerTests()
|
public ReactivateUsuarioCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new ReactivateUsuarioCommandHandler(_repo);
|
_handler = new ReactivateUsuarioCommandHandler(_repo, _audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Usuario MakeUser(int id = 5, bool activo = false)
|
private static Usuario MakeUser(int id = 5, bool activo = false)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Abstractions.Security;
|
using SIGCM2.Application.Abstractions.Security;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
@@ -12,11 +13,12 @@ public class ResetUsuarioPasswordCommandHandlerTests
|
|||||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly ResetUsuarioPasswordCommandHandler _handler;
|
private readonly ResetUsuarioPasswordCommandHandler _handler;
|
||||||
|
|
||||||
public ResetUsuarioPasswordCommandHandlerTests()
|
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]);
|
_hasher.Hash(Arg.Any<string>()).Returns(args => "$2a$12$hashof_" + args[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
using SIGCM2.Application.Usuarios.Update;
|
using SIGCM2.Application.Usuarios.Update;
|
||||||
using SIGCM2.Domain.Entities;
|
using SIGCM2.Domain.Entities;
|
||||||
@@ -14,11 +15,12 @@ public class UpdateUsuarioCommandHandlerTests
|
|||||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||||
private readonly IRolRepository _rolRepo = Substitute.For<IRolRepository>();
|
private readonly IRolRepository _rolRepo = Substitute.For<IRolRepository>();
|
||||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
private readonly UpdateUsuarioCommandHandler _handler;
|
private readonly UpdateUsuarioCommandHandler _handler;
|
||||||
|
|
||||||
public UpdateUsuarioCommandHandlerTests()
|
public UpdateUsuarioCommandHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo);
|
_handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo, _audit);
|
||||||
|
|
||||||
// Default: rol exists and is active
|
// Default: rol exists and is active
|
||||||
_rolRepo.ExistsActiveByCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(true);
|
_rolRepo.ExistsActiveByCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|||||||
@@ -32,16 +32,25 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows
|
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows
|
||||||
await EnsureV009SchemaAsync();
|
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
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||||
{
|
{
|
||||||
DbAdapter = DbAdapter.SqlServer,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
||||||
// Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog.
|
// 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 =
|
TablesToIgnore =
|
||||||
[
|
[
|
||||||
new Respawn.Graph.Table("dbo", "Rol"),
|
new Respawn.Graph.Table("dbo", "Rol"),
|
||||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
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);
|
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>
|
/// <summary>
|
||||||
/// Applies V009 schema changes idempotently to the test database.
|
/// Applies V009 schema changes idempotently to the test database.
|
||||||
/// Mirrors V009__activate_permisos_overrides.sql.
|
/// Mirrors V009__activate_permisos_overrides.sql.
|
||||||
|
|||||||
Reference in New Issue
Block a user