diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs new file mode 100644 index 0000000..fd0972d --- /dev/null +++ b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.AuditLog.Central; + +/// +/// Central-only direct-write implementation of . +/// Wraps as a best-effort +/// audit emission path for components that originate audit events ON the central +/// node (Notification Outbox dispatch, Inbound API) — NOT for site telemetry +/// ingest (that path is the SiteAudit → AuditLogIngestActor batched flow). +/// +/// +/// +/// Best-effort contract. Audit-write failures NEVER abort the user-facing +/// action (alog.md §13). The writer catches every exception thrown by repository +/// resolution or the insert call, logs at warning, and returns successfully. +/// Callers may still wrap the call in their own try/catch (defensive — the writer +/// is supposed to swallow). +/// +/// +/// Scope-per-call resolution. is a SCOPED +/// EF Core service (registered by ScadaLink.ConfigurationDatabase). The +/// writer itself is registered as a singleton (so all callers share one instance), +/// so it cannot hold a scope across calls — it opens a fresh +/// per invocation, mirroring +/// the per-message scope pattern used by AuditLogIngestActor and +/// NotificationOutboxActor. +/// +/// +/// Idempotency. Persistence is via InsertIfNotExistsAsync, so a +/// double-emitted event (same ) is a silent +/// no-op — the writer is safe to call from any number of dispatch paths. +/// +/// +public sealed class CentralAuditWriter : ICentralAuditWriter +{ + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + public CentralAuditWriter(IServiceProvider services, ILogger logger) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Persists into the central AuditLog table + /// idempotently on . Stamps + /// from the central-side clock. + /// Internal failures are logged and swallowed — never thrown. + /// + public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + if (evt is null) + { + // Defensive — a null event is a programming bug at the caller and + // produces no meaningful audit row. Log and return. + _logger.LogWarning("CentralAuditWriter.WriteAsync received null event; ignoring."); + return; + } + + try + { + await using var scope = _services.CreateAsyncScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var stamped = evt with { IngestedAtUtc = DateTime.UtcNow }; + await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // Audit failure NEVER aborts the user-facing action — swallow and log. + _logger.LogWarning( + ex, + "CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})", + evt.EventId, evt.Kind, evt.Status); + } + } +} diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index e420f4f..346ea0f 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; @@ -129,6 +130,17 @@ public static class ServiceCollectionExtensions services.AddSingleton( sp => sp.GetRequiredService()); + // M4 Bundle B: central direct-write audit writer used by + // NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to + // emit AuditLog rows that originate ON central, not via site telemetry. + // Singleton — the writer is stateless; its per-call scope opens a fresh + // IAuditLogRepository (a SCOPED EF Core service registered by + // ScadaLink.ConfigurationDatabase). The interface (ICentralAuditWriter) + // is intentionally distinct from IAuditWriter so site composition roots + // do not accidentally bind it; central composition roots that include + // AddConfigurationDatabase get a working implementation transparently. + services.AddSingleton(); + return services; } diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index fe4fe15..b8c5171 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -275,11 +275,18 @@ akka {{ .GetRequiredService>().Value; var outboxLogger = _serviceProvider.GetRequiredService() .CreateLogger(); + // M4 Bundle B: central direct-write audit writer for dispatcher attempt + // + terminal events. Resolved once from the root provider — the writer + // is a singleton and stateless, opening per-call DI scopes internally + // to resolve the scoped IAuditLogRepository. + var outboxAuditWriter = _serviceProvider + .GetRequiredService(); var outboxSingletonProps = ClusterSingletonManager.Props( singletonProps: Props.Create(() => new ScadaLink.NotificationOutbox.NotificationOutboxActor( _serviceProvider, outboxOptions, + outboxAuditWriter, outboxLogger)), terminationMessage: PoisonPill.Instance, settings: ClusterSingletonManagerSettings.Create(_actorSystem!) diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index bc17053..9c0d02d 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -1,8 +1,10 @@ using Akka.Actor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Notifications; @@ -30,6 +32,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers private readonly IServiceProvider _serviceProvider; private readonly NotificationOutboxOptions _options; + private readonly ICentralAuditWriter _auditWriter; private readonly ILogger _logger; /// @@ -45,11 +48,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers public NotificationOutboxActor( IServiceProvider serviceProvider, NotificationOutboxOptions options, + ICentralAuditWriter auditWriter, ILogger logger) { - _serviceProvider = serviceProvider; - _options = options; - _logger = logger; + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); Receive(HandleSubmit); Receive(HandleIngestPersisted); diff --git a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs index 03d337a..61d0031 100644 --- a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; @@ -155,6 +156,34 @@ public class AddAuditLogTests Assert.IsType(client); } + // -- M4 Bundle B (B1) central direct-write audit writer ----------------- + + [Fact] + public void AddAuditLog_Registers_ICentralAuditWriter_AsCentralAuditWriter() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var writer = provider.GetService(); + Assert.NotNull(writer); + Assert.IsType(writer); + } + + [Fact] + public void AddAuditLog_ICentralAuditWriter_IsSingleton() + { + using var provider = BuildProvider(new Dictionary + { + ["AuditLog:SiteWriter:DatabasePath"] = ":memory:", + }); + + var w1 = provider.GetService(); + var w2 = provider.GetService(); + Assert.Same(w1, w2); + } + [Fact] public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter() { diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs new file mode 100644 index 0000000..1a9ca3b --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs @@ -0,0 +1,127 @@ +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; + +/// +/// M4 Bundle B1 — unit tests for , the +/// central-only direct-write implementation of . +/// The writer is a thin wrapper around +/// : it stamps +/// , 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). +/// +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(); + var services = new ServiceCollection(); + services.AddScoped(_ => repo); + var provider = services.BuildServiceProvider(); + return (new CentralAuditWriter(provider, NullLogger.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(e => e.EventId == evt.EventId), + Arg.Any()); + } + + [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(e => + e.IngestedAtUtc != null && + e.IngestedAtUtc >= before && + e.IngestedAtUtc <= after), + Arg.Any()); + } + + [Fact] + public async Task WriteAsync_Repository_Throws_DoesNotPropagate() + { + var repo = Substitute.For(); + repo.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("db down")); + var services = new ServiceCollection(); + services.AddScoped(_ => repo); + var provider = services.BuildServiceProvider(); + var writer = new CentralAuditWriter(provider, NullLogger.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(); + var services = new ServiceCollection(); + services.AddScoped(_ => + { + var r = Substitute.For(); + instances.Add(r); + return r; + }); + var provider = services.BuildServiceProvider(); + var writer = new CentralAuditWriter(provider, NullLogger.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( + () => new CentralAuditWriter(null!, NullLogger.Instance)); + } + + [Fact] + public void Constructor_NullLogger_Throws() + { + var services = new ServiceCollection().BuildServiceProvider(); + Assert.Throws( + () => new CentralAuditWriter(services, null!)); + } +} diff --git a/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs b/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs index a4ed61b..b639a9a 100644 --- a/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs +++ b/tests/ScadaLink.IntegrationTests/NotificationOutboxFlowTests.cs @@ -8,8 +8,10 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; +using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types.Enums; using ScadaLink.ConfigurationDatabase; @@ -172,7 +174,20 @@ public class NotificationOutboxFlowTests : TestKit }; return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor( - services, options, NullLogger.Instance))); + services, + options, + (ICentralAuditWriter)new NoOpCentralAuditWriter(), + NullLogger.Instance))); + } + + /// + /// Test-only no-op . The integration tests + /// in this file pre-date M4 Bundle B's audit-writer injection; they do not + /// assert on emission, just need a non-null collaborator. + /// + private sealed class NoOpCentralAuditWriter : ICentralAuditWriter + { + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask; } private static NotificationSubmit MakeSubmit(string notificationId) diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs new file mode 100644 index 0000000..0b6a302 --- /dev/null +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAuditInjectionTests.cs @@ -0,0 +1,79 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Entities.Notifications; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.NotificationOutbox.Tests; + +/// +/// M4 Bundle B (B1) — verifies accepts an +/// at construction so subsequent bundle tasks +/// (B2/B3) can route attempt + terminal lifecycle events through the central +/// direct-write audit path. +/// +public class NotificationOutboxActorAuditInjectionTests : TestKit +{ + private static IServiceProvider BuildEmptyProvider() + { + var services = new ServiceCollection(); + services.AddScoped(_ => Substitute.For()); + services.AddScoped(_ => Substitute.For()); + return services.BuildServiceProvider(); + } + + /// + /// Inline NoOp writer that records calls — used to assert later tasks emit + /// events without depending on a concrete CentralAuditWriter. + /// + private sealed class RecordingCentralAuditWriter : ICentralAuditWriter + { + public List Events { get; } = new(); + + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + lock (Events) + { + Events.Add(evt); + } + return Task.CompletedTask; + } + } + + [Fact] + public void Actor_ConstructedWith_ICentralAuditWriter_NoException() + { + var writer = new RecordingCentralAuditWriter(); + // Long dispatch interval so PreStart's timer never fires during the test. + var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) }; + + var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor( + BuildEmptyProvider(), + options, + writer, + NullLogger.Instance))); + + Assert.NotNull(actor); + // No event has been emitted yet — the writer is purely injected at this stage. + lock (writer.Events) + { + Assert.Empty(writer.Events); + } + } + + [Fact] + public void Actor_NullAuditWriter_Throws() + { + var options = new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) }; + + Assert.Throws(() => new NotificationOutboxActor( + BuildEmptyProvider(), + options, + auditWriter: null!, + NullLogger.Instance)); + } +} diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs index fafdf3d..ea91625 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs @@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types.Enums; using ScadaLink.NotificationOutbox.Delivery; using ScadaLink.NotificationOutbox.Messages; +using ScadaLink.NotificationOutbox.Tests.TestSupport; namespace ScadaLink.NotificationOutbox.Tests; @@ -81,6 +82,7 @@ public class NotificationOutboxActorDispatchTests : TestKit return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor( BuildServiceProvider(adapters), options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) }, + new NoOpCentralAuditWriter(), NullLogger.Instance))); } diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs index 4c62943..123711d 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs @@ -9,6 +9,7 @@ using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types.Enums; using ScadaLink.NotificationOutbox.Delivery; +using ScadaLink.NotificationOutbox.Tests.TestSupport; namespace ScadaLink.NotificationOutbox.Tests; @@ -34,6 +35,7 @@ public class NotificationOutboxActorIngestTests : TestKit return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor( BuildServiceProvider(), new NotificationOutboxOptions(), + new NoOpCentralAuditWriter(), NullLogger.Instance))); } diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs index bcd749e..b291f76 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorPurgeTests.cs @@ -8,6 +8,7 @@ using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types.Enums; using ScadaLink.NotificationOutbox.Delivery; using ScadaLink.NotificationOutbox.Messages; +using ScadaLink.NotificationOutbox.Tests.TestSupport; namespace ScadaLink.NotificationOutbox.Tests; @@ -46,6 +47,7 @@ public class NotificationOutboxActorPurgeTests : TestKit DispatchInterval = TimeSpan.FromHours(1), PurgeInterval = TimeSpan.FromHours(1), }, + new NoOpCentralAuditWriter(), NullLogger.Instance))); } diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index 3f2a5d6..5bc9052 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -10,6 +10,7 @@ using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Notifications; using ScadaLink.NotificationOutbox.Delivery; +using ScadaLink.NotificationOutbox.Tests.TestSupport; namespace ScadaLink.NotificationOutbox.Tests; @@ -36,6 +37,7 @@ public class NotificationOutboxActorQueryTests : TestKit BuildServiceProvider(), // A long dispatch interval keeps the dispatch loop from interfering with these tests. options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) }, + new NoOpCentralAuditWriter(), NullLogger.Instance))); } diff --git a/tests/ScadaLink.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs b/tests/ScadaLink.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs new file mode 100644 index 0000000..ae82341 --- /dev/null +++ b/tests/ScadaLink.NotificationOutbox.Tests/TestSupport/NoOpCentralAuditWriter.cs @@ -0,0 +1,15 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.NotificationOutbox.Tests.TestSupport; + +/// +/// Test-only no-op . Used by existing +/// NotificationOutboxActor TestKit fixtures whose tests pre-date the M4 Bundle B +/// audit-writer injection — they don't care about audit emission, they just +/// need a non-null collaborator so the actor's constructor succeeds. +/// +internal sealed class NoOpCentralAuditWriter : ICentralAuditWriter +{ + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask; +}