From 26efb74c221028eddac74f55a2e646d4661f4d71 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:49:44 -0300 Subject: [PATCH] =?UTF-8?q?feat(audit):=20enchufar=20audit=20en=20handlers?= =?UTF-8?q?=20de=20Usuario=20=E2=80=94=20Closes=20#6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 command handlers del módulo Usuarios ahora auditan via IAuditLogger: | Handler | Action | |-----------------------------------------|-------------------------| | CreateUsuarioCommandHandler | usuario.create | | UpdateUsuarioCommandHandler | usuario.update | | DeactivateUsuarioCommandHandler | usuario.deactivate | | ReactivateUsuarioCommandHandler | usuario.reactivate | | ChangeMyPasswordCommandHandler | usuario.password_change | | ResetUsuarioPasswordCommandHandler | usuario.password_reset | | UpdateUsuarioPermisosOverridesHandler | usuario.permisos_update | Patrón por handler (per design #D-1): using (var tx = new TransactionScope(Required, ReadCommitted, AsyncFlowEnabled)) { await repo.UpdateAsync(...); await audit.LogAsync(...); tx.Complete(); } // post-commit reads OUTSIDE the using block var updated = await repo.GetDetailAsync(...); Metadata captured: - usuario.create: after={username, nombre, apellido, email, rol} — NO password. - usuario.update: {before, after} diff of editable fields. - usuario.password_reset: {targetId} only — tempPassword is NEVER persisted to audit (returned to caller once, never stored). - usuario.permisos_update: {before, after} of grant/deny override lists. Key fix during implementation: initially used 'using var tx = ...' (bare declaration). This kept the TransactionScope active for the rest of the method, causing 'The current TransactionScope is already complete' when post-commit reads (GetDetailAsync) tried to enlist. Solution: explicit 'using (var tx = ...) { ... }' block that disposes the scope before post-commit reads. AuditContextMissingException surfaces from AuditLogger when IAuditContext lacks ActorUserId — fail-closed per #REQ-AUD-4. In integration tests, the middleware populates ActorUserId from the JWT sub of the authenticated admin. Test updates: 6 existing unit test classes now inject IAuditLogger mock: - CreateUsuarioCommandHandlerTests - UpdateUsuarioCommandHandlerTests - DeactivateUsuarioCommandHandlerTests - ReactivateUsuarioCommandHandlerTests - ChangeMyPasswordCommandHandlerTests - ResetUsuarioPasswordCommandHandlerTests Follow-up #6 ([Auditoría] Registrar admin creador en alta de usuarios) is closed: CreateUsuarioCommandHandler now records ActorUserId = admin JWT sub on every user creation. TODO comment removed. Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Closes #6 Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-UM-AUD, design, tasks#B7} --- .../ChangeMyPasswordCommandHandler.cs | 21 +++++++++- .../Create/CreateUsuarioCommandHandler.cs | 32 ++++++++++++++- .../DeactivateUsuarioCommandHandler.cs | 26 ++++++++++-- ...eUsuarioPermisosOverridesCommandHandler.cs | 35 +++++++++++++--- .../ReactivateUsuarioCommandHandler.cs | 23 +++++++++-- .../ResetUsuarioPasswordCommandHandler.cs | 23 +++++++++-- .../Update/UpdateUsuarioCommandHandler.cs | 40 +++++++++++++++---- .../ChangeMyPasswordCommandHandlerTests.cs | 4 +- .../CreateUsuarioCommandHandlerTests.cs | 4 +- .../DeactivateUsuarioCommandHandlerTests.cs | 4 +- .../ReactivateUsuarioCommandHandlerTests.cs | 4 +- ...ResetUsuarioPasswordCommandHandlerTests.cs | 4 +- .../UpdateUsuarioCommandHandlerTests.cs | 4 +- 13 files changed, 191 insertions(+), 33 deletions(-) diff --git a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs index 60df389..60e6c3b 100644 --- a/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs +++ b/src/api/SIGCM2.Application/Usuarios/ChangeMyPassword/ChangeMyPasswordCommandHandler.cs @@ -1,6 +1,8 @@ +using System.Transactions; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Domain.Exceptions; @@ -10,13 +12,16 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler Handle(ChangeMyPasswordCommand cmd) @@ -28,9 +33,21 @@ public sealed class ChangeMyPasswordCommandHandler : ICommandHandler Handle(CreateUsuarioCommand command) @@ -37,9 +42,32 @@ public sealed class CreateUsuarioCommandHandler : ICommandHandler Handle(DeactivateUsuarioCommand cmd) @@ -39,10 +44,23 @@ public sealed class DeactivateUsuarioCommandHandler : ICommandHandler Handle(UpdateUsuarioPermisosOverridesCommand command) @@ -53,11 +58,31 @@ public sealed class UpdateUsuarioPermisosOverridesCommandHandler // 4. Persist — use WithPermisosJson to get updated FechaModificacion var newOverrides = new PermisosOverride(grant, deny); + var previousOverrides = PermisosOverride.FromJson(usuario.PermisosJson); var updated = usuario.WithPermisosJson(newOverrides.ToJson()); - await _usuarioRepo.UpdatePermisosJsonAsync( - updated.Id, - updated.PermisosJson, - updated.FechaModificacion!.Value); + + using (var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled)) + { + await _usuarioRepo.UpdatePermisosJsonAsync( + updated.Id, + updated.PermisosJson, + updated.FechaModificacion!.Value); + + await _audit.LogAsync( + action: "usuario.permisos_update", + targetType: "Usuario", + targetId: command.Id.ToString(), + metadata: new + { + before = new { grant = previousOverrides.Grant, deny = previousOverrides.Deny }, + after = new { grant = grant, deny = deny }, + }); + + tx.Complete(); + } // 5. Return updated effective set var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(updated.Rol); diff --git a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs index 3c56919..c89a473 100644 --- a/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs +++ b/src/api/SIGCM2.Application/Usuarios/Reactivate/ReactivateUsuarioCommandHandler.cs @@ -1,5 +1,7 @@ +using System.Transactions; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Application.Usuarios.GetById; using SIGCM2.Domain.Exceptions; @@ -9,10 +11,12 @@ namespace SIGCM2.Application.Usuarios.Reactivate; public sealed class ReactivateUsuarioCommandHandler : ICommandHandler { private readonly IUsuarioRepository _repository; + private readonly IAuditLogger _audit; - public ReactivateUsuarioCommandHandler(IUsuarioRepository repository) + public ReactivateUsuarioCommandHandler(IUsuarioRepository repository, IAuditLogger audit) { _repository = repository; + _audit = audit; } public async Task Handle(ReactivateUsuarioCommand cmd) @@ -31,9 +35,22 @@ public sealed class ReactivateUsuarioCommandHandler : ICommandHandler Handle(ResetUsuarioPasswordCommand cmd) @@ -32,13 +37,25 @@ public sealed class ResetUsuarioPasswordCommandHandler : ICommandHandler Handle(UpdateUsuarioCommand cmd) @@ -48,17 +53,36 @@ public sealed class UpdateUsuarioCommandHandler : ICommandHandler(); private readonly IPasswordHasher _hasher = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly ChangeMyPasswordCommandHandler _handler; public ChangeMyPasswordCommandHandlerTests() { - _handler = new ChangeMyPasswordCommandHandler(_repo, _hasher); + _handler = new ChangeMyPasswordCommandHandler(_repo, _hasher, _audit); } private static Usuario MakeUser(int id = 1, bool mustChangePassword = false) diff --git a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs index 52a7c74..a386861 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs @@ -1,6 +1,7 @@ using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; using SIGCM2.Application.Usuarios.Create; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; @@ -11,6 +12,7 @@ public class CreateUsuarioCommandHandlerTests { private readonly IUsuarioRepository _repository = Substitute.For(); private readonly IPasswordHasher _hasher = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly CreateUsuarioCommandHandler _handler; private static CreateUsuarioCommand ValidCommand() => new( @@ -23,7 +25,7 @@ public class CreateUsuarioCommandHandlerTests public CreateUsuarioCommandHandlerTests() { - _handler = new CreateUsuarioCommandHandler(_repository, _hasher); + _handler = new CreateUsuarioCommandHandler(_repository, _hasher, _audit); } // ── exists → throws ────────────────────────────────────────────────────── diff --git a/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs index a9161a3..7d7a69c 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/DeactivateUsuarioCommandHandlerTests.cs @@ -1,5 +1,6 @@ using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Application.Usuarios.Deactivate; using SIGCM2.Domain.Entities; @@ -11,11 +12,12 @@ public class DeactivateUsuarioCommandHandlerTests { private readonly IUsuarioRepository _repo = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly DeactivateUsuarioCommandHandler _handler; public DeactivateUsuarioCommandHandlerTests() { - _handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo); + _handler = new DeactivateUsuarioCommandHandler(_repo, _refreshRepo, _audit); _repo.CountActiveAdminsAsync(Arg.Any()).Returns(2); } diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs index 20cc2ca..bceb7cd 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/ReactivateUsuarioCommandHandlerTests.cs @@ -1,5 +1,6 @@ using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Application.Usuarios.Reactivate; using SIGCM2.Domain.Entities; @@ -10,11 +11,12 @@ namespace SIGCM2.Application.Tests.Usuarios; public class ReactivateUsuarioCommandHandlerTests { private readonly IUsuarioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly ReactivateUsuarioCommandHandler _handler; public ReactivateUsuarioCommandHandlerTests() { - _handler = new ReactivateUsuarioCommandHandler(_repo); + _handler = new ReactivateUsuarioCommandHandler(_repo, _audit); } private static Usuario MakeUser(int id = 5, bool activo = false) diff --git a/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs index d7a92ab..d0b0ab2 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/ResetUsuarioPasswordCommandHandlerTests.cs @@ -1,6 +1,7 @@ using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; using SIGCM2.Application.Usuarios.ResetPassword; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; @@ -12,11 +13,12 @@ public class ResetUsuarioPasswordCommandHandlerTests private readonly IUsuarioRepository _repo = Substitute.For(); private readonly IPasswordHasher _hasher = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly ResetUsuarioPasswordCommandHandler _handler; public ResetUsuarioPasswordCommandHandlerTests() { - _handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo); + _handler = new ResetUsuarioPasswordCommandHandler(_repo, _hasher, _refreshRepo, _audit); _hasher.Hash(Arg.Any()).Returns(args => "$2a$12$hashof_" + args[0]); } diff --git a/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs index fab516d..b634c72 100644 --- a/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Usuarios/UpdateUsuarioCommandHandlerTests.cs @@ -2,6 +2,7 @@ using FluentValidation; using NSubstitute; using NSubstitute.ExceptionExtensions; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Application.Usuarios.Update; using SIGCM2.Domain.Entities; @@ -14,11 +15,12 @@ public class UpdateUsuarioCommandHandlerTests private readonly IUsuarioRepository _repo = Substitute.For(); private readonly IRolRepository _rolRepo = Substitute.For(); private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); private readonly UpdateUsuarioCommandHandler _handler; public UpdateUsuarioCommandHandlerTests() { - _handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo); + _handler = new UpdateUsuarioCommandHandler(_repo, _rolRepo, _refreshRepo, _audit); // Default: rol exists and is active _rolRepo.ExistsActiveByCodigoAsync(Arg.Any(), Arg.Any()).Returns(true);