using FluentAssertions; using NSubstitute; using SIGCM2.Application.Audit; using SIGCM2.Domain.Exceptions; using Xunit; namespace SIGCM2.Application.Tests.Audit; /// UDT-010 Batch 2 — shape contract tests for audit abstractions. /// These tests fail to compile if the interface/DTO/exception shape drifts from #D-8. public sealed class AuditAbstractionsTests { [Fact] public void IAuditContext_ExposesExpectedReadOnlyProperties() { var ctx = Substitute.For(); ctx.ActorUserId.Returns(42); ctx.ActorRoleId.Returns(7); ctx.Ip.Returns("127.0.0.1"); ctx.UserAgent.Returns("test-agent/1.0"); var corrId = Guid.NewGuid(); ctx.CorrelationId.Returns(corrId); ctx.ActorUserId.Should().Be(42); ctx.ActorRoleId.Should().Be(7); ctx.Ip.Should().Be("127.0.0.1"); ctx.UserAgent.Should().Be("test-agent/1.0"); ctx.CorrelationId.Should().Be(corrId); } [Fact] public void IAuditContext_AllowsNullActorAndConnectionMetadata() { var ctx = Substitute.For(); ctx.ActorUserId.Returns((int?)null); ctx.ActorRoleId.Returns((int?)null); ctx.Ip.Returns((string?)null); ctx.UserAgent.Returns((string?)null); ctx.ActorUserId.Should().BeNull(); ctx.ActorRoleId.Should().BeNull(); ctx.Ip.Should().BeNull(); ctx.UserAgent.Should().BeNull(); } [Fact] public async Task IAuditLogger_LogAsync_ExposesExpectedSignature() { var logger = Substitute.For(); await logger.LogAsync( action: "usuario.create", targetType: "Usuario", targetId: "42", metadata: new { username = "juan" }, ct: CancellationToken.None); await logger.Received(1).LogAsync( "usuario.create", "Usuario", "42", Arg.Any(), Arg.Any()); } [Fact] public async Task IAuditLogger_LogAsync_AllowsNullMetadata() { var logger = Substitute.For(); await logger.LogAsync("usuario.deactivate", "Usuario", "42"); await logger.Received(1).LogAsync( "usuario.deactivate", "Usuario", "42", null, Arg.Any()); } [Fact] public async Task ISecurityEventLogger_LogAsync_ExposesFullSignatureForLoginFailure() { var logger = Substitute.For(); var sessionId = Guid.NewGuid(); await logger.LogAsync( action: "login", result: "failure", actorUserId: null, attemptedUsername: "juan", sessionId: sessionId, failureReason: "invalid_password", metadata: new { ip = "1.2.3.4" }, ct: CancellationToken.None); await logger.Received(1).LogAsync( "login", "failure", null, "juan", sessionId, "invalid_password", Arg.Any(), Arg.Any()); } [Fact] public async Task ISecurityEventLogger_LogAsync_MinimalSuccessCall() { var logger = Substitute.For(); await logger.LogAsync("logout", "success", actorUserId: 42); await logger.Received(1).LogAsync( "logout", "success", 42, null, null, null, null, Arg.Any()); } [Fact] public void AuditContextMissingException_IsDomainException_WithFixedMessage() { var ex = new AuditContextMissingException(); ex.Should().BeAssignableTo(); ex.Message.Should().NotBeNullOrWhiteSpace(); ex.Message.Should().Contain("ActorUserId"); } [Fact] public void AuditOptions_HasSanitizedKeysDefault() { var opts = new AuditOptions(); opts.SanitizedKeys.Should().NotBeNull(); opts.SanitizedKeys.Should().Contain(new[] { "password", "passwordHash", "token", "refreshToken" }); } [Fact] public void AuditEventDto_IsReadonlyRecord() { var dto = new AuditEventDto( Id: 1, OccurredAt: DateTime.UtcNow, ActorUserId: 42, ActorUsername: "admin", Action: "usuario.create", TargetType: "Usuario", TargetId: "99", CorrelationId: Guid.NewGuid(), IpAddress: "1.2.3.4", Metadata: """{"after":{"username":"juan"}}"""); dto.Id.Should().Be(1); dto.Action.Should().Be("usuario.create"); dto.TargetType.Should().Be("Usuario"); } [Fact] public void AuditEventFilter_DefaultLimitIsReasonable() { var f = new AuditEventFilter( ActorUserId: null, TargetType: null, TargetId: null, From: null, To: null, Cursor: null); f.Limit.Should().BeInRange(1, 100); } [Fact] public void AuditEventFilter_AcceptsExplicitLimitWithinBounds() { var f = new AuditEventFilter( ActorUserId: 42, TargetType: "Usuario", TargetId: "99", From: DateTime.UtcNow.AddDays(-7), To: DateTime.UtcNow, Cursor: "opaque-cursor", Limit: 100); f.Limit.Should().Be(100); f.ActorUserId.Should().Be(42); } }