Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs
dmolinari 68f96b90c7 feat(application): audit abstractions (UDT-010 B2)
Introduces the contract layer for audit logging per design #D-8:

SIGCM2.Application/Audit/:
- IAuditContext — request-scoped accessor for ActorUserId/ActorRoleId/
  Ip/UserAgent/CorrelationId. Populated by CorrelationIdMiddleware +
  AuditActorMiddleware (B4).
- IAuditLogger.LogAsync(action, targetType, targetId, metadata?, ct) —
  domain-level audit emitter. Enlists in ambient TransactionScope
  (fail-closed per #REQ-AUD-4).
- ISecurityEventLogger.LogAsync(action, result, actorUserId?, attemptedUsername?,
  sessionId?, failureReason?, metadata?, ct) — security-events emitter
  separate from IAuditLogger (different retention, no transaction scope,
  captures login/logout/refresh/permission.denied).
- AuditOptions — bindable POCO with SanitizedKeys[] defaults (used by
  JsonSanitizer in B3).
- AuditEventDto — read projection for GET /api/v1/audit/events (B10).
- AuditEventFilter — query filter record with default Limit=50.

SIGCM2.Domain/Exceptions/:
- AuditContextMissingException : DomainException — fail-closed sentinel
  thrown when IAuditLogger is called without ActorUserId in a user-scoped
  command (#REQ-AUD-4).

Tests (Strict TDD — shape contract, RED → GREEN):
- tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs: 11 tests
  covering nullability, signatures, default options, record equality.

Suite: 336/336 Application.Tests (prev 325 + 11 new). 130/130 Api.Tests.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/4/5, design#D-8, tasks#B2}
2026-04-16 13:23:11 -03:00

180 lines
5.3 KiB
C#

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<IAuditContext>();
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<IAuditContext>();
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<IAuditLogger>();
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<object>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task IAuditLogger_LogAsync_AllowsNullMetadata()
{
var logger = Substitute.For<IAuditLogger>();
await logger.LogAsync("usuario.deactivate", "Usuario", "42");
await logger.Received(1).LogAsync(
"usuario.deactivate",
"Usuario",
"42",
null,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ISecurityEventLogger_LogAsync_ExposesFullSignatureForLoginFailure()
{
var logger = Substitute.For<ISecurityEventLogger>();
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<object>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ISecurityEventLogger_LogAsync_MinimalSuccessCall()
{
var logger = Substitute.For<ISecurityEventLogger>();
await logger.LogAsync("logout", "success", actorUserId: 42);
await logger.Received(1).LogAsync(
"logout", "success", 42, null, null, null, null, Arg.Any<CancellationToken>());
}
[Fact]
public void AuditContextMissingException_IsDomainException_WithFixedMessage()
{
var ex = new AuditContextMissingException();
ex.Should().BeAssignableTo<DomainException>();
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);
}
}