Compare commits
15 Commits
d201d9e08e
...
7c0646be0d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c0646be0d | |||
| 9eac044752 | |||
| b526df2125 | |||
| 2bb90118ab | |||
| b619c05762 | |||
| a3f01bc6c9 | |||
| 26efb74c22 | |||
| a3d6214d09 | |||
| 300badda73 | |||
| 0b4af4c332 | |||
| 08d6622e43 | |||
| 68f96b90c7 | |||
| c95bc7fe01 | |||
| 1c79dfa0a4 | |||
| 2d1d187f6e |
@@ -15,6 +15,7 @@
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
||||
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||
</ItemGroup>
|
||||
<!-- Test dependencies -->
|
||||
<ItemGroup>
|
||||
|
||||
85
database/README.md
Normal file
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.Http;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
@@ -12,21 +13,25 @@ namespace SIGCM2.Api.Authorization;
|
||||
/// and IUsuarioRepository, resolves effective permissions via PermisoResolver,
|
||||
/// and succeeds if at least one required permission matches (OR semantics).
|
||||
/// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3).
|
||||
/// UDT-010: emits SecurityEvent 'permission.denied' on rejection.
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationHandler
|
||||
: AuthorizationHandler<RequirePermissionAttribute>
|
||||
{
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||
private readonly IUsuarioRepository _usuarioRepo;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
private readonly ILogger<PermissionAuthorizationHandler> _logger;
|
||||
|
||||
public PermissionAuthorizationHandler(
|
||||
IRolPermisoRepository rolPermisoRepo,
|
||||
IUsuarioRepository usuarioRepo,
|
||||
ISecurityEventLogger security,
|
||||
ILogger<PermissionAuthorizationHandler> logger)
|
||||
{
|
||||
_rolPermisoRepo = rolPermisoRepo;
|
||||
_usuarioRepo = usuarioRepo;
|
||||
_security = security;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -83,8 +88,17 @@ public sealed class PermissionAuthorizationHandler
|
||||
}
|
||||
|
||||
// 8. Stash required permission for ForbiddenProblemDetailsHandler
|
||||
var requiredPermission = requirement.PermissionCodes[0];
|
||||
if (context.Resource is HttpContext httpContext)
|
||||
httpContext.Items["RequiredPermission"] = requirement.PermissionCodes[0];
|
||||
httpContext.Items["RequiredPermission"] = requiredPermission;
|
||||
|
||||
// 9. Emit SecurityEvent for the denial
|
||||
var endpoint = (context.Resource as HttpContext)?.Request?.Path.Value;
|
||||
var method = (context.Resource as HttpContext)?.Request?.Method;
|
||||
await _security.LogAsync("permission.denied", "failure",
|
||||
actorUserId: userId,
|
||||
failureReason: $"missing_permission:{requiredPermission}",
|
||||
metadata: new { permissionRequired = requiredPermission, endpoint, method });
|
||||
|
||||
context.Fail(new AuthorizationFailureReason(this,
|
||||
$"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));
|
||||
|
||||
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 Scalar.AspNetCore;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Api.HealthChecks;
|
||||
using SIGCM2.Api.Middleware;
|
||||
using SIGCM2.Application;
|
||||
using SIGCM2.Infrastructure;
|
||||
using SIGCM2.Infrastructure.Audit.Jobs;
|
||||
using SIGCM2.Api.Filters;
|
||||
|
||||
// Bootstrap logger — before DI is built
|
||||
@@ -23,6 +26,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// UDT-010: Quartz.NET + 3 audit maintenance jobs (partition, retention, integrity).
|
||||
// Disabled in Testing environment to keep integration tests deterministic.
|
||||
if (!builder.Environment.IsEnvironment("Testing"))
|
||||
builder.Services.AddAuditMaintenance(builder.Configuration);
|
||||
|
||||
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
@@ -37,6 +45,10 @@ builder.Services.AddControllers(opts =>
|
||||
// OpenAPI / Scalar
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// UDT-010: Audit infrastructure health check
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<AuditHealthCheck>("audit", tags: new[] { "audit" });
|
||||
|
||||
// CORS
|
||||
var allowedOrigins = builder.Configuration
|
||||
.GetSection("Cors:AllowedOrigins")
|
||||
@@ -66,10 +78,21 @@ if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors();
|
||||
// UDT-010: correlation id + ip/ua capture runs BEFORE auth so anonymous requests
|
||||
// still get a correlation id and so logs can tie pre-auth events to the request.
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseAuthentication();
|
||||
// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim.
|
||||
app.UseMiddleware<AuditActorMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
// UDT-010: /health/audit returns the audit check status (public but sparse data).
|
||||
app.MapHealthChecks("/health/audit", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("audit"),
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
// Exposed for WebApplicationFactory in integration tests
|
||||
|
||||
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.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -19,6 +20,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
private readonly IClientContext _clientContext;
|
||||
private readonly AuthOptions _authOptions;
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
private readonly ILogger<LoginCommandHandler> _logger;
|
||||
|
||||
public LoginCommandHandler(
|
||||
@@ -30,6 +32,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
IClientContext clientContext,
|
||||
AuthOptions authOptions,
|
||||
IRolPermisoRepository rolPermisoRepository,
|
||||
ISecurityEventLogger security,
|
||||
ILogger<LoginCommandHandler> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
@@ -40,6 +43,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
_clientContext = clientContext;
|
||||
_authOptions = authOptions;
|
||||
_rolPermisoRepository = rolPermisoRepository;
|
||||
_security = security;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -47,12 +51,30 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
{
|
||||
var usuario = await _repository.GetByUsernameAsync(command.Username);
|
||||
|
||||
// Deliberately vague — never reveal which check failed
|
||||
if (usuario is null || !usuario.Activo)
|
||||
// Deliberately vague to the client — never reveal which check failed.
|
||||
// Internally, SecurityEvent captures the precise FailureReason for ops.
|
||||
if (usuario is null)
|
||||
{
|
||||
await _security.LogAsync("login", "failure",
|
||||
actorUserId: null, attemptedUsername: command.Username,
|
||||
failureReason: "user_not_found");
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
if (!usuario.Activo)
|
||||
{
|
||||
await _security.LogAsync("login", "failure",
|
||||
actorUserId: usuario.Id, attemptedUsername: command.Username,
|
||||
failureReason: "user_inactive");
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
if (!_hasher.Verify(command.Password, usuario.PasswordHash))
|
||||
{
|
||||
await _security.LogAsync("login", "failure",
|
||||
actorUserId: usuario.Id, attemptedUsername: command.Username,
|
||||
failureReason: "invalid_password");
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
var accessToken = _jwtService.GenerateAccessToken(usuario);
|
||||
|
||||
@@ -83,6 +105,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
|
||||
var permisos = effective.OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
||||
|
||||
await _security.LogAsync("login", "success", actorUserId: usuario.Id);
|
||||
|
||||
return new LoginResponseDto(
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: rawRefresh, // raw to client — never stored
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
|
||||
namespace SIGCM2.Application.Auth.Logout;
|
||||
|
||||
public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, LogoutResponseDto>
|
||||
{
|
||||
private readonly IRefreshTokenRepository _refreshRepo;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
|
||||
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo)
|
||||
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo, ISecurityEventLogger security)
|
||||
{
|
||||
_refreshRepo = refreshRepo;
|
||||
_security = security;
|
||||
}
|
||||
|
||||
public async Task<LogoutResponseDto> Handle(LogoutCommand command)
|
||||
@@ -17,6 +20,7 @@ public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, Logout
|
||||
// Revoke all active tokens for the user across all families.
|
||||
// Idempotent: 0 rows affected is not an error.
|
||||
await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, DateTime.UtcNow);
|
||||
await _security.LogAsync("logout", "success", actorUserId: command.UsuarioId);
|
||||
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
using SIGCM2.Domain.Security;
|
||||
@@ -15,6 +16,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
private readonly IRefreshTokenGenerator _refreshGenerator;
|
||||
private readonly IClientContext _clientCtx;
|
||||
private readonly AuthOptions _authOptions;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
|
||||
public RefreshCommandHandler(
|
||||
IRefreshTokenRepository refreshRepo,
|
||||
@@ -22,7 +24,8 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
IJwtService jwt,
|
||||
IRefreshTokenGenerator refreshGenerator,
|
||||
IClientContext clientCtx,
|
||||
AuthOptions authOptions)
|
||||
AuthOptions authOptions,
|
||||
ISecurityEventLogger security)
|
||||
{
|
||||
_refreshRepo = refreshRepo;
|
||||
_usuarioRepo = usuarioRepo;
|
||||
@@ -30,6 +33,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
_refreshGenerator = refreshGenerator;
|
||||
_clientCtx = clientCtx;
|
||||
_authOptions = authOptions;
|
||||
_security = security;
|
||||
}
|
||||
|
||||
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
|
||||
@@ -62,23 +66,44 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
if (stored.IsRevoked)
|
||||
{
|
||||
await _refreshRepo.RevokeFamilyAsync(stored.FamilyId, now);
|
||||
await _security.LogAsync("refresh.reuse_detected", "failure",
|
||||
actorUserId: stored.UsuarioId,
|
||||
failureReason: "token_reused",
|
||||
metadata: new { familyId = stored.FamilyId });
|
||||
throw new TokenReuseDetectedException();
|
||||
}
|
||||
|
||||
// 5. Absolute expiration check
|
||||
if (stored.IsExpired(now))
|
||||
{
|
||||
await _security.LogAsync("refresh.issue", "failure",
|
||||
actorUserId: stored.UsuarioId, failureReason: "token_expired");
|
||||
throw new InvalidRefreshTokenException();
|
||||
}
|
||||
|
||||
// 6. UsuarioId must match access token's sub claim
|
||||
if (stored.UsuarioId != accessUserId)
|
||||
{
|
||||
await _security.LogAsync("refresh.issue", "failure",
|
||||
actorUserId: stored.UsuarioId, failureReason: "sub_mismatch");
|
||||
throw new InvalidRefreshTokenException();
|
||||
}
|
||||
|
||||
// 7. Load current user (so access token has up-to-date claims)
|
||||
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId)
|
||||
?? throw new InvalidRefreshTokenException();
|
||||
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId);
|
||||
if (usuario is null)
|
||||
{
|
||||
await _security.LogAsync("refresh.issue", "failure",
|
||||
actorUserId: stored.UsuarioId, failureReason: "user_not_found");
|
||||
throw new InvalidRefreshTokenException();
|
||||
}
|
||||
|
||||
if (!usuario.Activo)
|
||||
{
|
||||
await _security.LogAsync("refresh.issue", "failure",
|
||||
actorUserId: usuario.Id, failureReason: "user_inactive");
|
||||
throw new InvalidRefreshTokenException();
|
||||
}
|
||||
|
||||
// 8. Rotate: create new token, persist, then revoke old
|
||||
var newRaw = _refreshGenerator.Generate();
|
||||
@@ -90,6 +115,9 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
|
||||
// 9. Issue new access token
|
||||
var newAccess = _jwt.GenerateAccessToken(usuario);
|
||||
|
||||
await _security.LogAsync("refresh.issue", "success", actorUserId: usuario.Id);
|
||||
|
||||
return new RefreshResponseDto(newAccess, newRaw, _authOptions.AccessTokenMinutes * 60);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Permisos.Dtos;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
@@ -10,15 +12,18 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
|
||||
private readonly IRolRepository _rolRepository;
|
||||
private readonly IPermisoRepository _permisoRepository;
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public AssignPermisosToRolCommandHandler(
|
||||
IRolRepository rolRepository,
|
||||
IPermisoRepository permisoRepository,
|
||||
IRolPermisoRepository rolPermisoRepository)
|
||||
IRolPermisoRepository rolPermisoRepository,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_rolRepository = rolRepository;
|
||||
_permisoRepository = permisoRepository;
|
||||
_rolPermisoRepository = rolPermisoRepository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermisoDto>> Handle(AssignPermisosToRolCommand command)
|
||||
@@ -40,9 +45,28 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
|
||||
throw new PermisoNotFoundException(missing);
|
||||
}
|
||||
|
||||
// 3. Reemplazar el set (DELETE+INSERT en transacción dentro del repo)
|
||||
var permisoIds = permisos.Select(p => p.Id);
|
||||
await _rolPermisoRepository.ReplaceForRolAsync(rol.Id, permisoIds);
|
||||
// Capture "before" snapshot for audit diff
|
||||
var previousPermisos = await _rolPermisoRepository.GetByRolCodigoAsync(rol.Codigo);
|
||||
var beforeCodigos = previousPermisos.Select(p => p.Codigo).OrderBy(c => c, StringComparer.Ordinal).ToArray();
|
||||
var afterCodigos = permisos.Select(p => p.Codigo).OrderBy(c => c, StringComparer.Ordinal).ToArray();
|
||||
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
// 3. Reemplazar el set (DELETE+INSERT en transacción dentro del repo)
|
||||
var permisoIds = permisos.Select(p => p.Id);
|
||||
await _rolPermisoRepository.ReplaceForRolAsync(rol.Id, permisoIds);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "rol.permisos_update",
|
||||
targetType: "Rol",
|
||||
targetId: rol.Id.ToString(),
|
||||
metadata: new { before = beforeCodigos, after = afterCodigos });
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
// 4. Retornar el nuevo set asignado
|
||||
return permisos
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -9,10 +11,12 @@ namespace SIGCM2.Application.Roles.Create;
|
||||
public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand, RolCreatedDto>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public CreateRolCommandHandler(IRolRepository repository)
|
||||
public CreateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<RolCreatedDto> Handle(CreateRolCommand command)
|
||||
@@ -24,7 +28,23 @@ public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand,
|
||||
throw new RolAlreadyExistsException(command.Codigo);
|
||||
|
||||
var rol = Rol.ForCreation(command.Codigo, command.Nombre, command.Descripcion);
|
||||
var newId = await _repository.AddAsync(rol);
|
||||
|
||||
int newId;
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
newId = await _repository.AddAsync(rol);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "rol.create",
|
||||
targetType: "Rol",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new { after = new { rol.Codigo, rol.Nombre, rol.Descripcion } });
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
return new RolCreatedDto(
|
||||
Id: newId,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
@@ -8,10 +10,12 @@ namespace SIGCM2.Application.Roles.Deactivate;
|
||||
public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolCommand, RolDto>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public DeactivateRolCommandHandler(IRolRepository repository)
|
||||
public DeactivateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<RolDto> Handle(DeactivateRolCommand command)
|
||||
@@ -23,10 +27,23 @@ public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolC
|
||||
if (await _repository.HasActiveUsuariosAsync(command.Codigo))
|
||||
throw new RolInUseException(command.Codigo);
|
||||
|
||||
var updated = await _repository.UpdateAsync(
|
||||
existing.Codigo, existing.Nombre, existing.Descripcion, activo: false);
|
||||
if (!updated)
|
||||
throw new RolNotFoundException(command.Codigo);
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
var updated = await _repository.UpdateAsync(
|
||||
existing.Codigo, existing.Nombre, existing.Descripcion, activo: false);
|
||||
if (!updated)
|
||||
throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "rol.deactivate",
|
||||
targetType: "Rol",
|
||||
targetId: existing.Id.ToString());
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
var rol = await _repository.GetByCodigoAsync(command.Codigo)
|
||||
?? throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
@@ -8,19 +10,42 @@ namespace SIGCM2.Application.Roles.Update;
|
||||
public sealed class UpdateRolCommandHandler : ICommandHandler<UpdateRolCommand, RolDto>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public UpdateRolCommandHandler(IRolRepository repository)
|
||||
public UpdateRolCommandHandler(IRolRepository repository, IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<RolDto> Handle(UpdateRolCommand command)
|
||||
{
|
||||
var updated = await _repository.UpdateAsync(
|
||||
command.Codigo, command.Nombre, command.Descripcion, command.Activo);
|
||||
var before = await _repository.GetByCodigoAsync(command.Codigo)
|
||||
?? throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
if (!updated)
|
||||
throw new RolNotFoundException(command.Codigo);
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
var updated = await _repository.UpdateAsync(
|
||||
command.Codigo, command.Nombre, command.Descripcion, command.Activo);
|
||||
|
||||
if (!updated)
|
||||
throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "rol.update",
|
||||
targetType: "Rol",
|
||||
targetId: before.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new { before.Nombre, before.Descripcion, before.Activo },
|
||||
after = new { command.Nombre, command.Descripcion, command.Activo },
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
var rol = await _repository.GetByCodigoAsync(command.Codigo)
|
||||
?? throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
@@ -10,13 +12,16 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPas
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IPasswordHasher _hasher;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public ChangeMyPasswordCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IPasswordHasher hasher)
|
||||
IPasswordHasher hasher,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ChangeMyPasswordCommand cmd)
|
||||
@@ -28,9 +33,21 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler<ChangeMyPas
|
||||
throw new InvalidOldPasswordException();
|
||||
|
||||
var newHash = _hasher.Hash(cmd.NewPassword);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repository.UpdatePasswordAsync(cmd.UsuarioId, newHash, mustChangePassword: false);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
await _audit.LogAsync(
|
||||
action: "usuario.password_change",
|
||||
targetType: "Usuario",
|
||||
targetId: cmd.UsuarioId.ToString());
|
||||
|
||||
tx.Complete();
|
||||
|
||||
// NOTE: intentionally does NOT revoke own refresh tokens (spec REQ-BCP-05)
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
@@ -10,13 +12,16 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler<CreateUsuarioC
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IPasswordHasher _hasher;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public CreateUsuarioCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IPasswordHasher hasher)
|
||||
IPasswordHasher hasher,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<UsuarioCreatedDto> Handle(CreateUsuarioCommand command)
|
||||
@@ -37,9 +42,32 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler<CreateUsuarioC
|
||||
email: command.Email,
|
||||
rol: command.Rol);
|
||||
|
||||
// TODO: audit — record which admin created this user (defer to UDT-Audit)
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
var newId = await _repository.AddAsync(usuario);
|
||||
|
||||
// UDT-010 (closes follow-up #6): record admin who created this user.
|
||||
await _audit.LogAsync(
|
||||
action: "usuario.create",
|
||||
targetType: "Usuario",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
{
|
||||
usuario.Username,
|
||||
usuario.Nombre,
|
||||
usuario.Apellido,
|
||||
usuario.Email,
|
||||
usuario.Rol,
|
||||
},
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new UsuarioCreatedDto(
|
||||
Id: newId,
|
||||
Username: usuario.Username,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -10,13 +12,16 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler<Deactivate
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public DeactivateUsuarioCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> Handle(DeactivateUsuarioCommand cmd)
|
||||
@@ -39,10 +44,23 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler<Deactivate
|
||||
|
||||
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, false);
|
||||
var now = DateTime.UtcNow;
|
||||
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
|
||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
|
||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.UsuarioId, now);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "usuario.deactivate",
|
||||
targetType: "Usuario",
|
||||
targetId: cmd.UsuarioId.ToString());
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
|
||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
@@ -15,15 +17,18 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler
|
||||
private readonly IUsuarioRepository _usuarioRepo;
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||
private readonly IPermisoRepository _permisoRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public UpdateUsuarioPermisosOverridesCommandHandler(
|
||||
IUsuarioRepository usuarioRepo,
|
||||
IRolPermisoRepository rolPermisoRepo,
|
||||
IPermisoRepository permisoRepo)
|
||||
IPermisoRepository permisoRepo,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_usuarioRepo = usuarioRepo;
|
||||
_rolPermisoRepo = rolPermisoRepo;
|
||||
_permisoRepo = permisoRepo;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<UsuarioPermisosDto> Handle(UpdateUsuarioPermisosOverridesCommand command)
|
||||
@@ -53,11 +58,31 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler
|
||||
|
||||
// 4. Persist — use WithPermisosJson to get updated FechaModificacion
|
||||
var newOverrides = new PermisosOverride(grant, deny);
|
||||
var previousOverrides = PermisosOverride.FromJson(usuario.PermisosJson);
|
||||
var updated = usuario.WithPermisosJson(newOverrides.ToJson());
|
||||
await _usuarioRepo.UpdatePermisosJsonAsync(
|
||||
updated.Id,
|
||||
updated.PermisosJson,
|
||||
updated.FechaModificacion!.Value);
|
||||
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
await _usuarioRepo.UpdatePermisosJsonAsync(
|
||||
updated.Id,
|
||||
updated.PermisosJson,
|
||||
updated.FechaModificacion!.Value);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "usuario.permisos_update",
|
||||
targetType: "Usuario",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new { grant = previousOverrides.Grant, deny = previousOverrides.Deny },
|
||||
after = new { grant = grant, deny = deny },
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
// 5. Return updated effective set
|
||||
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(updated.Rol);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -9,10 +11,12 @@ namespace SIGCM2.Application.Usuarios.Reactivate;
|
||||
public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<ReactivateUsuarioCommand, UsuarioDetailDto>
|
||||
{
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository)
|
||||
public ReactivateUsuarioCommandHandler(IUsuarioRepository repository, IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> Handle(ReactivateUsuarioCommand cmd)
|
||||
@@ -31,9 +35,22 @@ public sealed class ReactivateUsuarioCommandHandler : ICommandHandler<Reactivate
|
||||
|
||||
var fields = new UpdateUsuarioFields(target.Nombre, target.Apellido, target.Email, target.Rol, true);
|
||||
var now = DateTime.UtcNow;
|
||||
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
await _repository.UpdateAsync(cmd.UsuarioId, fields, now);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "usuario.reactivate",
|
||||
targetType: "Usuario",
|
||||
targetId: cmd.UsuarioId.ToString());
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
var updated = await _repository.GetDetailAsync(cmd.UsuarioId)
|
||||
?? throw new UsuarioNotFoundException(cmd.UsuarioId);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
@@ -11,15 +13,18 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUs
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IPasswordHasher _hasher;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public ResetUsuarioPasswordCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IPasswordHasher hasher,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<ResetUsuarioPasswordResponse> Handle(ResetUsuarioPasswordCommand cmd)
|
||||
@@ -32,13 +37,25 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler<ResetUs
|
||||
?? throw new UsuarioNotFoundException(cmd.TargetId);
|
||||
|
||||
var temp = TempPasswordGenerator.Generate(12);
|
||||
// SECURITY: NEVER log tempPassword
|
||||
// SECURITY: NEVER log tempPassword — it is returned to the caller, never persisted.
|
||||
var hash = _hasher.Hash(temp);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repository.UpdatePasswordAsync(cmd.TargetId, hash, mustChangePassword: true);
|
||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.TargetId, DateTime.UtcNow);
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
await _audit.LogAsync(
|
||||
action: "usuario.password_reset",
|
||||
targetType: "Usuario",
|
||||
targetId: cmd.TargetId.ToString(),
|
||||
metadata: new { targetId = cmd.TargetId }); // NO tempPassword in metadata
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ResetUsuarioPasswordResponse(temp, MustChangeOnLogin: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -11,15 +13,18 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler<UpdateUsuarioC
|
||||
private readonly IUsuarioRepository _repository;
|
||||
private readonly IRolRepository _rolRepository;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IAuditLogger _audit;
|
||||
|
||||
public UpdateUsuarioCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
IRolRepository rolRepository,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IAuditLogger audit)
|
||||
{
|
||||
_repository = repository;
|
||||
_rolRepository = rolRepository;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<UsuarioDetailDto> Handle(UpdateUsuarioCommand cmd)
|
||||
@@ -48,17 +53,36 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler<UpdateUsuarioC
|
||||
|
||||
var fields = new UpdateUsuarioFields(cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo);
|
||||
var now = DateTime.UtcNow;
|
||||
await _repository.UpdateAsync(cmd.Id, fields, now);
|
||||
|
||||
// Revoke refresh tokens if rol changed or user deactivated
|
||||
var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal);
|
||||
var justDeactivated = target.Activo && !cmd.Activo;
|
||||
if (rolChanged || justDeactivated)
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now);
|
||||
await _repository.UpdateAsync(cmd.Id, fields, now);
|
||||
|
||||
// Revoke refresh tokens if rol changed or user deactivated
|
||||
var rolChanged = !string.Equals(target.Rol, cmd.Rol, StringComparison.Ordinal);
|
||||
var justDeactivated = target.Activo && !cmd.Activo;
|
||||
if (rolChanged || justDeactivated)
|
||||
{
|
||||
await _refreshTokenRepository.RevokeAllActiveForUserAsync(cmd.Id, now);
|
||||
}
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "usuario.update",
|
||||
targetType: "Usuario",
|
||||
targetId: cmd.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new { target.Nombre, target.Apellido, target.Email, target.Rol, target.Activo },
|
||||
after = new { cmd.Nombre, cmd.Apellido, cmd.Email, cmd.Rol, cmd.Activo },
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
// TODO: audit — defer to ADM-004
|
||||
// Post-commit read: outside the scope so SqlClient does not try to enlist a completed transaction.
|
||||
var updated = await _repository.GetDetailAsync(cmd.Id)
|
||||
?? throw new UsuarioNotFoundException(cmd.Id);
|
||||
|
||||
|
||||
@@ -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.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Auth;
|
||||
using SIGCM2.Infrastructure.Http;
|
||||
using SIGCM2.Infrastructure.Messaging;
|
||||
@@ -68,6 +69,14 @@ public static class DependencyInjection
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<IClientContext, ClientContext>();
|
||||
|
||||
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
|
||||
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
|
||||
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
|
||||
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
|
||||
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
|
||||
services.AddScoped<IAuditLogger, SIGCM2.Infrastructure.Audit.AuditLogger>();
|
||||
services.AddScoped<ISecurityEventLogger, SIGCM2.Infrastructure.Audit.SecurityEventLogger>();
|
||||
|
||||
// Dispatcher
|
||||
services.AddScoped<IDispatcher, Dispatcher>();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
71
src/web/src/api/audit.ts
Normal file
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,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
FileClock,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react'
|
||||
@@ -23,6 +24,8 @@ interface NavItem {
|
||||
href: string
|
||||
icon: React.ElementType
|
||||
disabled?: boolean
|
||||
/** Si se define, el item solo se muestra si el user tiene este permiso. */
|
||||
requiredPermission?: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@@ -38,6 +41,12 @@ const adminItems: NavItem[] = [
|
||||
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
||||
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
|
||||
{
|
||||
label: 'Auditoría',
|
||||
href: '/admin/audit',
|
||||
icon: FileClock,
|
||||
requiredPermission: 'administracion:auditoria:ver',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
@@ -120,14 +129,20 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
||||
{isAdmin && (
|
||||
<>
|
||||
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
||||
{adminItems.map((item) => (
|
||||
<NavRow
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isItemActive(item)}
|
||||
/>
|
||||
))}
|
||||
{adminItems
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.requiredPermission ||
|
||||
user?.permisos.includes(item.requiredPermission),
|
||||
)
|
||||
.map((item) => (
|
||||
<NavRow
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isItemActive(item)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
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 { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
||||
import { AuditPage } from './pages/admin/audit/AuditPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -154,6 +155,15 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin/audit"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['administracion:auditoria:ver']}>
|
||||
<AuditPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
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 SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Authorization;
|
||||
@@ -18,6 +19,7 @@ public sealed class PermissionAuthorizationHandlerTests
|
||||
{
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
||||
private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
|
||||
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
|
||||
private readonly PermissionAuthorizationHandler _handler;
|
||||
|
||||
public PermissionAuthorizationHandlerTests()
|
||||
@@ -29,6 +31,7 @@ public sealed class PermissionAuthorizationHandlerTests
|
||||
_handler = new PermissionAuthorizationHandler(
|
||||
_rolPermisoRepo,
|
||||
_usuarioRepo,
|
||||
_security,
|
||||
NullLogger<PermissionAuthorizationHandler>.Instance);
|
||||
}
|
||||
|
||||
|
||||
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.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Auth;
|
||||
using SIGCM2.Application.Auth.Login;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -20,6 +21,7 @@ public class LoginCommandHandlerTests
|
||||
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
|
||||
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
|
||||
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
|
||||
private readonly ILogger<LoginCommandHandler> _logger = Substitute.For<ILogger<LoginCommandHandler>>();
|
||||
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
|
||||
private readonly LoginCommandHandler _handler;
|
||||
@@ -42,7 +44,7 @@ public class LoginCommandHandlerTests
|
||||
_handler = new LoginCommandHandler(
|
||||
_repository, _hasher, _jwtService,
|
||||
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
|
||||
_rolPermisoRepo, _logger);
|
||||
_rolPermisoRepo, _security, _logger);
|
||||
}
|
||||
|
||||
// Scenario: valid credentials → returns token response with usuario populated
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Auth.Logout;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Auth.Logout;
|
||||
@@ -7,11 +8,12 @@ namespace SIGCM2.Application.Tests.Auth.Logout;
|
||||
public class LogoutCommandHandlerTests
|
||||
{
|
||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
|
||||
private readonly LogoutCommandHandler _handler;
|
||||
|
||||
public LogoutCommandHandlerTests()
|
||||
{
|
||||
_handler = new LogoutCommandHandler(_refreshRepo);
|
||||
_handler = new LogoutCommandHandler(_refreshRepo, _security);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -4,6 +4,7 @@ using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Auth;
|
||||
using SIGCM2.Application.Auth.Refresh;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -19,6 +20,7 @@ public class RefreshCommandHandlerTests
|
||||
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
|
||||
private readonly IRefreshTokenGenerator _generator = Substitute.For<IRefreshTokenGenerator>();
|
||||
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
|
||||
private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
|
||||
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
|
||||
private readonly RefreshCommandHandler _handler;
|
||||
|
||||
@@ -34,7 +36,7 @@ public class RefreshCommandHandlerTests
|
||||
_generator.Generate().Returns("new_raw_token_value_xyz");
|
||||
|
||||
_handler = new RefreshCommandHandler(
|
||||
_refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions);
|
||||
_refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions, _security);
|
||||
}
|
||||
|
||||
// Helper: build an active stored RefreshToken with a matching principal
|
||||
|
||||
@@ -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", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
|
||||
new Respawn.Graph.Table("dbo", "Usuario_History"),
|
||||
new Respawn.Graph.Table("dbo", "Rol_History"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso_History"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ public class RolRepositoryTests : IAsyncLifetime
|
||||
_connection = new SqlConnection(ConnectionString);
|
||||
await _connection.OpenAsync();
|
||||
|
||||
// Clean Usuario first (FK), then custom Rol codes created by tests.
|
||||
// Clean RefreshToken first (FK to Usuario), then Usuario (FK to Rol), then custom Rol codes.
|
||||
// Residual RefreshTokens from prior test suites would violate FK_RefreshToken_Usuario otherwise.
|
||||
await _connection.ExecuteAsync("DELETE FROM dbo.RefreshToken;");
|
||||
await _connection.ExecuteAsync("DELETE FROM dbo.Usuario;");
|
||||
await _connection.ExecuteAsync("DELETE FROM dbo.Rol WHERE Codigo NOT IN ('admin','cajero','operador_ctacte','picadora','jefe_publicidad','productor','diagramacion','reportes');");
|
||||
// Ensure canonical Rol seeds exist (idempotent — previous test classes may have wiped them via Respawn).
|
||||
|
||||
@@ -28,6 +28,11 @@ public class UsuarioRepositoryTests : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
|
||||
new Respawn.Graph.Table("dbo", "Usuario_History"),
|
||||
new Respawn.Graph.Table("dbo", "Rol_History"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso_History"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
|
||||
new Respawn.Graph.Table("dbo", "Usuario_History"),
|
||||
new Respawn.Graph.Table("dbo", "Rol_History"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso_History"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ public sealed class V009MigrationTests : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
// UDT-010: *_History tables are system-versioned — engine rejects direct DELETE.
|
||||
new Respawn.Graph.Table("dbo", "Usuario_History"),
|
||||
new Respawn.Graph.Table("dbo", "Rol_History"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso_History"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Permisos.Assign;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -11,11 +12,12 @@ public class AssignPermisosToRolCommandHandlerTests
|
||||
private readonly IRolRepository _rolRepository = Substitute.For<IRolRepository>();
|
||||
private readonly IPermisoRepository _permisoRepository = Substitute.For<IPermisoRepository>();
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For<IRolPermisoRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly AssignPermisosToRolCommandHandler _handler;
|
||||
|
||||
public AssignPermisosToRolCommandHandlerTests()
|
||||
{
|
||||
_handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository);
|
||||
_handler = new AssignPermisosToRolCommandHandler(_rolRepository, _permisoRepository, _rolPermisoRepository, _audit);
|
||||
}
|
||||
|
||||
private static Rol MakeRol(int id, string codigo) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Roles.Create;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -9,13 +10,14 @@ namespace SIGCM2.Application.Tests.Roles.Create;
|
||||
public class CreateRolCommandHandlerTests
|
||||
{
|
||||
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly CreateRolCommandHandler _handler;
|
||||
|
||||
private static CreateRolCommand ValidCommand() => new("cajero_senior", "Cajero Senior", "Con más permisos");
|
||||
|
||||
public CreateRolCommandHandlerTests()
|
||||
{
|
||||
_handler = new CreateRolCommandHandler(_repository);
|
||||
_handler = new CreateRolCommandHandler(_repository, _audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Roles.Deactivate;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -9,6 +10,7 @@ namespace SIGCM2.Application.Tests.Roles.Deactivate;
|
||||
public class DeactivateRolCommandHandlerTests
|
||||
{
|
||||
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly DeactivateRolCommandHandler _handler;
|
||||
|
||||
private static Rol RolActive(string codigo, int id = 10)
|
||||
@@ -19,7 +21,7 @@ public class DeactivateRolCommandHandlerTests
|
||||
|
||||
public DeactivateRolCommandHandlerTests()
|
||||
{
|
||||
_handler = new DeactivateRolCommandHandler(_repository);
|
||||
_handler = new DeactivateRolCommandHandler(_repository, _audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Roles.Update;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -9,11 +10,12 @@ namespace SIGCM2.Application.Tests.Roles.Update;
|
||||
public class UpdateRolCommandHandlerTests
|
||||
{
|
||||
private readonly IRolRepository _repository = Substitute.For<IRolRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly UpdateRolCommandHandler _handler;
|
||||
|
||||
public UpdateRolCommandHandlerTests()
|
||||
{
|
||||
_handler = new UpdateRolCommandHandler(_repository);
|
||||
_handler = new UpdateRolCommandHandler(_repository, _audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -12,11 +13,12 @@ public class ChangeMyPasswordCommandHandlerTests
|
||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly ChangeMyPasswordCommandHandler _handler;
|
||||
|
||||
public ChangeMyPasswordCommandHandlerTests()
|
||||
{
|
||||
_handler = new ChangeMyPasswordCommandHandler(_repo, _hasher);
|
||||
_handler = new ChangeMyPasswordCommandHandler(_repo, _hasher, _audit);
|
||||
}
|
||||
|
||||
private static Usuario MakeUser(int id = 1, bool mustChangePassword = false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Usuarios.Create;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -11,6 +12,7 @@ public class CreateUsuarioCommandHandlerTests
|
||||
{
|
||||
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
|
||||
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly CreateUsuarioCommandHandler _handler;
|
||||
|
||||
private static CreateUsuarioCommand ValidCommand() => new(
|
||||
@@ -23,7 +25,7 @@ public class CreateUsuarioCommandHandlerTests
|
||||
|
||||
public CreateUsuarioCommandHandlerTests()
|
||||
{
|
||||
_handler = new CreateUsuarioCommandHandler(_repository, _hasher);
|
||||
_handler = new CreateUsuarioCommandHandler(_repository, _hasher, _audit);
|
||||
}
|
||||
|
||||
// ── exists → throws ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.Deactivate;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -11,11 +12,12 @@ public class DeactivateUsuarioCommandHandlerTests
|
||||
{
|
||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly DeactivateUsuarioCommandHandler _handler;
|
||||
|
||||
public DeactivateUsuarioCommandHandlerTests()
|
||||
{
|
||||
_handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo);
|
||||
_handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo, _audit);
|
||||
_repo.CountActiveAdminsAsync(Arg.Any<CancellationToken>()).Returns(2);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.Reactivate;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -10,11 +11,12 @@ namespace SIGCM2.Application.Tests.Usuarios;
|
||||
public class ReactivateUsuarioCommandHandlerTests
|
||||
{
|
||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly ReactivateUsuarioCommandHandler _handler;
|
||||
|
||||
public ReactivateUsuarioCommandHandlerTests()
|
||||
{
|
||||
_handler = new ReactivateUsuarioCommandHandler(_repo);
|
||||
_handler = new ReactivateUsuarioCommandHandler(_repo, _audit);
|
||||
}
|
||||
|
||||
private static Usuario MakeUser(int id = 5, bool activo = false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Abstractions.Security;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
@@ -12,11 +13,12 @@ public class ResetUsuarioPasswordCommandHandlerTests
|
||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
|
||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly ResetUsuarioPasswordCommandHandler _handler;
|
||||
|
||||
public ResetUsuarioPasswordCommandHandlerTests()
|
||||
{
|
||||
_handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo);
|
||||
_handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo, _audit);
|
||||
_hasher.Hash(Arg.Any<string>()).Returns(args => "$2a$12$hashof_" + args[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Usuarios.Update;
|
||||
using SIGCM2.Domain.Entities;
|
||||
@@ -14,11 +15,12 @@ public class UpdateUsuarioCommandHandlerTests
|
||||
private readonly IUsuarioRepository _repo = Substitute.For<IUsuarioRepository>();
|
||||
private readonly IRolRepository _rolRepo = Substitute.For<IRolRepository>();
|
||||
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly UpdateUsuarioCommandHandler _handler;
|
||||
|
||||
public UpdateUsuarioCommandHandlerTests()
|
||||
{
|
||||
_handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo);
|
||||
_handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo, _audit);
|
||||
|
||||
// Default: rol exists and is active
|
||||
_rolRepo.ExistsActiveByCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
@@ -32,16 +32,25 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// V009: update PermisosJson DEFAULT constraint and migrate legacy rows
|
||||
await EnsureV009SchemaAsync();
|
||||
|
||||
// V010 (UDT-010): verify audit infrastructure + temporal tables are active.
|
||||
// Applied manually via: sqlcmd ... -i database/migrations/V010__audit_infrastructure.sql
|
||||
await EnsureV010SchemaAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
// Rol is a lookup table seeded by migration V003 — never wipe or Usuario FK breaks.
|
||||
// Permiso and RolPermiso are seeded by V005/V006 — never wipe or integration tests lose the permission catalog.
|
||||
// *_History tables: UDT-010 system-versioned — Respawn cannot DELETE them directly (engine rejects).
|
||||
TablesToIgnore =
|
||||
[
|
||||
new Respawn.Graph.Table("dbo", "Rol"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso"),
|
||||
new Respawn.Graph.Table("dbo", "Usuario_History"),
|
||||
new Respawn.Graph.Table("dbo", "Rol_History"),
|
||||
new Respawn.Graph.Table("dbo", "Permiso_History"),
|
||||
new Respawn.Graph.Table("dbo", "RolPermiso_History"),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -232,6 +241,29 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await _connection.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UDT-010 (V010): verifies that the audit infrastructure is present.
|
||||
/// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition
|
||||
/// function/scheme creation requires the full script). If missing, fails with a clear
|
||||
/// message pointing to the migration script.
|
||||
/// </summary>
|
||||
private async Task EnsureV010SchemaAsync()
|
||||
{
|
||||
const string check = """
|
||||
SELECT
|
||||
CAST(CASE WHEN OBJECT_ID('dbo.AuditEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasAuditEvent,
|
||||
CAST(CASE WHEN OBJECT_ID('dbo.SecurityEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasSecurityEvent,
|
||||
CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2) THEN 1 ELSE 0 END AS BIT) AS UsuarioVersioned
|
||||
""";
|
||||
var result = await _connection.QuerySingleAsync<(bool HasAuditEvent, bool HasSecurityEvent, bool UsuarioVersioned)>(check);
|
||||
if (!result.HasAuditEvent || !result.HasSecurityEvent || !result.UsuarioVersioned)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"V010 audit infrastructure is not applied in the test database. " +
|
||||
"Run: sqlcmd -S <server> -d SIGCM2_Test -U <user> -P <pass> -i database/migrations/V010__audit_infrastructure.sql");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies V009 schema changes idempotently to the test database.
|
||||
/// Mirrors V009__activate_permisos_overrides.sql.
|
||||
|
||||
Reference in New Issue
Block a user