diff --git a/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs
index ebb7d55..01496bb 100644
--- a/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs
+++ b/src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs
@@ -1,4 +1,5 @@
using Akka.Actor;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
@@ -28,12 +29,27 @@ namespace ScadaLink.AuditLog.Central;
/// inside ReceiveAsync does not restart the actor (which would also
/// reset any in-flight state).
///
+///
+/// Two constructors exist for a deliberate reason: Bundle D's tests inject a
+/// concrete against a per-test MSSQL fixture
+/// (the only way to verify the IngestedAtUtc stamp + duplicate-key idempotency
+/// end to end), while Bundle E's host wiring registers the actor as a cluster
+/// singleton and must therefore resolve the repository — which is a scoped EF
+/// Core service — from a fresh DI scope per message. Mirroring the Notification
+/// Outbox actor's pattern.
+///
///
public class AuditLogIngestActor : ReceiveActor
{
- private readonly IAuditLogRepository _repository;
+ private readonly IServiceProvider? _serviceProvider;
+ private readonly IAuditLogRepository? _injectedRepository;
private readonly ILogger _logger;
+ ///
+ /// Test-mode constructor — injects a concrete repository instance whose
+ /// lifetime exceeds the test, so the actor reuses the same instance across
+ /// every message. Used by Bundle D's MSSQL-backed TestKit fixture.
+ ///
public AuditLogIngestActor(
IAuditLogRepository repository,
ILogger logger)
@@ -41,7 +57,27 @@ public class AuditLogIngestActor : ReceiveActor
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
- _repository = repository;
+ _injectedRepository = repository;
+ _logger = logger;
+
+ ReceiveAsync(OnIngestAsync);
+ }
+
+ ///
+ /// Production constructor — resolves from
+ /// a fresh DI scope per message because the repository is a scoped EF Core
+ /// service registered by AddConfigurationDatabase. The actor itself
+ /// is a long-lived cluster singleton, so it cannot hold a scope across
+ /// messages.
+ ///
+ public AuditLogIngestActor(
+ IServiceProvider serviceProvider,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(serviceProvider);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ _serviceProvider = serviceProvider;
_logger = logger;
ReceiveAsync(OnIngestAsync);
@@ -68,27 +104,49 @@ public class AuditLogIngestActor : ReceiveActor
var nowUtc = DateTime.UtcNow;
var accepted = new List(cmd.Events.Count);
- foreach (var evt in cmd.Events)
+ // Resolve the repository for the whole batch — one DbContext per
+ // message, mirroring NotificationOutboxActor. The injected-repository
+ // mode (Bundle D tests) skips the scope entirely.
+ IServiceScope? scope = null;
+ IAuditLogRepository repository;
+ if (_injectedRepository is not null)
{
- try
+ repository = _injectedRepository;
+ }
+ else
+ {
+ scope = _serviceProvider!.CreateScope();
+ repository = scope.ServiceProvider.GetRequiredService();
+ }
+
+ try
+ {
+ foreach (var evt in cmd.Events)
{
- // Stamp IngestedAtUtc here, not at the site. Bundle A's
- // repository hardening already swallows duplicate-key races,
- // so the same id arriving twice (site retry, reconciliation)
- // is a silent no-op.
- var ingested = evt with { IngestedAtUtc = nowUtc };
- await _repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
- accepted.Add(evt.EventId);
- }
- catch (Exception ex)
- {
- // Per-row catch — one bad row never sinks the whole batch.
- // The row stays Pending at the site; the next drain retries.
- _logger.LogError(ex,
- "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.",
- evt.EventId);
+ try
+ {
+ // Stamp IngestedAtUtc here, not at the site. Bundle A's
+ // repository hardening already swallows duplicate-key races,
+ // so the same id arriving twice (site retry, reconciliation)
+ // is a silent no-op.
+ var ingested = evt with { IngestedAtUtc = nowUtc };
+ await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
+ accepted.Add(evt.EventId);
+ }
+ catch (Exception ex)
+ {
+ // Per-row catch — one bad row never sinks the whole batch.
+ // The row stays Pending at the site; the next drain retries.
+ _logger.LogError(ex,
+ "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.",
+ evt.EventId);
+ }
}
}
+ finally
+ {
+ scope?.Dispose();
+ }
replyTo.Tell(new IngestAuditEventsReply(accepted));
}
diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
index 7e010e6..b8b183c 100644
--- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
@@ -1,44 +1,106 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
+using ScadaLink.AuditLog.Site;
+using ScadaLink.AuditLog.Site.Telemetry;
+using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog;
///
-/// Composition root for the Audit Log (#23) component. M1 registers
-/// and its validator; later milestones extend
-/// this method to wire up writers, telemetry actors, and the central ingest
-/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and
-/// Site Call Audit (#22).
+/// Composition root for the Audit Log (#23) component.
///
+///
+///
+/// M1 registered + the validator. M2 Bundle E
+/// extends the surface with the site-side writer chain
+/// ( + +
+/// ) and the telemetry collaborators
+/// (, ,
+/// , ,
+/// ).
+///
+///
+/// Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call
+/// Audit (#22). IAuditLogRepository is registered by
+/// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase,
+/// so the caller (the Host on the central node) must also call that.
+///
+///
public static class ServiceCollectionExtensions
{
/// Configuration section bound to .
public const string ConfigSectionName = "AuditLog";
+ /// Configuration section bound to .
+ public const string SiteWriterSectionName = "AuditLog:SiteWriter";
+
+ /// Configuration section bound to .
+ public const string SiteTelemetrySectionName = "AuditLog:SiteTelemetry";
+
///
- /// Binds from the
- /// section of
- /// and registers so a misconfigured
- /// AuditLog section is rejected with a key-naming message when the
- /// options are first resolved (or at startup when consumers wire in
- /// ValidateOnStart()). M2+ will register writers, telemetry actors,
- /// and the central ingest pipeline here. IAuditLogRepository is
- /// registered by
- /// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase,
- /// so the caller (the Host on the central node) must also call that.
+ /// Registers the Audit Log (#23) component services: options, the site
+ /// SQLite writer chain (primary + ring fallback + failure-counter sink),
+ /// and the site-→central telemetry collaborators. Idempotent re-registration
+ /// is not supported; call this exactly once per .
///
public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
+ // M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
services.AddOptions()
.Bind(config.GetSection(ConfigSectionName))
.ValidateOnStart();
services.AddSingleton, AuditLogOptionsValidator>();
+ // M2 Bundle E: site writer + telemetry options bindings.
+ // BindConfiguration is not used because the configuration root supplied
+ // by the caller may not be the application root — we go through the
+ // section explicitly so a partial IConfiguration (e.g. a test stub
+ // anchored on the AuditLog section's parent) still works.
+ services.AddOptions()
+ .Bind(config.GetSection(SiteWriterSectionName));
+ services.AddOptions()
+ .Bind(config.GetSection(SiteTelemetrySectionName));
+
+ // SqliteAuditWriter is a singleton with a single owned SqliteConnection
+ // and a background writer Task; multiple instances would race on the
+ // same file. Registered concretely so the ISiteAuditQueue + IAuditWriter
+ // forwards below resolve to the same instance — the actor must observe
+ // the writes made via the hot-path interface.
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+
+ // RingBufferFallback: drop-oldest in-memory ring used by
+ // FallbackAuditWriter when the primary SQLite writer throws. Default
+ // capacity is fine for M2 (1024).
+ services.AddSingleton();
+
+ // IAuditWriteFailureCounter: NoOp default. Bundle G overrides this
+ // binding with the real Site Health Monitoring counter. Registered
+ // before FallbackAuditWriter so the factory can resolve it.
+ services.AddSingleton();
+
+ // The script-thread surface is FallbackAuditWriter (primary + ring +
+ // counter), not the raw SqliteAuditWriter — primary failures must NEVER
+ // abort the user-facing action.
+ services.AddSingleton(sp => new FallbackAuditWriter(
+ primary: sp.GetRequiredService(),
+ ring: sp.GetRequiredService(),
+ failureCounter: sp.GetRequiredService(),
+ logger: sp.GetRequiredService>()));
+
+ // ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings
+ // the real gRPC-backed implementation (no site→central gRPC channel
+ // exists today — sites talk to central via Akka ClusterClient only).
+ // Bundle H's integration test substitutes a stub directly into the
+ // SiteAuditTelemetryActor's Props.Create call.
+ services.AddSingleton();
+
return services;
}
}
diff --git a/src/ScadaLink.AuditLog/Site/NoOpAuditWriteFailureCounter.cs b/src/ScadaLink.AuditLog/Site/NoOpAuditWriteFailureCounter.cs
new file mode 100644
index 0000000..b3d7d91
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Site/NoOpAuditWriteFailureCounter.cs
@@ -0,0 +1,25 @@
+namespace ScadaLink.AuditLog.Site;
+
+///
+/// Default registered by
+/// on
+/// every node. Bundle G replaces this binding with a real counter that bridges
+/// into the Site Health Monitoring report payload as
+/// SiteAuditWriteFailures — until then,
+/// emits to a silent sink rather than NRE-ing
+/// on a null collaborator.
+///
+///
+/// Audit-write failures must NEVER abort the user-facing action (alog.md §7),
+/// so the counter is best-effort by contract. A NoOp default is the correct
+/// safe fallback while the health metric is being wired in.
+///
+public sealed class NoOpAuditWriteFailureCounter : IAuditWriteFailureCounter
+{
+ ///
+ public void Increment()
+ {
+ // Intentionally empty. Bundle G overrides this binding with the real
+ // counter once Site Health Monitoring is wired.
+ }
+}
diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs
new file mode 100644
index 0000000..b1a0190
--- /dev/null
+++ b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs
@@ -0,0 +1,41 @@
+using ScadaLink.Communication.Grpc;
+
+namespace ScadaLink.AuditLog.Site.Telemetry;
+
+///
+/// Default registered by
+/// .
+/// Ships with M2 site-sync-pipeline wiring; the real gRPC-backed
+/// implementation is deferred to M6 reconciliation, where a site→central gRPC
+/// channel will be introduced (no such channel exists today — sites talk to
+/// central exclusively via Akka ClusterClient, while the gRPC SiteStreamService
+/// is hosted on the SITE side for central→site streaming).
+///
+///
+///
+/// Returns an empty so the
+/// doesn't flip any rows to
+/// Forwarded when this NoOp is in effect — Bundle H's integration test
+/// substitutes a stub client that routes directly to the central
+/// AuditLogIngestActor in-process. Production wiring (M6) will replace
+/// this binding with a real client.
+///
+///
+/// Audit-write paths are best-effort by contract: a NoOp client keeps the
+/// host running cleanly and is consistent with "audit-write failures never
+/// abort the user-facing action".
+///
+///
+public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
+{
+ private static readonly IngestAck EmptyAck = new();
+
+ ///
+ public Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(batch);
+ // Empty ack — no EventIds will be flipped to Forwarded, so rows stay
+ // Pending until M6's real client (or a Bundle H test stub) takes over.
+ return Task.FromResult(EmptyAck);
+ }
+}
diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs
index c0711d8..5bc5c7e 100644
--- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs
+++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs
@@ -128,6 +128,13 @@ public class AkkaHostedService : IHostedService
var rolesStr = string.Join(",", roles.Select(QuoteHocon));
return $@"
+audit-telemetry-dispatcher {{
+ type = ForkJoinDispatcher
+ throughput = 100
+ dedicated-thread-pool {{
+ thread-count = 2
+ }}
+}}
akka {{
extensions = [
""Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools""
@@ -294,6 +301,47 @@ akka {{
commService?.SetNotificationOutbox(outboxProxy);
_logger.LogInformation("NotificationOutbox singleton created and registered with CentralCommunicationActor");
+ // Audit Log (#23) — central singleton mirrors the Notification Outbox
+ // pattern. The IngestAuditEvents gRPC handler lives on SiteStreamGrpcServer
+ // (Communication.Grpc); a central node hosting that server (M6 reconciliation
+ // path) hands the proxy in via SetAuditIngestActor below. When the gRPC
+ // server is not registered (current central topology), the host still
+ // brings the singleton up so a Bundle H in-process test (or a future
+ // direct caller) can Ask the proxy without further wiring.
+ // IAuditLogRepository is a SCOPED EF Core service, so the singleton
+ // actor takes the root IServiceProvider and creates a fresh scope per
+ // message (mirroring NotificationOutboxActor). Pre-resolving the
+ // repository here would attempt to take a scoped service from the
+ // root and fail under DI scope validation.
+ var auditIngestLogger = _serviceProvider.GetRequiredService()
+ .CreateLogger();
+
+ var auditIngestSingletonProps = ClusterSingletonManager.Props(
+ singletonProps: Props.Create(() => new ScadaLink.AuditLog.Central.AuditLogIngestActor(
+ _serviceProvider,
+ auditIngestLogger)),
+ terminationMessage: PoisonPill.Instance,
+ settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
+ .WithSingletonName("audit-log-ingest"));
+ _actorSystem!.ActorOf(auditIngestSingletonProps, "audit-log-ingest-singleton");
+
+ var auditIngestProxyProps = ClusterSingletonProxy.Props(
+ singletonManagerPath: "/user/audit-log-ingest-singleton",
+ settings: ClusterSingletonProxySettings.Create(_actorSystem)
+ .WithSingletonName("audit-log-ingest"));
+ var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy");
+
+ // Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
+ // so the IngestAuditEvents RPC routes incoming site batches to the singleton.
+ // The gRPC server is currently only registered on Site nodes; on a central
+ // node this resolves to null and the wiring is a no-op until M6 (which
+ // brings central-hosted gRPC + a real site→central client).
+ var grpcServer = _serviceProvider.GetService();
+ grpcServer?.SetAuditIngestActor(auditIngestProxy);
+ _logger.LogInformation(
+ "AuditLogIngestActor singleton created (gRPC server bound: {GrpcBound})",
+ grpcServer is not null);
+
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
}
@@ -504,6 +552,41 @@ akka {{
contacts.Count, _nodeOptions.SiteId);
}
+ // Audit Log (#23) — site-side telemetry actor that drains the SQLite
+ // Pending queue and pushes to central via IngestAuditEvents. Not a
+ // cluster singleton: each site is its own cluster, and the actor reads
+ // node-local SQLite (no replication). The Props are bound to the
+ // dedicated audit-telemetry-dispatcher (defined in BuildHocon) so a
+ // batch SQLite read + gRPC push never contend with the default
+ // dispatcher used by hot-path actors.
+ //
+ // Per Bundle E's brief: the SiteAuditTelemetryActor takes its
+ // collaborators through its constructor, so we resolve them from DI
+ // and pass them in via Props.Create rather than relying on a future
+ // FactoryProvider. This also lets the M6 follow-up swap the
+ // NoOpSiteStreamAuditClient registration for the real gRPC client
+ // without touching this site wiring.
+ var siteAuditOptions = _serviceProvider
+ .GetRequiredService>();
+ var siteAuditQueue = _serviceProvider
+ .GetRequiredService();
+ var siteAuditClient = _serviceProvider
+ .GetRequiredService();
+ var siteAuditLogger = _serviceProvider.GetRequiredService()
+ .CreateLogger();
+
+ var siteAuditTelemetryProps = Props.Create(() =>
+ new ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor(
+ siteAuditQueue,
+ siteAuditClient,
+ siteAuditOptions,
+ siteAuditLogger))
+ .WithDispatcher("audit-telemetry-dispatcher");
+ _actorSystem.ActorOf(siteAuditTelemetryProps, "site-audit-telemetry");
+ _logger.LogInformation(
+ "SiteAuditTelemetryActor created (dispatcher=audit-telemetry-dispatcher, client={ClientType})",
+ siteAuditClient.GetType().Name);
+
// Gate gRPC subscriptions until the actor system and SiteStreamManager are
// initialized (REQ-HOST-7).
//
diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs
index 58d7ba7..a42f93d 100644
--- a/src/ScadaLink.Host/Program.cs
+++ b/src/ScadaLink.Host/Program.cs
@@ -1,5 +1,6 @@
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using ScadaLink.AuditLog;
using ScadaLink.CentralUI;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication;
@@ -77,6 +78,10 @@ try
// AddNotificationService() SMTP machinery above. AddNotificationOutbox binds
// NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed.
builder.Services.AddNotificationOutbox();
+ // Audit Log (#23) — central node owns the AuditLogIngestActor singleton +
+ // IAuditLogRepository. The site writer chain is still registered (lazy
+ // singletons) but is never resolved on a central node.
+ builder.Services.AddAuditLog(builder.Configuration);
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
builder.Services.AddSecurity();
diff --git a/src/ScadaLink.Host/ScadaLink.Host.csproj b/src/ScadaLink.Host/ScadaLink.Host.csproj
index 71f7406..4b45288 100644
--- a/src/ScadaLink.Host/ScadaLink.Host.csproj
+++ b/src/ScadaLink.Host/ScadaLink.Host.csproj
@@ -38,6 +38,7 @@
+
diff --git a/src/ScadaLink.Host/SiteServiceRegistration.cs b/src/ScadaLink.Host/SiteServiceRegistration.cs
index 5e9dc50..e92b583 100644
--- a/src/ScadaLink.Host/SiteServiceRegistration.cs
+++ b/src/ScadaLink.Host/SiteServiceRegistration.cs
@@ -1,3 +1,4 @@
+using ScadaLink.AuditLog;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication;
using ScadaLink.DataConnectionLayer;
@@ -44,6 +45,12 @@ public static class SiteServiceRegistration
services.AddStoreAndForward();
services.AddSiteEventLogging();
+ // Audit Log (#23) — site-side hot-path writer + telemetry collaborators.
+ // The SiteAuditTelemetryActor itself is registered by AkkaHostedService
+ // in the site-role block; this call wires every DI dependency it (and
+ // ScriptRuntimeContext, when Bundle F lands) reaches for.
+ services.AddAuditLog(config);
+
// WP-13: Akka.NET bootstrap via hosted service
services.AddSingleton();
services.AddHostedService(sp => sp.GetRequiredService());
diff --git a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
index a1057a1..afe70f4 100644
--- a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
@@ -1,28 +1,42 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Configuration;
+using ScadaLink.AuditLog.Site;
+using ScadaLink.AuditLog.Site.Telemetry;
+using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog.Tests;
///
-/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies
-/// AddAuditLog registers against the
-/// AuditLog configuration section. Bundle E ships only the scaffold;
-/// the validator + full options surface land in Task 9.
+/// Bundle E (M2 Task E1) DI surface tests for AddAuditLog. M1 shipped
+/// the options-only scaffold; M2 extends it with the site writer chain
+/// ( + +
+/// ) and the telemetry collaborators
+/// (, ,
+/// ).
///
public class AddAuditLogTests
{
- [Fact]
- public void AddAuditLog_RegistersAuditLogOptions()
+ private static ServiceProvider BuildProvider(IDictionary? settings = null)
{
var config = new ConfigurationBuilder()
- .AddInMemoryCollection(new Dictionary())
+ .AddInMemoryCollection(settings ?? new Dictionary())
.Build();
var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
services.AddAuditLog(config);
- var provider = services.BuildServiceProvider();
+ return services.BuildServiceProvider();
+ }
+
+ [Fact]
+ public void AddAuditLog_RegistersAuditLogOptions()
+ {
+ using var provider = BuildProvider();
var opts = provider.GetService>();
@@ -47,4 +61,130 @@ public class AddAuditLogTests
Assert.Throws(
() => services.AddAuditLog(null!));
}
+
+ // -- Bundle E (M2 Task E1) ---------------------------------------------
+
+ [Fact]
+ public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ // In-memory database keeps the writer's owned connection portable
+ // across tests; the per-instance Cache=Shared in the writer's
+ // default connection string ensures no on-disk file is touched.
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var writer = provider.GetService();
+
+ Assert.NotNull(writer);
+ // Singleton — same instance on a second resolve.
+ Assert.Same(writer, provider.GetService());
+ }
+
+ [Fact]
+ public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var writer = provider.GetService();
+
+ Assert.NotNull(writer);
+ Assert.IsType(writer);
+ }
+
+ [Fact]
+ public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
+ {
+ // The telemetry actor reads from ISiteAuditQueue while scripts write
+ // through IAuditWriter → SqliteAuditWriter. Both surfaces MUST resolve
+ // to the same instance or pending rows will never be visible.
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var queue = provider.GetService();
+ var writer = provider.GetService();
+
+ Assert.NotNull(queue);
+ Assert.NotNull(writer);
+ Assert.Same(writer, queue);
+ }
+
+ [Fact]
+ public void AddAuditLog_Registers_RingBufferFallback_Singleton()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var ring = provider.GetService();
+ Assert.NotNull(ring);
+ Assert.Same(ring, provider.GetService());
+ }
+
+ [Fact]
+ public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var counter = provider.GetService();
+ Assert.NotNull(counter);
+ Assert.IsType(counter);
+ }
+
+ [Fact]
+ public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ var client = provider.GetService();
+ Assert.NotNull(client);
+ Assert.IsType(client);
+ }
+
+ [Fact]
+ public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteWriter:DatabasePath"] = "/tmp/test-audit.db",
+ ["AuditLog:SiteWriter:ChannelCapacity"] = "8192",
+ ["AuditLog:SiteWriter:BatchSize"] = "128",
+ ["AuditLog:SiteWriter:FlushIntervalMs"] = "75",
+ });
+
+ var opts = provider.GetRequiredService>().Value;
+ Assert.Equal("/tmp/test-audit.db", opts.DatabasePath);
+ Assert.Equal(8192, opts.ChannelCapacity);
+ Assert.Equal(128, opts.BatchSize);
+ Assert.Equal(75, opts.FlushIntervalMs);
+ }
+
+ [Fact]
+ public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
+ {
+ using var provider = BuildProvider(new Dictionary
+ {
+ ["AuditLog:SiteTelemetry:BatchSize"] = "512",
+ ["AuditLog:SiteTelemetry:BusyIntervalSeconds"] = "3",
+ ["AuditLog:SiteTelemetry:IdleIntervalSeconds"] = "60",
+ });
+
+ var opts = provider.GetRequiredService>().Value;
+ Assert.Equal(512, opts.BatchSize);
+ Assert.Equal(3, opts.BusyIntervalSeconds);
+ Assert.Equal(60, opts.IdleIntervalSeconds);
+ }
}
diff --git a/tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs b/tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs
new file mode 100644
index 0000000..ed84701
--- /dev/null
+++ b/tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs
@@ -0,0 +1,301 @@
+using Akka.Configuration;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog;
+using ScadaLink.AuditLog.Site;
+using ScadaLink.AuditLog.Site.Telemetry;
+using ScadaLink.ClusterInfrastructure;
+using ScadaLink.Commons.Interfaces.Services;
+using ScadaLink.ConfigurationDatabase;
+using ScadaLink.Host;
+using ScadaLink.Host.Actors;
+
+namespace ScadaLink.Host.Tests;
+
+///
+/// Bundle E (M2 Task E1) — verifies the Audit Log (#23) DI surface is wired
+/// into both composition roots and that the HOCON document emitted by
+/// includes the dedicated
+/// audit-telemetry-dispatcher the site telemetry actor binds to.
+///
+///
+///
+/// Full cluster bring-up is exercised by the existing
+/// pattern — these tests reuse the same
+/// trick to short-circuit
+/// so DI resolution is exercised
+/// without the actor system actually being created.
+///
+///
+public class AkkaHostedServiceAuditWiringHoconTests
+{
+ [Fact]
+ public void BuildHocon_Emits_AuditTelemetryDispatcher_Block()
+ {
+ // Bundle E acceptance: the HOCON document the host parses must declare
+ // the dedicated dispatcher the SiteAuditTelemetryActor binds to. A
+ // missing dispatcher block would route the actor to the default
+ // dispatcher and silently lose the isolation guarantee.
+ var nodeOptions = new NodeOptions
+ {
+ Role = "Site",
+ NodeHostname = "site-test-1",
+ RemotingPort = 0,
+ SiteId = "TestSite",
+ };
+ var clusterOptions = new ClusterOptions
+ {
+ SeedNodes = new List { "akka.tcp://scadalink@localhost:2551" },
+ SplitBrainResolverStrategy = "keep-oldest",
+ MinNrOfMembers = 1,
+ StableAfter = TimeSpan.FromSeconds(15),
+ HeartbeatInterval = TimeSpan.FromSeconds(2),
+ FailureDetectionThreshold = TimeSpan.FromSeconds(10),
+ };
+
+ var hocon = AkkaHostedService.BuildHocon(
+ nodeOptions,
+ clusterOptions,
+ new[] { "Site", "site-TestSite" },
+ TimeSpan.FromSeconds(5),
+ TimeSpan.FromSeconds(15));
+
+ var config = ConfigurationFactory.ParseString(hocon);
+
+ // The dispatcher is declared at the root, so the lookup is by its
+ // unqualified name. The HOCON parser must accept the block as a
+ // standalone dispatcher definition the actor system can resolve.
+ var dispatcherType = config.GetString("audit-telemetry-dispatcher.type");
+ Assert.Equal("ForkJoinDispatcher", dispatcherType);
+
+ var throughput = config.GetInt("audit-telemetry-dispatcher.throughput");
+ Assert.Equal(100, throughput);
+
+ var threadCount = config.GetInt("audit-telemetry-dispatcher.dedicated-thread-pool.thread-count");
+ Assert.Equal(2, threadCount);
+ }
+}
+
+///
+/// Verifies Audit Log (#23) services land in the Central composition root.
+///
+public class CentralAuditWiringTests : IDisposable
+{
+ private readonly WebApplicationFactory _factory;
+ private readonly string? _previousEnv;
+
+ public CentralAuditWiringTests()
+ {
+ _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
+ Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
+
+ _factory = new WebApplicationFactory()
+ .WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ ["ScadaLink:Node:NodeHostname"] = "localhost",
+ ["ScadaLink:Node:RemotingPort"] = "0",
+ ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
+ ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
+ ["ScadaLink:Database:SkipMigrations"] = "true",
+ ["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
+ ["ScadaLink:Security:LdapServer"] = "localhost",
+ ["ScadaLink:Security:LdapPort"] = "3893",
+ ["ScadaLink:Security:LdapUseTls"] = "false",
+ ["ScadaLink:Security:AllowInsecureLdap"] = "true",
+ ["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
+ ["ScadaLink:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
+ });
+ });
+ builder.UseSetting("ScadaLink:Node:Role", "Central");
+ builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
+ builder.ConfigureServices(services =>
+ {
+ var descriptorsToRemove = services
+ .Where(d =>
+ d.ServiceType == typeof(DbContextOptions) ||
+ d.ServiceType == typeof(DbContextOptions) ||
+ d.ServiceType == typeof(ScadaLinkDbContext) ||
+ d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
+ .ToList();
+ foreach (var d in descriptorsToRemove)
+ services.Remove(d);
+
+ services.AddDbContext(options =>
+ options.UseInMemoryDatabase($"CentralAuditWiringTests_{Guid.NewGuid()}"));
+
+ AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
+ });
+ });
+
+ _ = _factory.Server;
+ }
+
+ public void Dispose()
+ {
+ _factory.Dispose();
+ Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
+ }
+
+ [Fact]
+ public void Central_Resolves_IAuditWriter_AsFallbackAuditWriter()
+ {
+ // Central nodes still register the writer chain because AddAuditLog is
+ // shared between roles — the registrations are lazy singletons and the
+ // writer is never resolved on a central node in production. Asserting
+ // it resolves here confirms the chain is intact and ready for the
+ // future case where a central-only actor needs to emit audit events.
+ var writer = _factory.Services.GetService();
+ Assert.NotNull(writer);
+ Assert.IsType(writer);
+ }
+
+ [Fact]
+ public void Central_Resolves_AuditLogOptions()
+ {
+ var opts = _factory.Services.GetService>();
+ Assert.NotNull(opts);
+ Assert.NotNull(opts!.Value);
+ }
+
+ [Fact]
+ public void Central_Resolves_SqliteAuditWriterOptions()
+ {
+ var opts = _factory.Services.GetService>();
+ Assert.NotNull(opts);
+ Assert.NotNull(opts!.Value);
+ }
+
+ [Fact]
+ public void Central_Resolves_SiteAuditTelemetryOptions()
+ {
+ var opts = _factory.Services.GetService>();
+ Assert.NotNull(opts);
+ Assert.NotNull(opts!.Value);
+ }
+
+ [Fact]
+ public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
+ {
+ var client = _factory.Services.GetService();
+ Assert.NotNull(client);
+ Assert.IsType(client);
+ }
+}
+
+///
+/// Verifies Audit Log (#23) services land in the Site composition root.
+///
+public class SiteAuditWiringTests : IDisposable
+{
+ private readonly WebApplication _host;
+ private readonly string _tempDbPath;
+
+ public SiteAuditWiringTests()
+ {
+ _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_audit_wiring_{Guid.NewGuid()}.db");
+
+ var builder = WebApplication.CreateBuilder();
+ builder.Configuration.Sources.Clear();
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["ScadaLink:Node:Role"] = "Site",
+ ["ScadaLink:Node:NodeHostname"] = "test-site",
+ ["ScadaLink:Node:SiteId"] = "TestSite",
+ ["ScadaLink:Node:RemotingPort"] = "0",
+ ["ScadaLink:Node:GrpcPort"] = "0",
+ ["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
+ ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
+ ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
+ // SqliteAuditWriter would attempt to open a SQLite file when first
+ // resolved; point it at an in-memory connection so the test doesn't
+ // pollute the working directory.
+ ["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
+ });
+
+ builder.Services.AddGrpc();
+ builder.Services.AddSingleton();
+ SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
+ AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
+
+ _host = builder.Build();
+ }
+
+ public void Dispose()
+ {
+ (_host as IDisposable)?.Dispose();
+ try { File.Delete(_tempDbPath); } catch { /* best effort */ }
+ }
+
+ [Fact]
+ public void Site_Resolves_IAuditWriter_AsFallbackAuditWriter()
+ {
+ var writer = _host.Services.GetService();
+ Assert.NotNull(writer);
+ Assert.IsType(writer);
+ }
+
+ [Fact]
+ public void Site_Resolves_SqliteAuditWriter_AsSingleton()
+ {
+ var a = _host.Services.GetService();
+ var b = _host.Services.GetService();
+ Assert.NotNull(a);
+ Assert.NotNull(b);
+ Assert.Same(a, b);
+ }
+
+ [Fact]
+ public void Site_ISiteAuditQueue_AndSqliteAuditWriter_AreSameInstance()
+ {
+ // The telemetry actor reads from ISiteAuditQueue while ScriptRuntimeContext
+ // writes through IAuditWriter → SqliteAuditWriter. If these don't resolve
+ // to the same instance, pending rows are invisible to the actor.
+ var queue = _host.Services.GetService();
+ var writer = _host.Services.GetService();
+ Assert.NotNull(queue);
+ Assert.NotNull(writer);
+ Assert.Same(writer, queue);
+ }
+
+ [Fact]
+ public void Site_Resolves_RingBufferFallback()
+ {
+ var ring = _host.Services.GetService();
+ Assert.NotNull(ring);
+ }
+
+ [Fact]
+ public void Site_Resolves_IAuditWriteFailureCounter_AsNoOpDefault()
+ {
+ var counter = _host.Services.GetService();
+ Assert.NotNull(counter);
+ Assert.IsType(counter);
+ }
+
+ [Fact]
+ public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
+ {
+ var client = _host.Services.GetService();
+ Assert.NotNull(client);
+ Assert.IsType(client);
+ }
+
+ [Fact]
+ public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults()
+ {
+ var opts = _host.Services.GetService>();
+ Assert.NotNull(opts);
+ Assert.Equal(256, opts!.Value.BatchSize);
+ Assert.Equal(5, opts.Value.BusyIntervalSeconds);
+ Assert.Equal(30, opts.Value.IdleIntervalSeconds);
+ }
+}