M4 Bundle B (B1) — add the central-only ICentralAuditWriter implementation and inject it into NotificationOutboxActor so subsequent tasks (B2/B3) can route attempt + terminal lifecycle events through the direct-write audit path. - CentralAuditWriter: thin wrapper around IAuditLogRepository.InsertIfNotExistsAsync; scope-per-call (matches AuditLogIngestActor / NotificationOutboxActor pattern); stamps IngestedAtUtc; swallows all internal failures (alog.md §13). - Registered as a singleton in AddAuditLog. - NotificationOutboxActor ctor takes ICentralAuditWriter (validated non-null). - Host wiring resolves the writer once from the root provider and passes it into the singleton's Props.Create call. - Existing TestKit fixtures updated with a NoOpCentralAuditWriter helper so tests that don't exercise audit emission still compile and pass.
128 lines
4.6 KiB
C#
128 lines
4.6 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ScadaLink.AuditLog.Central;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.AuditLog.Tests.Central;
|
|
|
|
/// <summary>
|
|
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
|
|
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
|
/// The writer is a thin wrapper around
|
|
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
|
|
/// <see cref="AuditEvent.IngestedAtUtc"/>, resolves the (scoped) repository
|
|
/// from a fresh DI scope per call, and swallows any thrown exception —
|
|
/// audit-write failures NEVER abort the user-facing action (alog.md §13).
|
|
/// </summary>
|
|
public class CentralAuditWriterTests
|
|
{
|
|
private static AuditEvent NewEvent(Guid? eventId = null) => new()
|
|
{
|
|
EventId = eventId ?? Guid.NewGuid(),
|
|
OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
|
Channel = AuditChannel.Notification,
|
|
Kind = AuditKind.NotifyDeliver,
|
|
Status = AuditStatus.Attempted,
|
|
CorrelationId = Guid.NewGuid(),
|
|
Target = "ops-team",
|
|
};
|
|
|
|
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => repo);
|
|
var provider = services.BuildServiceProvider();
|
|
return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
|
|
{
|
|
var (writer, repo) = BuildWriter();
|
|
var evt = NewEvent();
|
|
|
|
await writer.WriteAsync(evt);
|
|
|
|
await repo.Received(1).InsertIfNotExistsAsync(
|
|
Arg.Is<AuditEvent>(e => e.EventId == evt.EventId),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
|
|
{
|
|
var (writer, repo) = BuildWriter();
|
|
var before = DateTime.UtcNow;
|
|
|
|
await writer.WriteAsync(NewEvent());
|
|
|
|
var after = DateTime.UtcNow;
|
|
await repo.Received(1).InsertIfNotExistsAsync(
|
|
Arg.Is<AuditEvent>(e =>
|
|
e.IngestedAtUtc != null &&
|
|
e.IngestedAtUtc >= before &&
|
|
e.IngestedAtUtc <= after),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("db down"));
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => repo);
|
|
var provider = services.BuildServiceProvider();
|
|
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
|
|
|
|
// Must not throw — audit failure NEVER aborts the user-facing action.
|
|
await writer.WriteAsync(NewEvent());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
|
|
{
|
|
// Counting factory: every scope opening should resolve a new repo
|
|
// (scoped lifetime). We assert at least two distinct instances
|
|
// across two WriteAsync calls.
|
|
var instances = new List<IAuditLogRepository>();
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<IAuditLogRepository>(_ =>
|
|
{
|
|
var r = Substitute.For<IAuditLogRepository>();
|
|
instances.Add(r);
|
|
return r;
|
|
});
|
|
var provider = services.BuildServiceProvider();
|
|
var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);
|
|
|
|
await writer.WriteAsync(NewEvent());
|
|
await writer.WriteAsync(NewEvent());
|
|
|
|
Assert.Equal(2, instances.Count);
|
|
Assert.NotSame(instances[0], instances[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullServices_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(
|
|
() => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_NullLogger_Throws()
|
|
{
|
|
var services = new ServiceCollection().BuildServiceProvider();
|
|
Assert.Throws<ArgumentNullException>(
|
|
() => new CentralAuditWriter(services, null!));
|
|
}
|
|
}
|