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}
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user