From 2d1d187f6e589dc0ec4ec9b1056cd8f626b2426e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 12:56:17 -0300 Subject: [PATCH 01/14] chore(udt-010): bootstrap rama + spike anti-MSDTC Validates design decision #D-1 (TransactionScope ambient over IUnitOfWork): TransactionScope with TransactionScopeAsyncFlowOption.Enabled does NOT escalate to MSDTC when multiple SqlConnections share the same connection string. Test passes (DistributedIdentifier == Guid.Empty). Unblocks UDT-010 batches B1-B14. Refs: sdd/udt-010-auditoria-trazabilidad/{design,tasks} --- .../Audit/TransactionScopeSpikeTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs diff --git a/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs b/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs new file mode 100644 index 0000000..32c9ca3 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs @@ -0,0 +1,58 @@ +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. +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(); + } +} -- 2.49.1 From 1c79dfa0a4218973040b18b94a5dcfbd86f8e1e9 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:10:04 -0300 Subject: [PATCH 02/14] feat(db): V010 audit infrastructure + temporal tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied to SIGCM2 (dev) and SIGCM2_Test. V010__audit_infrastructure.sql (idempotent, ~280 LoC): - Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names via DB_NAME() prefix to avoid collision in dev/test). - pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT, DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends forward monthly in B11. - dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes (Actor/Target/Action/Correlation) with PAGE compression. - dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure). - CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure). - SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention + PAGE compression in history tables. - No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion). V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history). database/README.md: migration order + V010 prod-apply notes. tests/SIGCM2.TestSupport/SqlTestFixture.cs: - EnsureV010SchemaAsync() validates audit infra is applied (fails fast with clear message if not — migration itself requires ALTER DATABASE privileges and is applied manually via sqlcmd). - Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE on system-versioned history tables). tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests: - AuditEvent insert+roundtrip with CorrelationId. - CK_AuditEvent_Action rejects Action without '.'. - CK_AuditEvent_Metadata rejects non-JSON. - CK_SecurityEvent_Result rejects invalid Result. - Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns pre-update state + Usuario_History populated. Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1, design#D-4, tasks} --- database/README.md | 85 ++++ database/migrations/V010_ROLLBACK.sql | 183 ++++++++ .../migrations/V010__audit_infrastructure.sql | 434 ++++++++++++++++++ .../Audit/V010MigrationTests.cs | 149 ++++++ tests/SIGCM2.TestSupport/SqlTestFixture.cs | 32 ++ 5 files changed, 883 insertions(+) create mode 100644 database/README.md create mode 100644 database/migrations/V010_ROLLBACK.sql create mode 100644 database/migrations/V010__audit_infrastructure.sql create mode 100644 tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..c8d18a7 --- /dev/null +++ b/database/README.md @@ -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__.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 `_AUDIT_HOT.ndf` y `_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}` diff --git a/database/migrations/V010_ROLLBACK.sql b/database/migrations/V010_ROLLBACK.sql new file mode 100644 index 0000000..63eaaf8 --- /dev/null +++ b/database/migrations/V010_ROLLBACK.sql @@ -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 diff --git a/database/migrations/V010__audit_infrastructure.sql b/database/migrations/V010__audit_infrastructure.sql new file mode 100644 index 0000000..5f27ce9 --- /dev/null +++ b/database/migrations/V010__audit_infrastructure.sql @@ -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 diff --git a/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs b/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs new file mode 100644 index 0000000..38ae730 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs @@ -0,0 +1,149 @@ +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. +public sealed class V010MigrationTests : IClassFixture +{ + 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(""" + 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() + .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() + .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() + .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(""" + 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(""" + 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( + "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( + "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 }); + } +} diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 2bf7c92..d09c8c5 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -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); } + /// + /// 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. + /// + 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 -d SIGCM2_Test -U -P -i database/migrations/V010__audit_infrastructure.sql"); + } + } + /// /// Applies V009 schema changes idempotently to the test database. /// Mirrors V009__activate_permisos_overrides.sql. -- 2.49.1 From c95bc7fe0194de374b32750f032ae9f7d47a1db0 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:22:56 -0300 Subject: [PATCH 03/14] fix(tests): extend Respawn + collection config for UDT-010 temporal tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up of B1 (V010 migration). Issues found when running the full suite cross-assembly: 1. Respawn 'Cannot delete rows from a temporal history table' error: 4 per-class Respawner configs in SIGCM2.Application.Tests did not include the newly-created *_History tables introduced by V010 (Usuario_History / Rol_History / Permiso_History / RolPermiso_History). The engine rejects direct DELETE on system-versioned history tables. Extended TablesToIgnore in all 4 configs. 2. FK_RefreshToken_Usuario violation in RolRepositoryTests.InitializeAsync: Manual 'DELETE FROM Usuario' failed when residual RefreshTokens from prior suites existed. Added 'DELETE FROM RefreshToken' before the Usuario cleanup to respect FK order. Latent bug surfaced by a new test-run ordering — not UDT-010 specific, but fixed in scope. 3. UQ_Usuario_Username duplicate admin race: TransactionScopeSpikeTests (B0) and V010MigrationTests (B1) were missing [Collection("ApiIntegration")], causing them to run in parallel with the rest of SIGCM2.Api.Tests and race on SeedAdmin. Serialized by adding the Collection attribute. Suite now passes cross-assembly: 130/130 Api.Tests + 336/336 Application.Tests. Refs: sdd/udt-010-auditoria-trazabilidad/apply-progress (B1 follow-up) --- tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs | 1 + tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs | 1 + .../Infrastructure/RefreshTokenRepositoryTests.cs | 5 +++++ .../Integration/RolRepositoryTests.cs | 4 +++- .../Integration/UsuarioRepositoryTests.cs | 5 +++++ .../Integration/UsuarioRepository_PermisosTests.cs | 5 +++++ .../Integration/V009MigrationTests.cs | 5 +++++ 7 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs b/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs index 32c9ca3..8c7bcf6 100644 --- a/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs +++ b/tests/SIGCM2.Api.Tests/Audit/TransactionScopeSpikeTests.cs @@ -9,6 +9,7 @@ namespace SIGCM2.Api.Tests.Audit; /// 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 = diff --git a/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs b/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs index 38ae730..e433d95 100644 --- a/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs +++ b/tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs @@ -10,6 +10,7 @@ namespace SIGCM2.Api.Tests.Audit; /// 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 { private const string ConnectionString = diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs index 8758b8e..ccd6313 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs @@ -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"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs index ee1cf60..17ec627 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolRepositoryTests.cs @@ -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). diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs index 27b34e9..6eabd33 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -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"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs index 6ffe69a..6418293 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs @@ -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"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs index 8566027..281b24e 100644 --- a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs @@ -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"), ] }); -- 2.49.1 From 68f96b90c7ab6135f10ab93c1015744430dd7d68 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:23:11 -0300 Subject: [PATCH 04/14] feat(application): audit abstractions (UDT-010 B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the contract layer for audit logging per design #D-8: SIGCM2.Application/Audit/: - IAuditContext — request-scoped accessor for ActorUserId/ActorRoleId/ Ip/UserAgent/CorrelationId. Populated by CorrelationIdMiddleware + AuditActorMiddleware (B4). - IAuditLogger.LogAsync(action, targetType, targetId, metadata?, ct) — domain-level audit emitter. Enlists in ambient TransactionScope (fail-closed per #REQ-AUD-4). - ISecurityEventLogger.LogAsync(action, result, actorUserId?, attemptedUsername?, sessionId?, failureReason?, metadata?, ct) — security-events emitter separate from IAuditLogger (different retention, no transaction scope, captures login/logout/refresh/permission.denied). - AuditOptions — bindable POCO with SanitizedKeys[] defaults (used by JsonSanitizer in B3). - AuditEventDto — read projection for GET /api/v1/audit/events (B10). - AuditEventFilter — query filter record with default Limit=50. SIGCM2.Domain/Exceptions/: - AuditContextMissingException : DomainException — fail-closed sentinel thrown when IAuditLogger is called without ActorUserId in a user-scoped command (#REQ-AUD-4). Tests (Strict TDD — shape contract, RED → GREEN): - tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs: 11 tests covering nullability, signatures, default options, record equality. Suite: 336/336 Application.Tests (prev 325 + 11 new). 130/130 Api.Tests. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/4/5, design#D-8, tasks#B2} --- .../SIGCM2.Application/Audit/AuditEventDto.cs | 15 ++ .../Audit/AuditEventFilter.cs | 13 ++ .../SIGCM2.Application/Audit/AuditOptions.cs | 22 +++ .../SIGCM2.Application/Audit/IAuditContext.cs | 14 ++ .../SIGCM2.Application/Audit/IAuditLogger.cs | 16 ++ .../Audit/ISecurityEventLogger.cs | 20 ++ .../AuditContextMissingException.cs | 11 ++ .../Audit/AuditAbstractionsTests.cs | 179 ++++++++++++++++++ 8 files changed, 290 insertions(+) create mode 100644 src/api/SIGCM2.Application/Audit/AuditEventDto.cs create mode 100644 src/api/SIGCM2.Application/Audit/AuditEventFilter.cs create mode 100644 src/api/SIGCM2.Application/Audit/AuditOptions.cs create mode 100644 src/api/SIGCM2.Application/Audit/IAuditContext.cs create mode 100644 src/api/SIGCM2.Application/Audit/IAuditLogger.cs create mode 100644 src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs create mode 100644 tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs diff --git a/src/api/SIGCM2.Application/Audit/AuditEventDto.cs b/src/api/SIGCM2.Application/Audit/AuditEventDto.cs new file mode 100644 index 0000000..1d5bfb9 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/AuditEventDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Audit/AuditEventFilter.cs b/src/api/SIGCM2.Application/Audit/AuditEventFilter.cs new file mode 100644 index 0000000..c80a97c --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/AuditEventFilter.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Audit/AuditOptions.cs b/src/api/SIGCM2.Application/Audit/AuditOptions.cs new file mode 100644 index 0000000..de19e3d --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/AuditOptions.cs @@ -0,0 +1,22 @@ +namespace SIGCM2.Application.Audit; + +/// Bound from appsettings section "Audit". Extensible via configuration. +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", + ]; +} diff --git a/src/api/SIGCM2.Application/Audit/IAuditContext.cs b/src/api/SIGCM2.Application/Audit/IAuditContext.cs new file mode 100644 index 0000000..a5011d6 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/IAuditContext.cs @@ -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; } +} diff --git a/src/api/SIGCM2.Application/Audit/IAuditLogger.cs b/src/api/SIGCM2.Application/Audit/IAuditLogger.cs new file mode 100644 index 0000000..2790bd4 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/IAuditLogger.cs @@ -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); +} diff --git a/src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs b/src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs new file mode 100644 index 0000000..c1f56e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/ISecurityEventLogger.cs @@ -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); +} diff --git a/src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs b/src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs new file mode 100644 index 0000000..d33140a --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/AuditContextMissingException.cs @@ -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.") + { } +} diff --git a/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs b/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs new file mode 100644 index 0000000..8870165 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs @@ -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(); + 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(); + 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(); + + 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(), + Arg.Any()); + } + + [Fact] + public async Task IAuditLogger_LogAsync_AllowsNullMetadata() + { + var logger = Substitute.For(); + await logger.LogAsync("usuario.deactivate", "Usuario", "42"); + + await logger.Received(1).LogAsync( + "usuario.deactivate", + "Usuario", + "42", + null, + Arg.Any()); + } + + [Fact] + public async Task ISecurityEventLogger_LogAsync_ExposesFullSignatureForLoginFailure() + { + var logger = Substitute.For(); + 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(), Arg.Any()); + } + + [Fact] + public async Task ISecurityEventLogger_LogAsync_MinimalSuccessCall() + { + var logger = Substitute.For(); + await logger.LogAsync("logout", "success", actorUserId: 42); + + await logger.Received(1).LogAsync( + "logout", "success", 42, null, null, null, null, Arg.Any()); + } + + [Fact] + public void AuditContextMissingException_IsDomainException_WithFixedMessage() + { + var ex = new AuditContextMissingException(); + + ex.Should().BeAssignableTo(); + 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); + } +} -- 2.49.1 From 08d6622e43062297d015a7bdfb3e66ac6a79cd13 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:28:37 -0300 Subject: [PATCH 05/14] feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the metadata sanitization layer per #REQ-AUD-5: SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class): - Sanitize(object?, IReadOnlyCollection) -> string? - Serializes via System.Text.Json + JsonNode recursive traversal. - Strips blacklisted keys at every nesting level (objects + arrays). - Case-insensitive match (ToLowerInvariant on both sides). - Null input -> null output (never throws). - Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK). SIGCM2.Application/Audit/AuditOptions.cs: - Documented the IConfiguration array-binding quirk: config is ADDITIVE (append at higher indices), not REPLACE. Intentional for security — defaults like 'password'/'token'/'cvv' must not be silently dropped. SIGCM2.Infrastructure/DependencyInjection.cs: - services.Configure(configuration.GetSection(AuditOptions.SectionName)) wired in AddInfrastructure(). Tests (Strict TDD, RED -> GREEN): - JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/ primitives/round-trip-valid-json/string-as-value/default-keys-effective. - AuditOptionsBindingTests (2): defaults when section absent + additive override. One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally asserted against an encoding-specific literal; rewrote to use JsonDocument round-trip (validates behavior without coupling to encoder quirks). Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3} --- .../SIGCM2.Application/Audit/AuditOptions.cs | 7 +- .../Audit/JsonSanitizer.cs | 63 +++++++ .../DependencyInjection.cs | 4 + .../Audit/AuditOptionsBindingTests.cs | 71 +++++++ .../Audit/JsonSanitizerTests.cs | 173 ++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs diff --git a/src/api/SIGCM2.Application/Audit/AuditOptions.cs b/src/api/SIGCM2.Application/Audit/AuditOptions.cs index de19e3d..6e63a68 100644 --- a/src/api/SIGCM2.Application/Audit/AuditOptions.cs +++ b/src/api/SIGCM2.Application/Audit/AuditOptions.cs @@ -1,6 +1,11 @@ namespace SIGCM2.Application.Audit; -/// Bound from appsettings section "Audit". Extensible via configuration. +/// 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"; diff --git a/src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs b/src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs new file mode 100644 index 0000000..c1b1f1b --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs @@ -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 to JSON and removes any property whose key matches + /// (case-insensitively) an entry in . Recursive into nested + /// objects and arrays. Returns null if the input is null. + public static string? Sanitize(object? obj, IReadOnlyCollection 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 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 + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index baa417f..1efd8a9 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -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,9 @@ public static class DependencyInjection services.AddHttpContextAccessor(); services.AddScoped(); + // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". + services.Configure(configuration.GetSection(AuditOptions.SectionName)); + // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs new file mode 100644 index 0000000..09e236a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs @@ -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>? overrides = null) + { + // Minimum required config for AddInfrastructure to succeed. + var inMemory = new Dictionary + { + ["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>().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("Audit:SanitizedKeys:11", "customSecret"), + new KeyValuePair("Audit:SanitizedKeys:12", "internalToken"), + }); + + var opts = sp.GetRequiredService>().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 + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs new file mode 100644 index 0000000..5382b32 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs @@ -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 + { + ["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\""); + } +} -- 2.49.1 From 0b4af4c332ab36dbff16a7ebd4fea4aeb3f6b6cd Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:32:13 -0300 Subject: [PATCH 06/14] feat(api): audit context middleware + scoped impl (UDT-010 B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the request-scoped audit context per design #D-2: Middleware pipeline in Program.cs: app.UseCors() app.UseMiddleware() // PRE-AUTH app.UseAuthentication() app.UseMiddleware() // POST-AUTH app.UseAuthorization() app.MapControllers() SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs: - Preserves client-sent X-Correlation-Id header when a valid GUID, otherwise generates Guid.NewGuid(). Stores in HttpContext.Items (audit:correlationId). - Captures Ip (Connection.RemoteIpAddress) + UserAgent header into Items. - Echoes the correlation id back via response header (OnStarting + immediate set — immediate set makes unit testing against DefaultHttpContext reliable). SIGCM2.Api/Middleware/AuditActorMiddleware.cs: - Reads JWT 'sub' claim from authenticated HttpContext.User, parses to int, stores as audit:actorUserId. Anonymous / non-numeric sub leaves it unset. SIGCM2.Infrastructure/Audit/AuditContext.cs (IAuditContext scoped impl): - Reads Items entries via IHttpContextAccessor. Returns null / Guid.Empty when no HttpContext is available (jobs, tests without middleware). - ActorRoleId intentionally null for now — rol code → id resolution is deferred; the logger may resolve it at persist time in a later batch. DI registration (Infrastructure/DependencyInjection.cs): - services.AddScoped() Tests (Strict TDD): - CorrelationIdMiddlewareTests (6): generates/preserves/handles-malformed correlation id, sets response header, captures ip/ua, calls next. - AuditActorMiddlewareTests (5): authenticated/anonymous/no-sub/non-numeric/ calls-next. - AuditContextTests (7): reads from Items, null-http-context defaults, ActorRoleId currently null. Suite: 355/355 Application.Tests + 141/141 Api.Tests = 496/496 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/9, design#D-2, tasks#B4} --- .../Middleware/AuditActorMiddleware.cs | 32 ++++++ .../Middleware/CorrelationIdMiddleware.cs | 51 +++++++++ src/api/SIGCM2.Api/Program.cs | 6 ++ .../Audit/AuditContext.cs | 38 +++++++ .../DependencyInjection.cs | 1 + .../Audit/AuditActorMiddlewareTests.cs | 87 +++++++++++++++ .../Audit/CorrelationIdMiddlewareTests.cs | 100 ++++++++++++++++++ .../Infrastructure/Audit/AuditContextTests.cs | 89 ++++++++++++++++ 8 files changed, 404 insertions(+) create mode 100644 src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs create mode 100644 src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs create mode 100644 tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs create mode 100644 tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditContextTests.cs diff --git a/src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs b/src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs new file mode 100644 index 0000000..13d416f --- /dev/null +++ b/src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs @@ -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); + } +} diff --git a/src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs b/src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..eb799a3 --- /dev/null +++ b/src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs @@ -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); + } +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index 313fe60..cc45135 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Serilog; using Scalar.AspNetCore; using SIGCM2.Api.Authorization; +using SIGCM2.Api.Middleware; using SIGCM2.Application; using SIGCM2.Infrastructure; using SIGCM2.Api.Filters; @@ -66,7 +67,12 @@ 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(); app.UseAuthentication(); +// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim. +app.UseMiddleware(); app.UseAuthorization(); app.MapControllers(); diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs new file mode 100644 index 0000000..81ad0f2 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditContext.cs @@ -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; +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 1efd8a9..946833d 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -71,6 +71,7 @@ public static class DependencyInjection // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". services.Configure(configuration.GetSection(AuditOptions.SectionName)); + services.AddScoped(); // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs new file mode 100644 index 0000000..d8732be --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/AuditActorMiddlewareTests.cs @@ -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(); + } +} diff --git a/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs b/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs new file mode 100644 index 0000000..847eac4 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/CorrelationIdMiddlewareTests.cs @@ -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)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(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditContextTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditContextTests.cs new file mode 100644 index 0000000..d285783 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditContextTests.cs @@ -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(); + 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(); + 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(); + } +} -- 2.49.1 From 300badda738d02e1e3c19e431b826eacfa1613e0 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:38:05 -0300 Subject: [PATCH 07/14] feat(infra): audit + security event repositories (UDT-010 B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces persistence layer for audit and security events per design #D-6: SIGCM2.Application/Audit/: - IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination - ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is queried only from an admin dashboard deferred to ADM-004) - AuditEventQueryResult: (Items, NextCursor) record SIGCM2.Infrastructure/Audit/: - AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for DESC pagination. TryDecode is fail-open — malformed cursor returns null and the query starts from the top. - AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic WHERE composition with parameterized filters (zero SQL injection risk). LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto. Pagination fetches Limit+1 rows to detect "more pages"; emits cursor from the Nth row when overflow observed. - SecurityEventRepository: straight INSERT for login/logout/refresh/ permission.denied events. DI: AddScoped for both repos in AddInfrastructure. Integration tests (Strict TDD): 13 total, all against SIGCM2_Test. - AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor, filter-by-target, filter-by-date-range, cursor pagination across 3 pages (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario populates username, cursor encode/decode roundtrip, cursor malformed variants. - SecurityEventRepositoryTests (3): insert success, insert failure with null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection. Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1, design#D-6, tasks#B5} --- .../Audit/IAuditEventRepository.cs | 27 +++ .../Audit/ISecurityEventRepository.cs | 19 ++ .../Audit/AuditEventCursor.cs | 45 ++++ .../Audit/AuditEventRepository.cs | 139 +++++++++++ .../Audit/SecurityEventRepository.cs | 58 +++++ .../DependencyInjection.cs | 2 + .../Audit/AuditEventRepositoryTests.cs | 228 ++++++++++++++++++ .../Audit/SecurityEventRepositoryTests.cs | 101 ++++++++ 8 files changed, 619 insertions(+) create mode 100644 src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs create mode 100644 src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventRepositoryTests.cs diff --git a/src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs b/src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs new file mode 100644 index 0000000..87cbb17 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/IAuditEventRepository.cs @@ -0,0 +1,27 @@ +namespace SIGCM2.Application.Audit; + +public sealed record AuditEventQueryResult( + IReadOnlyList 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 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 QueryAsync( + AuditEventFilter filter, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs b/src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs new file mode 100644 index 0000000..0aea683 --- /dev/null +++ b/src/api/SIGCM2.Application/Audit/ISecurityEventRepository.cs @@ -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 InsertAsync( + DateTime occurredAt, + int? actorUserId, + string? attemptedUsername, + Guid? sessionId, + string action, + string result, + string? failureReason, + string? ipAddress, + string? userAgent, + string? metadata, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs new file mode 100644 index 0000000..e8cc6e8 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditEventCursor.cs @@ -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; + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs new file mode 100644 index 0000000..40e3311 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditEventRepository.cs @@ -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 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(cmd); + } + + public async Task QueryAsync(AuditEventFilter filter, CancellationToken ct = default) + { + var limit = Math.Clamp(filter.Limit, 1, 100); + + var wheres = new List(); + 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(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); + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs new file mode 100644 index 0000000..c04d1de --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventRepository.cs @@ -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 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(cmd); + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 946833d..0a15373 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -72,6 +72,8 @@ public static class DependencyInjection // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". services.Configure(configuration.GetSection(AuditOptions.SectionName)); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs new file mode 100644 index 0000000..cf39f63 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditEventRepositoryTests.cs @@ -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("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'"); + if (adminId is null) + { + // Seed admin if not present (Respawn wiped it) + adminId = await _connection.ExecuteScalarAsync(""" + 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); + } + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventRepositoryTests.cs new file mode 100644 index 0000000..277183a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventRepositoryTests.cs @@ -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() + .Where(e => e.Message.Contains("CK_SecurityEvent_Result")); + } +} -- 2.49.1 From a3d6214d0982d622adc808785fbf859ff3ed0657 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:41:10 -0300 Subject: [PATCH 08/14] feat(infra): AuditLogger + SecurityEventLogger impl (UDT-010 B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes the audit emission layer per design #D-8: SIGCM2.Infrastructure/Audit/AuditLogger.cs (IAuditLogger): - Enriches from IAuditContext (ActorUserId/ActorRoleId/Ip/UserAgent/CorrelationId). - Sanitizes metadata via JsonSanitizer + AuditOptions.SanitizedKeys. - Persists via IAuditEventRepository.InsertAsync. - Fail-closed: throws AuditContextMissingException when ActorUserId is null. - Translates Guid.Empty correlation id to null (DB column is nullable; Empty indicates 'no middleware ran'). - Uses System.DateTime.UtcNow for occurredAt. SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs (ISecurityEventLogger): - NOT fail-closed: null ActorUserId is valid (login failures, anonymous permission.denied events). - Ip/UserAgent pulled from IAuditContext; metadata sanitized the same way. - Persists via ISecurityEventRepository. DI: AddScoped for both loggers in AddInfrastructure. Tests (Strict TDD, mocks for IAuditContext/IAuditEventRepository/ ISecurityEventRepository): - AuditLoggerTests (6): happy path with full context, fail-closed null actor, metadata sanitization, null metadata pass-through, repo-throws-bubbles-up (critical for TransactionScope rollback), custom SanitizedKeys from options. - SecurityEventLoggerTests (4): login.success with context, login.failure with null actor + attemptedUsername, metadata sanitization, permission.denied with both actor and attemptedUsername null. Two initial failures were fixed by replacing 'null' literal arguments in NSubstitute Received(...) assertions with Arg.Is(x => x == null) — NSubstitute does not always match null literals when mixed with Arg.Any(). Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-4 #REQ-SEC-2/3, design#D-8, tasks#B6} --- .../Audit/AuditLogger.cs | 57 +++++++ .../Audit/SecurityEventLogger.cs | 52 ++++++ .../DependencyInjection.cs | 2 + .../Infrastructure/Audit/AuditLoggerTests.cs | 154 ++++++++++++++++++ .../Audit/SecurityEventLoggerTests.cs | 101 ++++++++++++ 5 files changed, 366 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs diff --git a/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs b/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs new file mode 100644 index 0000000..6376ec1 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/AuditLogger.cs @@ -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 _options; + + public AuditLogger( + IAuditContext context, + IAuditEventRepository repo, + IOptions 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); + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs new file mode 100644 index 0000000..3b3a94e --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs @@ -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 _options; + + public SecurityEventLogger( + ISecurityEventRepository repo, + IAuditContext context, + IOptions 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); + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 0a15373..e9fe1b3 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -74,6 +74,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs new file mode 100644 index 0000000..6dc1615 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs @@ -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(); + repo ??= Substitute.For(); + 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(); + 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(); + var logger = Build(context, repo); + + await logger.LogAsync("usuario.create", "Usuario", "99", + metadata: new { username = "juan" }); + + await repo.Received(1).InsertAsync( + Arg.Any(), + 42, + 7, + "usuario.create", + "Usuario", + "99", + correlationId, + "10.0.0.5", + "ua/1.0", + Arg.Is(m => m != null && m.Contains("\"username\"") && m.Contains("juan")), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_WithoutActorUserId_ThrowsAuditContextMissingException() + { + var context = Substitute.For(); + context.ActorUserId.Returns((int?)null); + context.CorrelationId.Returns(Guid.NewGuid()); + var repo = Substitute.For(); + var logger = Build(context, repo); + + var act = async () => await logger.LogAsync("usuario.create", "Usuario", "1"); + + await act.Should().ThrowAsync(); + await repo.DidNotReceive().InsertAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task LogAsync_SanitizesMetadata_StripsBlacklistedKeys() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + 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(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Is(m => m != null && !m.Contains("\"password\"") && m.Contains("\"email\"")), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_NullMetadata_PassesNullToRepo() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + var logger = Build(context, repo); + + await logger.LogAsync("usuario.deactivate", "Usuario", "1"); + + await repo.Received(1).InsertAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + "usuario.deactivate", "Usuario", "1", + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Is(m => m == null), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_RepositoryThrows_ExceptionBubblesUp() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + repo.InsertAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(_ => 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() + .WithMessage("simulated db failure"); + } + + [Fact] + public async Task LogAsync_UsesCustomSanitizedKeys_FromOptions() + { + var context = Substitute.For(); + context.ActorUserId.Returns(1); + var repo = Substitute.For(); + 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(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Is(m => m != null && !m.Contains("\"internalId\"") && m.Contains("\"visible\"")), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs new file mode 100644 index 0000000..45ca6fb --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs @@ -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(); + context ??= Substitute.For(); + options ??= new AuditOptions(); + return new SecurityEventLogger(repo, context, Options.Create(options)); + } + + [Fact] + public async Task LogAsync_LoginSuccess_PassesActorAndIpFromContext() + { + var repo = Substitute.For(); + var context = Substitute.For(); + 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(), + 42, + Arg.Is(s => s == null), // attemptedUsername + Arg.Is(g => g == null), // sessionId + "login", "success", + Arg.Is(s => s == null), // failureReason + "1.2.3.4", + "ua", + Arg.Is(s => s == null), // metadata + Arg.Any()); + } + + [Fact] + public async Task LogAsync_LoginFailure_SupportsNullActorAndAttemptedUsername() + { + var repo = Substitute.For(); + var logger = Build(repo); + + await logger.LogAsync("login", "failure", + actorUserId: null, + attemptedUsername: "juan", + failureReason: "invalid_password"); + + await repo.Received(1).InsertAsync( + Arg.Any(), + Arg.Is(i => i == null), + "juan", + Arg.Is(g => g == null), + "login", "failure", + "invalid_password", + Arg.Any(), Arg.Any(), + Arg.Is(s => s == null), + Arg.Any()); + } + + [Fact] + public async Task LogAsync_SanitizesMetadata() + { + var repo = Substitute.For(); + 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(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), + Arg.Is(m => m != null && !m.Contains("\"token\"") && m.Contains("\"ip\"")), + Arg.Any()); + } + + [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(); + var logger = Build(repo); + + var act = async () => await logger.LogAsync("permission.denied", "failure"); + + await act.Should().NotThrowAsync(); + } +} -- 2.49.1 From 26efb74c221028eddac74f55a2e646d4661f4d71 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:49:44 -0300 Subject: [PATCH 09/14] =?UTF-8?q?feat(audit):=20enchufar=20audit=20en=20ha?= =?UTF-8?q?ndlers=20de=20Usuario=20=E2=80=94=20Closes=20#6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 command handlers del módulo Usuarios ahora auditan via IAuditLogger: | Handler | Action | |-----------------------------------------|-------------------------| | CreateUsuarioCommandHandler | usuario.create | | UpdateUsuarioCommandHandler | usuario.update | | DeactivateUsuarioCommandHandler | usuario.deactivate | | ReactivateUsuarioCommandHandler | usuario.reactivate | | ChangeMyPasswordCommandHandler | usuario.password_change | | ResetUsuarioPasswordCommandHandler | usuario.password_reset | | UpdateUsuarioPermisosOverridesHandler | usuario.permisos_update | Patrón por handler (per design #D-1): using (var tx = new TransactionScope(Required, ReadCommitted, AsyncFlowEnabled)) { await repo.UpdateAsync(...); await audit.LogAsync(...); tx.Complete(); } // post-commit reads OUTSIDE the using block var updated = await repo.GetDetailAsync(...); Metadata captured: - usuario.create: after={username, nombre, apellido, email, rol} — NO password. - usuario.update: {before, after} diff of editable fields. - usuario.password_reset: {targetId} only — tempPassword is NEVER persisted to audit (returned to caller once, never stored). - usuario.permisos_update: {before, after} of grant/deny override lists. Key fix during implementation: initially used 'using var tx = ...' (bare declaration). This kept the TransactionScope active for the rest of the method, causing 'The current TransactionScope is already complete' when post-commit reads (GetDetailAsync) tried to enlist. Solution: explicit 'using (var tx = ...) { ... }' block that disposes the scope before post-commit reads. AuditContextMissingException surfaces from AuditLogger when IAuditContext lacks ActorUserId — fail-closed per #REQ-AUD-4. In integration tests, the middleware populates ActorUserId from the JWT sub of the authenticated admin. Test updates: 6 existing unit test classes now inject IAuditLogger mock: - CreateUsuarioCommandHandlerTests - UpdateUsuarioCommandHandlerTests - DeactivateUsuarioCommandHandlerTests - ReactivateUsuarioCommandHandlerTests - ChangeMyPasswordCommandHandlerTests - ResetUsuarioPasswordCommandHandlerTests Follow-up #6 ([Auditoría] Registrar admin creador en alta de usuarios) is closed: CreateUsuarioCommandHandler now records ActorUserId = admin JWT sub on every user creation. TODO comment removed. Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Closes #6 Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-UM-AUD, design, tasks#B7} --- .../ChangeMyPasswordCommandHandler.cs | 21 +++++++++- .../Create/CreateUsuarioCommandHandler.cs | 32 ++++++++++++++- .../DeactivateUsuarioCommandHandler.cs | 26 ++++++++++-- ...eUsuarioPermisosOverridesCommandHandler.cs | 35 +++++++++++++--- .../ReactivateUsuarioCommandHandler.cs | 23 +++++++++-- .../ResetUsuarioPasswordCommandHandler.cs | 23 +++++++++-- .../Update/UpdateUsuarioCommandHandler.cs | 40 +++++++++++++++---- .../ChangeMyPasswordCommandHandlerTests.cs | 4 +- .../CreateUsuarioCommandHandlerTests.cs | 4 +- .../DeactivateUsuarioCommandHandlerTests.cs | 4 +- .../ReactivateUsuarioCommandHandlerTests.cs | 4 +- ...ResetUsuarioPasswordCommandHandlerTests.cs | 4 +- .../UpdateUsuarioCommandHandlerTests.cs | 4 +- 13 files changed, 191 insertions(+), 33 deletions(-) diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs index 60df389..60e6c3b 100644 --- a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs @@ -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 Handle(ChangeMyPasswordCommand cmd) @@ -28,9 +33,21 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler Handle(CreateUsuarioCommand command) @@ -37,9 +42,32 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler Handle(DeactivateUsuarioCommand cmd) @@ -39,10 +44,23 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler 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); diff --git a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs index 3c56919..c89a473 100644 --- a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs +++ b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs @@ -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 { 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 Handle(ReactivateUsuarioCommand cmd) @@ -31,9 +35,22 @@ public sealed class ReactivateUsuarioCommandHandler : ICommandHandler Handle(ResetUsuarioPasswordCommand cmd) @@ -32,13 +37,25 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler Handle(UpdateUsuarioCommand cmd) @@ -48,17 +53,36 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler(); private readonly IPasswordHasher _hasher = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); 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) diff --git a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs index 52a7c74..a386861 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs @@ -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(); private readonly IPasswordHasher _hasher = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); 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 ────────────────────────────────────────────────────── diff --git a/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs index a9161a3..7d7a69c 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs @@ -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(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly DeactivateUsuarioCommandHandler _handler; public DeactivateUsuarioCommandHandlerTests() { - _handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo); + _handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo, _audit); _repo.CountActiveAdminsAsync(Arg.Any()).Returns(2); } diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs index 20cc2ca..bceb7cd 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); 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) diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs index d7a92ab..d0b0ab2 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs @@ -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(); private readonly IPasswordHasher _hasher = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly ResetUsuarioPasswordCommandHandler _handler; public ResetUsuarioPasswordCommandHandlerTests() { - _handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo); + _handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo, _audit); _hasher.Hash(Arg.Any()).Returns(args => "$2a$12$hashof_" + args[0]); } diff --git a/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs index fab516d..b634c72 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs @@ -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(); private readonly IRolRepository _rolRepo = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); 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(), Arg.Any()).Returns(true); -- 2.49.1 From a3f01bc6c97c9ee9b3ae10908186728dc7363f46 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:54:47 -0300 Subject: [PATCH 10/14] feat(audit): enchufar audit en handlers de Rol (UDT-010 B8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 command handlers del módulo Roles + Permisos ahora auditan: | Handler | Action | |--------------------------------------|------------------------| | CreateRolCommandHandler | rol.create | | UpdateRolCommandHandler | rol.update | | DeactivateRolCommandHandler | rol.deactivate | | AssignPermisosToRolCommandHandler | rol.permisos_update | Mismo patrón que B7 (using block + post-commit reads outside scope). Metadata: - rol.create: after={Codigo, Nombre, Descripcion} - rol.update: {before, after} diff - rol.permisos_update: {before, after} con arrays de codigos ordenados AssignPermisosToRolCommandHandler captura 'before' leyendo GetByRolCodigoAsync antes del TransactionScope para poder emitir el diff. 4 test classes actualizados con mock de IAuditLogger. Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-RM-AUD, design, tasks#B8} --- .../AssignPermisosToRolCommandHandler.cs | 32 ++++++++++++++--- .../Roles/Create/CreateRolCommandHandler.cs | 24 +++++++++++-- .../Deactivate/DeactivateRolCommandHandler.cs | 27 +++++++++++--- .../Roles/Update/UpdateRolCommandHandler.cs | 35 ++++++++++++++++--- .../AssignPermisosToRolCommandHandlerTests.cs | 4 ++- .../Create/CreateRolCommandHandlerTests.cs | 4 ++- .../DeactivateRolCommandHandlerTests.cs | 4 ++- .../Update/UpdateRolCommandHandlerTests.cs | 4 ++- 8 files changed, 114 insertions(+), 20 deletions(-) diff --git a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs index 5f705f7..87bab38 100644 --- a/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs +++ b/src/api/SIGCM2.Application/Permisos/Assign/AssignPermisosToRolCommandHandler.cs @@ -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> Handle(AssignPermisosToRolCommand command) @@ -40,9 +45,28 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler 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 diff --git a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs index 072dab6..508fb8a 100644 --- a/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs +++ b/src/api/SIGCM2.Application/Roles/Create/CreateRolCommandHandler.cs @@ -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 { 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 Handle(CreateRolCommand command) @@ -24,7 +28,23 @@ public sealed class CreateRolCommandHandler : ICommandHandler { 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 Handle(DeactivateRolCommand command) @@ -23,10 +27,23 @@ public sealed class DeactivateRolCommandHandler : ICommandHandler { 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 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); diff --git a/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs index 54a9818..14fb6f8 100644 --- a/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Permisos/Assign/AssignPermisosToRolCommandHandlerTests.cs @@ -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(); private readonly IPermisoRepository _permisoRepository = Substitute.For(); private readonly IRolPermisoRepository _rolPermisoRepository = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); 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) => diff --git a/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs index 04b47f2..8a1a5f9 100644 --- a/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Roles/Create/CreateRolCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); 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] diff --git a/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs index 71af502..a760006 100644 --- a/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Roles/Deactivate/DeactivateRolCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); 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] diff --git a/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs index 9edb511..12a0014 100644 --- a/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Roles/Update/UpdateRolCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly UpdateRolCommandHandler _handler; public UpdateRolCommandHandlerTests() { - _handler = new UpdateRolCommandHandler(_repository); + _handler = new UpdateRolCommandHandler(_repository, _audit); } [Fact] -- 2.49.1 From b619c0576235da1e342bbe0228d22baa4edf96dc Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:59:27 -0300 Subject: [PATCH 11/14] feat(audit): security events en Auth + authorization handlers (UDT-010 B9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instruments auth pipeline with ISecurityEventLogger per #REQ-AUTH-SEC: LoginCommandHandler: - login success → action=login result=success actorUserId=user.Id - login failure disaggregated internally (client still sees 401 unified): user_not_found / user_inactive / invalid_password — attempts captured with attemptedUsername + FailureReason LogoutCommandHandler: - action=logout result=success actorUserId=cmd.UsuarioId RefreshCommandHandler: - refresh.issue success on successful rotation - refresh.reuse_detected failure when revoked token is presented (chain revoke already happens; we add the security event with metadata.familyId) - refresh.issue failure for: token_expired / sub_mismatch / user_not_found / user_inactive PermissionAuthorizationHandler: - permission.denied failure on require-permission rejection, with metadata { permissionRequired, endpoint, method }. ActorUserId from JWT sub. DI: ISecurityEventLogger was already registered by B6 (AddInfrastructure). Test updates: 4 test classes now inject ISecurityEventLogger mock: - LoginCommandHandlerTests, LogoutCommandHandlerTests, RefreshCommandHandlerTests - PermissionAuthorizationHandlerTests (Api.Tests) Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-SEC-2/3/4/5 #REQ-AUTH-SEC, design, tasks#B9} --- .../PermissionAuthorizationHandler.cs | 16 ++++++++- .../Auth/Login/LoginCommandHandler.cs | 28 +++++++++++++-- .../Auth/Logout/LogoutCommandHandler.cs | 6 +++- .../Auth/Refresh/RefreshCommandHandler.cs | 34 +++++++++++++++++-- .../PermissionAuthorizationHandlerTests.cs | 3 ++ .../Auth/Login/LoginCommandHandlerTests.cs | 4 ++- .../Auth/Logout/LogoutCommandHandlerTests.cs | 4 ++- .../Refresh/RefreshCommandHandlerTests.cs | 4 ++- 8 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs index 43551a9..ca89103 100644 --- a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs +++ b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs @@ -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. /// public sealed class PermissionAuthorizationHandler : AuthorizationHandler { private readonly IRolPermisoRepository _rolPermisoRepo; private readonly IUsuarioRepository _usuarioRepo; + private readonly ISecurityEventLogger _security; private readonly ILogger _logger; public PermissionAuthorizationHandler( IRolPermisoRepository rolPermisoRepo, IUsuarioRepository usuarioRepo, + ISecurityEventLogger security, ILogger 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)}")); diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs index fc9a06e..e74a07f 100644 --- a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs @@ -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 _logger; public LoginCommandHandler( @@ -30,6 +32,7 @@ public sealed class LoginCommandHandler : ICommandHandler logger) { _repository = repository; @@ -40,6 +43,7 @@ public sealed class LoginCommandHandler : ICommandHandler p, StringComparer.Ordinal).ToArray(); + await _security.LogAsync("login", "success", actorUserId: usuario.Id); + return new LoginResponseDto( AccessToken: accessToken, RefreshToken: rawRefresh, // raw to client — never stored diff --git a/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs index 9103a08..daf0272 100644 --- a/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs @@ -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 { 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 Handle(LogoutCommand command) @@ -17,6 +20,7 @@ public sealed class LogoutCommandHandler : ICommandHandler Handle(RefreshCommand command) @@ -62,23 +66,44 @@ public sealed class RefreshCommandHandler : ICommandHandler(); private readonly IUsuarioRepository _usuarioRepo = Substitute.For(); + private readonly ISecurityEventLogger _security = Substitute.For(); private readonly PermissionAuthorizationHandler _handler; public PermissionAuthorizationHandlerTests() @@ -29,6 +31,7 @@ public sealed class PermissionAuthorizationHandlerTests _handler = new PermissionAuthorizationHandler( _rolPermisoRepo, _usuarioRepo, + _security, NullLogger.Instance); } diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs index e009f00..103300b 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs @@ -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(); private readonly IClientContext _clientCtx = Substitute.For(); private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For(); + private readonly ISecurityEventLogger _security = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); 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 diff --git a/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs index 2237bc4..ed07064 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs @@ -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(); + private readonly ISecurityEventLogger _security = Substitute.For(); private readonly LogoutCommandHandler _handler; public LogoutCommandHandlerTests() { - _handler = new LogoutCommandHandler(_refreshRepo); + _handler = new LogoutCommandHandler(_refreshRepo, _security); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs index b0907eb..7423cf0 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs @@ -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(); private readonly IRefreshTokenGenerator _generator = Substitute.For(); private readonly IClientContext _clientCtx = Substitute.For(); + private readonly ISecurityEventLogger _security = Substitute.For(); 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 -- 2.49.1 From 2bb90118ab126df804a41676746b37d32ccbf89b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 17:05:40 -0300 Subject: [PATCH 12/14] feat(api): GET /audit/events + /health/audit (UDT-010 B10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuditController: - GET /api/v1/audit/events?actorUserId&targetType&targetId&from&to&cursor&limit - Protected by [RequirePermission("administracion:auditoria:ver")] — reuses the existing permission (V005/V006 seed assigns it to admin). - 400 on limit out of [1,100] or from > to. - Cursor-based DESC pagination via AuditEventRepository.QueryAsync. AuditHealthCheck (IHealthCheck): - Validates SYSTEM_VERSIONING ON on Usuario/Rol/Permiso/RolPermiso. - Validates partition boundaries exist for next 3 months (both AuditEvent and SecurityEvent functions). - Reports last audit event age (lenient 24h to accommodate dev/test quiet envs). - Validates HISTORY_RETENTION_PERIOD == 10 YEARS on all 4 tables. Key fix during impl: sys.tables.history_retention_period is stored in UNITS (1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR), NOT seconds. Assertion: period=10 AND unit=6 (10 YEARS). - Mapped at /health/audit via app.MapHealthChecks with tag 'audit'. Tests (Strict TDD, integration against SIGCM2_Test): - AuditControllerTests (5): without-auth 401, without-permission 403 (cajero), admin with filter returns events, invalid limit 400, from>to 400. - AuditHealthCheckTests (1): returns Healthy with V010 applied. Suite: 378/378 Application.Tests + 147/147 Api.Tests = 525/525 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-7/8, design, tasks#B10} --- .../SIGCM2.Api/Controllers/AuditController.cs | 63 +++++++++ .../HealthChecks/AuditHealthCheck.cs | 126 ++++++++++++++++++ src/api/SIGCM2.Api/Program.cs | 11 ++ .../Audit/AuditControllerTests.cs | 126 ++++++++++++++++++ .../Audit/AuditHealthCheckTests.cs | 30 +++++ 5 files changed, 356 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/AuditController.cs create mode 100644 src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs create mode 100644 tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs create mode 100644 tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/AuditController.cs b/src/api/SIGCM2.Api/Controllers/AuditController.cs new file mode 100644 index 0000000..0bee30f --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/AuditController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Audit; + +namespace SIGCM2.Api.Controllers; + +/// +/// 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. +/// +[ApiController] +[Route("api/v1/audit")] +public sealed class AuditController : ControllerBase +{ + private readonly IAuditEventRepository _repo; + + public AuditController(IAuditEventRepository repo) + { + _repo = repo; + } + + /// Lists audit events with optional filters. Cursor-based DESC pagination. + [HttpGet("events")] + [RequirePermission("administracion:auditoria:ver")] + [ProducesResponseType(typeof(AuditEventPageResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task 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)); + } +} + +/// UDT-010: Paginated response wrapper for GET /api/v1/audit/events. +public sealed record AuditEventPageResponse( + IReadOnlyList Items, + string? NextCursor); diff --git a/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs b/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs new file mode 100644 index 0000000..36d781e --- /dev/null +++ b/src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs @@ -0,0 +1,126 @@ +using Dapper; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Api.HealthChecks; + +/// +/// 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. +/// +public sealed class AuditHealthCheck : IHealthCheck +{ + private readonly SqlConnectionFactory _factory; + + public AuditHealthCheck(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task 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(""" + 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(""" + 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( + "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 + { + ["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); + } + } +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index cc45135..69fdac1 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -2,6 +2,7 @@ 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; @@ -38,6 +39,10 @@ builder.Services.AddControllers(opts => // OpenAPI / Scalar builder.Services.AddOpenApi(); +// UDT-010: Audit infrastructure health check +builder.Services.AddHealthChecks() + .AddCheck("audit", tags: new[] { "audit" }); + // CORS var allowedOrigins = builder.Configuration .GetSection("Cors:AllowedOrigins") @@ -76,6 +81,12 @@ app.UseMiddleware(); 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 diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs new file mode 100644 index 0000000..ee7ddd3 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/AuditControllerTests.cs @@ -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 +{ + 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("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'"); + + var client = _factory.CreateClient(); + var jwt = _factory.Services.GetRequiredService(); + 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(); + // 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(); + 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); + } +} diff --git a/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs b/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs new file mode 100644 index 0000000..1802d0b --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Audit/AuditHealthCheckTests.cs @@ -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 +{ + 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"); + } +} -- 2.49.1 From b526df2125308217c1b9ca02ad949b4e1a10cb7b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 17:07:13 -0300 Subject: [PATCH 13/14] feat(web): /admin/audit page + filtros (UDT-010 B12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only audit timeline per design #D-9. Delegated to sub-agent, completed before rate limit cutoff; verified with vitest 161/161 passing. New files: - src/web/src/api/audit.ts — axios client: listAuditEvents(filter) - src/web/src/features/admin/audit/useAuditEvents.ts — TanStack Query hook - src/web/src/pages/admin/audit/AuditPage.tsx — DataTable + 4 filters + cursor pagination 'Cargar más' button. Columns: OccurredAt (local time formatted), ActorUsername, Action (badge), TargetType + TargetId, IpAddress, CorrelationId (copy button with toast). - src/web/src/pages/admin/audit/AuditFilters.tsx — 4 filters form. - src/web/src/tests/features/admin/audit/useAuditEvents.test.ts — hook unit. - src/web/src/tests/features/admin/audit/AuditPage.test.tsx — component test with MSW handler mock. Modified: - src/web/src/router.tsx — /admin/audit route, protected by auth + permission 'administracion:auditoria:ver'. - src/web/src/components/layout/AppSidebar.tsx — sidebar entry (icon, visible only with the required permission, uses existing permission-filtering pattern). OUT of scope (deferred to ADM-004): - Row drilldown modal with full metadata JSON formatted. - CSV export. - Timeline-per-entity visualization. Design System v2.4 conventions respected: DataTable component from @/components/ui/data-table (no raw ), tokens only (no hex inline), density compact, Radix tooltip for copy button, sonner toast on copy. Vitest run: 29 test files / 161 tests passing. No regressions in existing frontend tests. Refs: sdd/udt-010-auditoria-trazabilidad/{spec, design#D-9, tasks#B12} --- src/web/src/api/audit.ts | 71 +++++ src/web/src/components/layout/AppSidebar.tsx | 31 +- .../features/admin/audit/useAuditEvents.ts | 25 ++ .../src/pages/admin/audit/AuditFilters.tsx | 160 ++++++++++ src/web/src/pages/admin/audit/AuditPage.tsx | 285 ++++++++++++++++++ src/web/src/router.tsx | 10 + .../features/admin/audit/AuditPage.test.tsx | 254 ++++++++++++++++ .../admin/audit/useAuditEvents.test.ts | 137 +++++++++ 8 files changed, 965 insertions(+), 8 deletions(-) create mode 100644 src/web/src/api/audit.ts create mode 100644 src/web/src/features/admin/audit/useAuditEvents.ts create mode 100644 src/web/src/pages/admin/audit/AuditFilters.tsx create mode 100644 src/web/src/pages/admin/audit/AuditPage.tsx create mode 100644 src/web/src/tests/features/admin/audit/AuditPage.test.tsx create mode 100644 src/web/src/tests/features/admin/audit/useAuditEvents.test.ts diff --git a/src/web/src/api/audit.ts b/src/web/src/api/audit.ts new file mode 100644 index 0000000..dcf8be5 --- /dev/null +++ b/src/web/src/api/audit.ts @@ -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 { + 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( + '/api/v1/audit/events', + { params }, + ) + return response.data +} diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 0ca5055..7263153 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -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 && ( <> Administración - {adminItems.map((item) => ( - - ))} + {adminItems + .filter( + (item) => + !item.requiredPermission || + user?.permisos.includes(item.requiredPermission), + ) + .map((item) => ( + + ))} )} diff --git a/src/web/src/features/admin/audit/useAuditEvents.ts b/src/web/src/features/admin/audit/useAuditEvents.ts new file mode 100644 index 0000000..4692fea --- /dev/null +++ b/src/web/src/features/admin/audit/useAuditEvents.ts @@ -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({ + queryKey: auditEventsQueryKey(filter), + queryFn: () => listAuditEvents(filter), + staleTime: 15_000, + }) +} diff --git a/src/web/src/pages/admin/audit/AuditFilters.tsx b/src/web/src/pages/admin/audit/AuditFilters.tsx new file mode 100644 index 0000000..f55cc9f --- /dev/null +++ b/src/web/src/pages/admin/audit/AuditFilters.tsx @@ -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(initialValue) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + onApply(value) + } + + function handleReset() { + setValue(EMPTY_FILTERS) + onReset() + } + + return ( +
+
+
+ + setValue((v) => ({ ...v, actor: e.target.value }))} + placeholder="Ej: 42" + /> +
+ +
+ + + setValue((v) => ({ ...v, targetType: e.target.value })) + } + placeholder="Ej: User, Role, Permission" + /> +
+ +
+ + + setValue((v) => ({ ...v, targetId: e.target.value })) + } + placeholder="Ej: 123 o un GUID" + /> +
+ +
+ + setValue((v) => ({ ...v, from: e.target.value }))} + /> +
+ +
+ + setValue((v) => ({ ...v, to: e.target.value }))} + /> +
+
+ +
+ + +
+ + ) +} + +/** + * 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 +} diff --git a/src/web/src/pages/admin/audit/AuditPage.tsx b/src/web/src/pages/admin/audit/AuditPage.tsx new file mode 100644 index 0000000..b16e960 --- /dev/null +++ b/src/web/src/pages/admin/audit/AuditPage.tsx @@ -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 { + 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(EMPTY_FILTERS) + // Cursor actual (undefined = primera página). + const [cursor, setCursor] = useState(undefined) + // Acumulador de items entre páginas ("cargar más"). + const [accumulated, setAccumulated] = useState([]) + + const apiFilter = useMemo(() => { + 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[]>( + () => [ + { + accessorKey: 'occurredAt', + header: 'Fecha', + cell: ({ row }) => ( + + {formatOccurredAt(row.original.occurredAt)} + + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'actorUsername', + header: 'Usuario', + cell: ({ row }) => { + const u = row.original.actorUsername + if (!u) { + return ( + + sistema + + ) + } + return {u} + }, + meta: { priority: 'high' }, + }, + { + accessorKey: 'action', + header: 'Acción', + cell: ({ row }) => ( + + {row.original.action} + + ), + meta: { priority: 'high' }, + }, + { + id: 'target', + header: 'Entidad', + cell: ({ row }) => ( +
+ + {row.original.targetType} + + + {row.original.targetId} + +
+ ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'ipAddress', + header: 'IP', + cell: ({ row }) => ( + + {row.original.ipAddress ?? '—'} + + ), + meta: { priority: 'low' }, + }, + { + accessorKey: 'correlationId', + header: 'Correlation', + cell: ({ row }) => { + const cid = row.original.correlationId + if (!cid) return + const short = cid.length > 10 ? `${cid.slice(0, 8)}…` : cid + return ( +
+ + {short} + + + + + + Copiar correlation ID + +
+ ) + }, + meta: { priority: 'low' }, + }, + ], + [], + ) + + const hasMore = Boolean(data?.nextCursor) + + return ( +
+
+
+

+ Auditoría +

+

+ Historial de eventos del sistema. Resultados paginados por cursor. +

+
+
+ + + + {isError ? ( +
+ No se pudieron cargar los eventos de auditoría. Intentá de nuevo. +
+ ) : ( + String(row.id)} + isLoading={isLoading && accumulated.length === 0} + emptyMessage="Sin resultados — no se encontraron eventos con los filtros seleccionados." + /> + )} + +
+ + {accumulated.length > 0 + ? `${accumulated.length} evento${accumulated.length !== 1 ? 's' : ''} cargado${accumulated.length !== 1 ? 's' : ''}` + : ''} + + +
+ + {/* + TODOs para ADM-004: + - Drill-down del evento (modal con metadata JSON formatted) + - Export CSV de los resultados filtrados + - Timeline visualization por entidad + */} +
+ ) +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 9feac6f..2a83906 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -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() { } /> + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/admin/audit/AuditPage.test.tsx b/src/web/src/tests/features/admin/audit/AuditPage.test.tsx new file mode 100644 index 0000000..f242ba2 --- /dev/null +++ b/src/web/src/tests/features/admin/audit/AuditPage.test.tsx @@ -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 = {}) { + 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( + + + + + + + , + ) +} + +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('') + }) +}) diff --git a/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts b/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts new file mode 100644 index 0000000..3ff6f80 --- /dev/null +++ b/src/web/src/tests/features/admin/audit/useAuditEvents.test.ts @@ -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=') + }) +}) -- 2.49.1 From 9eac044752971c24667df125e0c6b9a01bf88d60 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 17:10:43 -0300 Subject: [PATCH 14/14] feat(jobs): 3 audit maintenance jobs (Quartz.NET, UDT-010 B11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega Quartz.Extensions.Hosting 3.13.1 al catálogo central. SIGCM2.Infrastructure/Audit/Jobs/: - AuditPartitionManagerJob — mensual (cron '0 0 2 1 * ?', UTC). Extiende pf_AuditEvent_Monthly y pf_SecurityEvent_Monthly con SPLIT RANGE para el mes+2 (mantiene +1 de buffer). Idempotente: verifica existencia antes. - AuditRetentionEnforcerJob — anual (cron '0 0 3 1 1 ?', UTC). DELETE rows > 10 años en AuditEvent y > 5 años en SecurityEvent. Temporal history se purga solo vía HISTORY_RETENTION_PERIOD del engine. - AuditIntegrityCheckJob — semanal domingos (cron '0 0 1 ? * SUN', UTC). Valida SYSTEM_VERSIONING=ON + partitions próximos 3 meses. Emite SecurityEvent 'system.integrity_alert' failure via ISecurityEventLogger cuando detecta inconsistencias. AuditMaintenanceRegistration.cs: - services.AddAuditMaintenance(configuration) wraps AddQuartz + AddQuartzHostedService con los 3 triggers crónicos. Program.cs: - builder.Services.AddAuditMaintenance(configuration) wired ONLY en entornos productivos — skipeado en 'Testing' para que los integration tests no disparen los triggers cron durante el ciclo de vida del TestWebAppFactory. Row-based DELETE en RetentionEnforcerJob es la opción conservadora para la primera generación — cuando los volúmenes lo justifiquen (>200M filas), se upgradea a SWITCH OUT + DROP para partition-level drop. Documentado en comentario de la clase. Tests (Strict TDD, integration): - AuditJobsTests (3): PartitionManager crea target boundary + idempotencia, RetentionEnforcer purga > threshold (10y audit, 5y security), IntegrityCheck all-OK no emite alert. Suite: 381/381 Application.Tests + 147/147 Api.Tests = 528/528 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-6 #REQ-SEC-5, design, tasks#B11} --- Directory.Packages.props | 1 + src/api/SIGCM2.Api/Program.cs | 6 + .../Audit/Jobs/AuditIntegrityCheckJob.cs | 95 ++++++++++++++ .../Jobs/AuditMaintenanceRegistration.cs | 43 ++++++ .../Audit/Jobs/AuditPartitionManagerJob.cs | 64 +++++++++ .../Audit/Jobs/AuditRetentionEnforcerJob.cs | 57 ++++++++ .../SIGCM2.Infrastructure.csproj | 1 + .../Infrastructure/Audit/AuditJobsTests.cs | 123 ++++++++++++++++++ 8 files changed, 390 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs create mode 100644 src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 332e9a2..d3074f0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs index 69fdac1..92ab9f8 100644 --- a/src/api/SIGCM2.Api/Program.cs +++ b/src/api/SIGCM2.Api/Program.cs @@ -6,6 +6,7 @@ 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 @@ -25,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(); diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs new file mode 100644 index 0000000..f6b2c3e --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditIntegrityCheckJob.cs @@ -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; + +/// +/// 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). +/// +[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 _logger; + + public AuditIntegrityCheckJob( + SqlConnectionFactory factory, + ISecurityEventLogger security, + ILogger logger) + { + _factory = factory; + _security = security; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + var failures = new List(); + + await using var conn = _factory.CreateConnection(); + await conn.OpenAsync(ct); + + // 1. SYSTEM_VERSIONING still ON + var missing = (await conn.QueryAsync(""" + 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(""" + 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"); + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs new file mode 100644 index 0000000..17c182c --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditMaintenanceRegistration.cs @@ -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(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(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(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; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs new file mode 100644 index 0000000..e980787 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditPartitionManagerJob.cs @@ -0,0 +1,64 @@ +using Dapper; +using Microsoft.Extensions.Logging; +using Quartz; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Infrastructure.Audit.Jobs; + +/// +/// 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. +/// +[DisallowConcurrentExecution] +public sealed class AuditPartitionManagerJob : IJob +{ + public const string CronSchedule = "0 0 2 1 * ?"; + + private readonly SqlConnectionFactory _factory; + private readonly ILogger _logger; + + public AuditPartitionManagerJob(SqlConnectionFactory factory, ILogger 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(""" + 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); + } +} diff --git a/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs new file mode 100644 index 0000000..054c06a --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/Jobs/AuditRetentionEnforcerJob.cs @@ -0,0 +1,57 @@ +using Dapper; +using Microsoft.Extensions.Logging; +using Quartz; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Infrastructure.Audit.Jobs; + +/// +/// 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. +/// +[DisallowConcurrentExecution] +public sealed class AuditRetentionEnforcerJob : IJob +{ + public const string CronSchedule = "0 0 3 1 1 ?"; + + private readonly SqlConnectionFactory _factory; + private readonly ILogger _logger; + + public AuditRetentionEnforcerJob(SqlConnectionFactory factory, ILogger 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); + } +} diff --git a/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj index cf325e1..53083e9 100644 --- a/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj +++ b/src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs new file mode 100644 index 0000000..1753434 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditJobsTests.cs @@ -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(); + ctx.CancellationToken.Returns(CancellationToken.None); + return ctx; + } + + [Fact] + public async Task PartitionManager_ExtendsFunctionForward_Idempotent() + { + var job = new AuditPartitionManagerJob(_factory, NullLogger.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(""" + 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.Instance); + await job.Execute(MockContext()); + + var auditCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.AuditEvent;"); + var securityCount = await _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM dbo.SecurityEvent;"); + auditCount.Should().Be(1); + securityCount.Should().Be(1); + } + + [Fact] + public async Task IntegrityCheck_AllOk_DoesNotEmitSecurityEvent() + { + var security = Substitute.For(); + var job = new AuditIntegrityCheckJob(_factory, security, NullLogger.Instance); + + // Ensure partition manager has run first so next-3-months exist + await new AuditPartitionManagerJob(_factory, NullLogger.Instance).Execute(MockContext()); + + await job.Execute(MockContext()); + + await security.DidNotReceive().LogAsync( + action: "system.integrity_alert", + result: Arg.Any(), + actorUserId: Arg.Any(), + attemptedUsername: Arg.Any(), + sessionId: Arg.Any(), + failureReason: Arg.Any(), + metadata: Arg.Any(), + ct: Arg.Any()); + } +} -- 2.49.1