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(); repo ??= Substitute.For(); 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(); 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(); var logger = Build(context, repo); await logger.LogAsync("usuario.create", "Usuario", "99", metadata: new { username = "juan" }); await repo.Received(1).InsertAsync( Arg.Any(), 42, 7, "usuario.create", "Usuario", "99", correlationId, "10.0.0.5", "ua/1.0", Arg.Is(m => m != null && m.Contains("\"username\"") && m.Contains("juan")), Arg.Any()); } [Fact] public async Task LogAsync_WithoutActorUserId_ThrowsAuditContextMissingException() { var context = Substitute.For(); context.ActorUserId.Returns((int?)null); context.CorrelationId.Returns(Guid.NewGuid()); var repo = Substitute.For(); var logger = Build(context, repo); var act = async () => await logger.LogAsync("usuario.create", "Usuario", "1"); await act.Should().ThrowAsync(); await repo.DidNotReceive().InsertAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task LogAsync_SanitizesMetadata_StripsBlacklistedKeys() { var context = Substitute.For(); context.ActorUserId.Returns(1); var repo = Substitute.For(); 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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Is(m => m != null && !m.Contains("\"password\"") && m.Contains("\"email\"")), Arg.Any()); } [Fact] public async Task LogAsync_NullMetadata_PassesNullToRepo() { var context = Substitute.For(); context.ActorUserId.Returns(1); var repo = Substitute.For(); var logger = Build(context, repo); await logger.LogAsync("usuario.deactivate", "Usuario", "1"); await repo.Received(1).InsertAsync( Arg.Any(), Arg.Any(), Arg.Any(), "usuario.deactivate", "Usuario", "1", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Is(m => m == null), Arg.Any()); } [Fact] public async Task LogAsync_RepositoryThrows_ExceptionBubblesUp() { var context = Substitute.For(); context.ActorUserId.Returns(1); var repo = Substitute.For(); repo.InsertAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => 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() .WithMessage("simulated db failure"); } [Fact] public async Task LogAsync_UsesCustomSanitizedKeys_FromOptions() { var context = Substitute.For(); context.ActorUserId.Returns(1); var repo = Substitute.For(); 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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Is(m => m != null && !m.Contains("\"internalId\"") && m.Contains("\"visible\"")), Arg.Any()); } }