Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Usuarios/Create/CreateUsuarioCommandHandlerTests.cs

174 lines
6.6 KiB
C#
Raw Normal View History

using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
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
using SIGCM2.Application.Audit;
using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Usuarios.Create;
public class CreateUsuarioCommandHandlerTests
{
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
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
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly CreateUsuarioCommandHandler _handler;
private static CreateUsuarioCommand ValidCommand() => new(
Username: "operador1",
Password: "Secreto123",
Nombre: "Juan",
Apellido: "Pérez",
Email: null,
Rol: "vendedor");
public CreateUsuarioCommandHandlerTests()
{
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
_handler = new CreateUsuarioCommandHandler(_repository, _hasher, _audit);
}
// ── exists → throws ──────────────────────────────────────────────────────
[Fact]
public async Task Handle_UsernameAlreadyExists_ThrowsUsernameAlreadyExistsException()
{
_repository.ExistsByUsernameAsync("operador1", Arg.Any<CancellationToken>())
.Returns(true);
await Assert.ThrowsAsync<UsernameAlreadyExistsException>(
() => _handler.Handle(ValidCommand()));
}
[Fact]
public async Task Handle_UsernameAlreadyExists_DoesNotCallAddAsync()
{
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(true);
try { await _handler.Handle(ValidCommand()); } catch (UsernameAlreadyExistsException) { }
await _repository.DidNotReceive().AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UsernameAlreadyExists_ExceptionContainsUsername()
{
_repository.ExistsByUsernameAsync("operador1", Arg.Any<CancellationToken>())
.Returns(true);
var ex = await Assert.ThrowsAsync<UsernameAlreadyExistsException>(
() => _handler.Handle(ValidCommand()));
Assert.Equal("operador1", ex.Username);
}
// ── happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_HashesPasswordBeforePersisting()
{
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_hasher.Hash("Secreto123").Returns("$2a$12$hashed");
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(42);
await _handler.Handle(ValidCommand());
// AddAsync must be called with the hashed value, not the plain password
await _repository.Received(1).AddAsync(
Arg.Is<Usuario>(u => u.PasswordHash == "$2a$12$hashed"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_NeverPersistsPlainPassword()
{
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(1);
await _handler.Handle(ValidCommand());
await _repository.Received(1).AddAsync(
Arg.Is<Usuario>(u => u.PasswordHash != "Secreto123"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_CallsAddAsyncOnce()
{
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(7);
await _handler.Handle(ValidCommand());
await _repository.Received(1).AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository()
{
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(42);
var result = await _handler.Handle(ValidCommand());
Assert.Equal(42, result.Id);
}
[Fact]
public async Task Handle_HappyPath_DtoContainsCorrectFields()
{
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(10);
var cmd = new CreateUsuarioCommand("user1", "Pass1234", "Ana", "García", "ana@example.com", "admin");
var result = await _handler.Handle(cmd);
Assert.Equal("user1", result.Username);
Assert.Equal("Ana", result.Nombre);
Assert.Equal("García", result.Apellido);
Assert.Equal("ana@example.com", result.Email);
Assert.Equal("admin", result.Rol);
Assert.True(result.Activo);
}
[Fact]
public async Task Handle_HappyPath_DtoDoesNotContainPasswordHash()
{
// UsuarioCreatedDto must not expose PasswordHash — compile-time check via reflection
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$secret");
_repository.AddAsync(Arg.Any<Usuario>(), Arg.Any<CancellationToken>()).Returns(1);
var result = await _handler.Handle(ValidCommand());
var props = result.GetType().GetProperties().Select(p => p.Name);
Assert.DoesNotContain("PasswordHash", props);
}
[Fact]
public async Task Handle_HappyPath_NewUserIsActive()
{
_repository.ExistsByUsernameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
_hasher.Hash(Arg.Any<string>()).Returns("$2a$12$hashed");
_repository.AddAsync(
Arg.Is<Usuario>(u => u.Activo && u.PermisosJson == "[]"),
Arg.Any<CancellationToken>()).Returns(5);
var result = await _handler.Handle(ValidCommand());
Assert.True(result.Activo);
}
}