Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Infrastructure/Audit/SecurityEventLoggerTests.cs

102 lines
3.6 KiB
C#
Raw Normal View History

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