using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Deferred-work register row 62 — hardening pin, not a bug fix. /// /// /// Six SiteRuntime call sites reach ISiteEventLogger straight off the injected /// , with no scope in between: /// /// /// SiteRuntime/Actors/AlarmActor.cs — resolves once in the /// constructor and CACHES it in _siteEventLogger, so it holds the instance for /// the actor's whole lifetime. /// SiteRuntime/Actors/ScriptActor.cs (two sites) — per-call /// resolve. /// SiteRuntime/Actors/InstanceActor.cs (two sites) — per-call /// resolve. /// SiteRuntime/Actors/NativeAlarmActor.cs — per-call /// resolve. /// SiteRuntime/Actors/DeploymentManagerActor.cs — per-call /// resolve. /// SiteRuntime/Scripts/ScriptRunLauncher.cs — per-run resolve. /// Not among the five the register row named: it is the run body that was lifted verbatim /// out of the deleted ScriptExecutionActor/AlarmExecutionActor, so it inherited the /// pattern rather than adding it. /// /// /// /// Every one of those is CORRECT TODAY, and this test is the reason it stays that way. /// They are correct for exactly one reason: ISiteEventLogger is registered /// , so resolving it from the root provider yields /// the same instance a scope would. The day that registration changes lifetime, every one /// of those call sites silently becomes register row 45 — a captive dependency / /// resolved-outside-its-scope bug — and nothing else in the tree would notice: /// /// /// ValidateScopes catches a SCOPED service resolved from the /// root provider — but the actors hold the root provider legitimately, and .NET's /// check fires at the offending GetService call, i.e. at runtime on a site /// node, on whatever code path happens to log an event first. Not at boot, and not /// in CI. /// ValidateOnBuild walks CONSTRUCTOR graphs. These are /// imperative GetService calls inside method bodies, invisible to it. /// CaptiveDependencyGuard catches the AlarmActor field case /// (a cached resolve) by design, but cannot see the five per-call resolves at /// all. /// /// /// /// So the lifetime is a load-bearing invariant with no other enforcement. Pinning it here /// means a future lifetime change fails loudly, in CI, with a message naming the call /// sites that have to be fixed alongside it — rather than shipping and being found on the /// rig months later, which is how row 45 was found. /// /// /// /// This deliberately asserts the REGISTRATION, not the call sites: the SiteRuntime actor /// files are correct as written and need no change. The pin is a tripwire on the /// assumption they rest on. /// /// public class SiteEventLoggerLifetimePinTests { /// /// The failure message is the whole point of the test — it has to carry the reader /// from "a lifetime assertion failed" to "here is what breaks and why". /// private const string Why = "ISiteEventLogger MUST be registered Singleton in the site service graph " + "(deferred-work register row 62).\n" + "Six SiteRuntime call sites resolve it straight off the root IServiceProvider with " + "no scope: AlarmActor (cached in a field for the actor's lifetime), ScriptActor (x2), " + "InstanceActor (x2), NativeAlarmActor, DeploymentManagerActor, and " + "Scripts/ScriptRunLauncher.\n" + "Under any non-singleton lifetime every one of those becomes a captive-dependency / " + "resolved-outside-its-scope bug (register row 45), and NOTHING else catches it: " + "ValidateOnBuild only walks constructor graphs, ValidateScopes fires at runtime on a " + "site node rather than at boot, and CaptiveDependencyGuard sees only the AlarmActor " + "field case.\n" + "If this lifetime change is intended, fix those six call sites to resolve through an " + "explicit scope in the same change, then update this pin."; /// /// Builds the real Site service graph the way Program.cs's Site branch does and /// asserts the lifetime of the registration the SiteRuntime root-resolves depend on. /// Registration only — the provider is never built, so this costs nothing and touches /// no SQLite file. /// /// The configured site service collection. private static IServiceCollection BuildSiteServiceGraph() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ScadaBridge:Node:Role"] = "Site", ["ScadaBridge:Node:NodeName"] = "node-a", ["ScadaBridge:Node:NodeHostname"] = "test-site", ["ScadaBridge:Node:SiteId"] = "TestSite", ["ScadaBridge:Node:RemotingPort"] = "0", ["ScadaBridge:Node:GrpcPort"] = "0", ["LocalDb:Path"] = "/tmp/sb-row62-pin-never-opened.db", ["AuditLog:SiteWriter:DatabasePath"] = "/tmp/sb-row62-pin-never-opened-audit.db", ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551", ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552", }) .Build(); var services = new ServiceCollection(); SiteServiceRegistration.Configure(services, configuration); return services; } /// Verifies that ISiteEventLogger is registered as a singleton in the site service graph. [Fact] public void ISiteEventLogger_IsRegisteredSingleton_InTheSiteServiceGraph() { var services = BuildSiteServiceGraph(); var descriptor = Assert.Single(services, d => d.ServiceType == typeof(ISiteEventLogger)); Assert.True(descriptor.Lifetime == ServiceLifetime.Singleton, $"{Why}\nActual lifetime: {descriptor.Lifetime}."); } /// Verifies that the concrete type the interface forwards to is also registered as a singleton. [Fact] public void SiteEventLogger_TheConcreteTypeTheInterfaceForwardsTo_IsAlsoSingleton() { // The interface registration is a FACTORY — // AddSingleton(sp => sp.GetRequiredService()) // — so a Singleton lifetime on ISiteEventLogger alone does not prove callers get a // shared instance. Downgrading the concrete SiteEventLogger to Scoped would leave // the assertion above passing while making the singleton factory resolve a scoped // service from the root: the exact bug, with the pin still green. Both halves have // to be pinned for the invariant to actually hold. var services = BuildSiteServiceGraph(); var descriptor = Assert.Single(services, d => d.ServiceType == typeof(SiteEventLogger)); Assert.True(descriptor.Lifetime == ServiceLifetime.Singleton, $"{Why}\nISiteEventLogger forwards to the concrete SiteEventLogger, whose actual " + $"lifetime is: {descriptor.Lifetime}."); } }