Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditLoggerTests.cs
dmolinari 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

155 lines
6.0 KiB
C#

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>());
}
}