Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteEventLoggerLifetimePinTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

149 lines
7.8 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Deferred-work register row 62 — hardening pin, not a bug fix.
///
/// <para>
/// Six SiteRuntime call sites reach <c>ISiteEventLogger</c> straight off the injected
/// <see cref="IServiceProvider"/>, with no scope in between:
/// </para>
/// <list type="bullet">
/// <item><description><c>SiteRuntime/Actors/AlarmActor.cs</c> — resolves once in the
/// constructor and CACHES it in <c>_siteEventLogger</c>, so it holds the instance for
/// the actor's whole lifetime.</description></item>
/// <item><description><c>SiteRuntime/Actors/ScriptActor.cs</c> (two sites) — per-call
/// resolve.</description></item>
/// <item><description><c>SiteRuntime/Actors/InstanceActor.cs</c> (two sites) — per-call
/// resolve.</description></item>
/// <item><description><c>SiteRuntime/Actors/NativeAlarmActor.cs</c> — per-call
/// resolve.</description></item>
/// <item><description><c>SiteRuntime/Actors/DeploymentManagerActor.cs</c> — per-call
/// resolve.</description></item>
/// <item><description><c>SiteRuntime/Scripts/ScriptRunLauncher.cs</c> — 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.</description></item>
/// </list>
///
/// <para>
/// Every one of those is CORRECT TODAY, and this test is the reason it stays that way.
/// They are correct for exactly one reason: <c>ISiteEventLogger</c> is registered
/// <see cref="ServiceLifetime.Singleton"/>, 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:
/// </para>
/// <list type="bullet">
/// <item><description><c>ValidateScopes</c> 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 <c>GetService</c> 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.</description></item>
/// <item><description><c>ValidateOnBuild</c> walks CONSTRUCTOR graphs. These are
/// imperative <c>GetService</c> calls inside method bodies, invisible to it.</description></item>
/// <item><description><c>CaptiveDependencyGuard</c> catches the AlarmActor field case
/// (a cached resolve) by design, but cannot see the five per-call resolves at
/// all.</description></item>
/// </list>
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
public class SiteEventLoggerLifetimePinTests
{
/// <summary>
/// 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".
/// </summary>
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.";
/// <summary>
/// Builds the real Site service graph the way <c>Program.cs</c>'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.
/// </summary>
/// <returns>The configured site service collection.</returns>
private static IServiceCollection BuildSiteServiceGraph()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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;
}
/// <summary>Verifies that <c>ISiteEventLogger</c> is registered as a singleton in the site service graph.</summary>
[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}.");
}
/// <summary>Verifies that the concrete type the interface forwards to is also registered as a singleton.</summary>
[Fact]
public void SiteEventLogger_TheConcreteTypeTheInterfaceForwardsTo_IsAlsoSingleton()
{
// The interface registration is a FACTORY —
// AddSingleton<ISiteEventLogger>(sp => sp.GetRequiredService<SiteEventLogger>())
// — 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}.");
}
}