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; using ScadaLink.StoreAndForward; 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); } /// /// M3 Bundle F (T15): the Central composition root calls /// AddSiteCallAudit(). Today that extension is a no-op placeholder, /// but invoking it must not throw and the central host's service collection /// must build successfully — the actor's Props are constructed inline in /// AkkaHostedService (via the root ), /// not from a DI factory. Asserting the host built confirms the wiring /// call is in place; this test guards against accidentally removing it /// from Program.cs. /// [Fact] public void Central_HostBuilds_With_AddSiteCallAudit_Wired() { // Reaching _factory.Services means WebApplicationFactory built the host // (DI validation completed). The fact this test is in the // CentralAuditWiringTests fixture means it ran against the Central // composition root path through Program.cs. Assert.NotNull(_factory.Services); } /// /// M3 Bundle F: the Central composition root registers /// ICachedCallTelemetryForwarder as a lazy singleton (the /// forwarder degrades to audit-only emission when the site-only /// IOperationTrackingStore is absent, matching the M2 lazy chain /// pattern). The binding is exercised here so a future regression that /// removes the registration or makes IOperationTrackingStore mandatory /// fails on the Central node, not just at first script execution. /// [Fact] public void Central_Resolves_ICachedCallTelemetryForwarder_LazySingleton() { var forwarder = _factory.Services.GetService(); Assert.NotNull(forwarder); Assert.IsType(forwarder); } } /// /// 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_AsHealthMetricsBridge() { // Bundle G (M2-T11): site composition root calls // AddAuditLogHealthMetricsBridge() after AddAuditLog + AddSiteHealthMonitoring, // which swaps the NoOp default for the real health-metrics bridge so // FallbackAuditWriter primary failures surface in the site health // report payload as SiteAuditWriteFailures. 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); } /// /// M3 Bundle F (T15): the site composition root resolves the cached-call /// telemetry forwarder. ScriptExecutionActor consumes this through /// GetService<ICachedCallTelemetryForwarder>() on every script /// execution; a missing registration would silently degrade /// ExternalSystem.CachedCall / Database.CachedWrite to the /// "no-emission" path and break the M3 audit pipeline. /// [Fact] public void Site_Resolves_ICachedCallTelemetryForwarder() { var forwarder = _host.Services.GetService(); Assert.NotNull(forwarder); Assert.IsType(forwarder); } /// /// M3 Bundle F (T15): the site composition root resolves the lifecycle /// bridge that translates S&F retry-loop attempt notifications into /// cached-call telemetry packets. /// [Fact] public void Site_Resolves_CachedCallLifecycleBridge_AsSingleton() { var a = _host.Services.GetService(); var b = _host.Services.GetService(); Assert.NotNull(a); Assert.NotNull(b); Assert.Same(a, b); } /// /// M3 Bundle F (T15): the lifecycle bridge is bound to the /// contract that /// StoreAndForwardService consults at construction time. Without this /// binding the S&F service is built with a null observer and the /// retry-loop telemetry never reaches the audit pipeline. /// [Fact] public void Site_ICachedCallLifecycleObserver_IsTheLifecycleBridge() { var observer = _host.Services.GetService(); var bridge = _host.Services.GetService(); Assert.NotNull(observer); Assert.NotNull(bridge); Assert.Same(bridge, observer); } /// /// M3 Bundle F (T15): the Host registers an /// adapter so the S&F service /// can resolve the site id at composition time WITHOUT introducing a /// StoreAndForward → HealthMonitoring project-reference cycle. /// [Fact] public void Site_Resolves_IStoreAndForwardSiteContext_FromHost() { var ctx = _host.Services.GetService(); Assert.NotNull(ctx); Assert.Equal("TestSite", ctx!.SiteId); } }