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(); context ??= Substitute.For(); options ??= new AuditOptions(); return new SecurityEventLogger(repo, context, Options.Create(options)); } [Fact] public async Task LogAsync_LoginSuccess_PassesActorAndIpFromContext() { var repo = Substitute.For(); var context = Substitute.For(); 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(), 42, Arg.Is(s => s == null), // attemptedUsername Arg.Is(g => g == null), // sessionId "login", "success", Arg.Is(s => s == null), // failureReason "1.2.3.4", "ua", Arg.Is(s => s == null), // metadata Arg.Any()); } [Fact] public async Task LogAsync_LoginFailure_SupportsNullActorAndAttemptedUsername() { var repo = Substitute.For(); var logger = Build(repo); await logger.LogAsync("login", "failure", actorUserId: null, attemptedUsername: "juan", failureReason: "invalid_password"); await repo.Received(1).InsertAsync( Arg.Any(), Arg.Is(i => i == null), "juan", Arg.Is(g => g == null), "login", "failure", "invalid_password", Arg.Any(), Arg.Any(), Arg.Is(s => s == null), Arg.Any()); } [Fact] public async Task LogAsync_SanitizesMetadata() { var repo = Substitute.For(); 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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Is(m => m != null && !m.Contains("\"token\"") && m.Contains("\"ip\"")), Arg.Any()); } [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(); var logger = Build(repo); var act = async () => await logger.LogAsync("permission.denied", "failure"); await act.Should().NotThrowAsync(); } }