Compare commits

...

10 Commits

Author SHA1 Message Date
9eac044752 feat(jobs): 3 audit maintenance jobs (Quartz.NET, UDT-010 B11)
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}
2026-04-16 17:10:43 -03:00
b526df2125 feat(web): /admin/audit page + filtros (UDT-010 B12)
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 <table>), 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}
2026-04-16 17:07:13 -03:00
2bb90118ab feat(api): GET /audit/events + /health/audit (UDT-010 B10)
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}
2026-04-16 17:05:40 -03:00
b619c05762 feat(audit): security events en Auth + authorization handlers (UDT-010 B9)
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}
2026-04-16 13:59:27 -03:00
a3f01bc6c9 feat(audit): enchufar audit en handlers de Rol (UDT-010 B8)
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}
2026-04-16 13:54:47 -03:00
26efb74c22 feat(audit): enchufar audit en handlers de Usuario — Closes #6
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}
2026-04-16 13:49:44 -03:00
a3d6214d09 feat(infra): AuditLogger + SecurityEventLogger impl (UDT-010 B6)
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<T?>(x => x == null) —
NSubstitute does not always match null literals when mixed with Arg.Any<T>().

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}
2026-04-16 13:41:10 -03:00
300badda73 feat(infra): audit + security event repositories (UDT-010 B5)
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}
2026-04-16 13:38:05 -03:00
0b4af4c332 feat(api): audit context middleware + scoped impl (UDT-010 B4)
Wires the request-scoped audit context per design #D-2:

Middleware pipeline in Program.cs:
  app.UseCors()
  app.UseMiddleware<CorrelationIdMiddleware>()  // PRE-AUTH
  app.UseAuthentication()
  app.UseMiddleware<AuditActorMiddleware>()     // 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<IAuditContext, AuditContext>()

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}
2026-04-16 13:32:13 -03:00
08d6622e43 feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3)
Adds the metadata sanitization layer per #REQ-AUD-5:

SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class):
- Sanitize(object?, IReadOnlyCollection<string>) -> 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<AuditOptions>(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}
2026-04-16 13:28:37 -03:00
71 changed files with 3811 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
namespace SIGCM2.Application.Audit; 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 sealed class AuditOptions
{ {
public const string SectionName = "Audit"; public const string SectionName = "Audit";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
using System.Transactions;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Exceptions;
@@ -10,15 +12,18 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
private readonly IRolRepository _rolRepository; private readonly IRolRepository _rolRepository;
private readonly IPermisoRepository _permisoRepository; private readonly IPermisoRepository _permisoRepository;
private readonly IRolPermisoRepository _rolPermisoRepository; private readonly IRolPermisoRepository _rolPermisoRepository;
private readonly IAuditLogger _audit;
public AssignPermisosToRolCommandHandler( public AssignPermisosToRolCommandHandler(
IRolRepository rolRepository, IRolRepository rolRepository,
IPermisoRepository permisoRepository, IPermisoRepository permisoRepository,
IRolPermisoRepository rolPermisoRepository) IRolPermisoRepository rolPermisoRepository,
IAuditLogger audit)
{ {
_rolRepository = rolRepository; _rolRepository = rolRepository;
_permisoRepository = permisoRepository; _permisoRepository = permisoRepository;
_rolPermisoRepository = rolPermisoRepository; _rolPermisoRepository = rolPermisoRepository;
_audit = audit;
} }
public async Task<IReadOnlyList<PermisoDto>> Handle(AssignPermisosToRolCommand command) public async Task<IReadOnlyList<PermisoDto>> Handle(AssignPermisosToRolCommand command)
@@ -40,10 +45,29 @@ public sealed class AssignPermisosToRolCommandHandler : ICommandHandler<AssignPe
throw new PermisoNotFoundException(missing); throw new PermisoNotFoundException(missing);
} }
// 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) // 3. Reemplazar el set (DELETE+INSERT en transacción dentro del repo)
var permisoIds = permisos.Select(p => p.Id); var permisoIds = permisos.Select(p => p.Id);
await _rolPermisoRepository.ReplaceForRolAsync(rol.Id, permisoIds); await _rolPermisoRepository.ReplaceForRolAsync(rol.Id, permisoIds);
await _audit.LogAsync(
action: "rol.permisos_update",
targetType: "Rol",
targetId: rol.Id.ToString(),
metadata: new { before = beforeCodigos, after = afterCodigos });
tx.Complete();
}
// 4. Retornar el nuevo set asignado // 4. Retornar el nuevo set asignado
return permisos return permisos
.Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo)) .Select(p => new PermisoDto(p.Id, p.Codigo, p.Nombre, p.Descripcion, p.Modulo))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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