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); + } +}