Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuardTests.cs
T
Joseph Doherty 00a804e6d9 guard(di): captive-dependency tripwires — scope validation + an actor field scan
Register row 50. Row 45 (fixed 9e769735) was a live captive dependency —
NotificationOutboxActor cached delivery adapters resolved from an actor-lifetime
IServiceScope, so MaxParallelDeliveries concurrent deliveries shared one EF
DbContext and central-b logged EF's "second operation was started on this context
instance" continuously. Actors take the ROOT IServiceProvider rather than typed
services, which is invisible to build-time analysis, so the bug class had no
mechanical guard at all. This adds both of the register's mitigations; neither
subsumes the other.

1. ValidateScopes + ValidateOnBuild in Development, on BOTH branches, via
   ServiceProviderValidation.Configure. WebApplication.CreateBuilder already sets
   both flags to IsDevelopment(), so on paper this restates a default — it is
   written out because that default is silent (a future move off CreateBuilder
   drops it with no diagnostic) and because this host carries three
   environment-ish signals (SCADABRIDGE_CONFIG, DOTNET_ENVIRONMENT,
   ASPNETCORE_ENVIRONMENT), so "is this node in Development" is not obvious at the
   call site. Production stays unvalidated deliberately: a validation failure is
   fail-closed, and CI now proves what a production node will not check.

   This surfaced two REAL latent violations on the SITE role — not captive
   dependencies but unconstructible descriptors, which fail ValidateOnBuild for
   the entire graph and are therefore what kept the site branch from being
   validatable at all (SiteHealthEndpointTests had pinned UseEnvironment
   ("Production") to work around exactly this):

     - ReconcileService (scoped, needs the central-only IDeploymentManagerRepository)
       moved out of the shared AddCommunication() into a new AddCentralCommunication().
     - AuditLogKpiSampleSource (scoped IKpiSampleSource, needs the central-only
       IAuditLogRepository) moved out of the shared AddAuditLog() into a new
       AddAuditLogCentralKpi().

   Both were registered on sites under the argument "harmless — only the central
   actor ever resolves it". True of the runtime, false of the container: a
   registration nothing can build is what stops the validator being usable, and it
   masks whatever real defect the validator would have found. Both are now
   central-only and SiteHealthEndpointTests boots the site branch validated again.

2. CaptiveDependencyGuard: a reflection scan over every actor class in the shipped
   assemblies (28 today; the reference graph is walked from the Host assembly
   because referenced assemblies load lazily). Fails on any instance field — auto-
   property backing fields included, they are ordinary private fields — typed as
   IServiceScope, as a DbContext, or as a service the REAL composition roots
   register Scoped. The scoped set is derived from the built Central and Site
   containers rather than hand-listed, so a lifetime change automatically re-scopes
   the check. IServiceProvider / IServiceScopeFactory fields are explicitly allowed:
   that IS the correct per-operation-scope pattern.

   No live violation exists — every actor opens a scope per operation. Two fields
   are allowlisted with their safety argument rather than exempted silently:
   AuditLogIngestActor._injectedRepository and SiteCallAuditActor._injectedRepository
   are scoped repository types assigned ONLY by explicitly test-mode constructors,
   null under production wiring (AkkaHostedService uses the IServiceProvider
   overload in both cases), and SiteCallAuditActor additionally wraps its injected
   instance in a SerializedRepository. The shape is one constructor call away from
   being row 45 again, so it is recorded, not dismissed.

Tests: 10 new. Six permanent self-tests of the guard, including positive controls
that reproduce the pre-row-45 shape (an actor caching a scoped service, an
IServiceScope, a DbContext, and a scoped auto-property) and a negative control for
the allowed root-provider pattern — a reflection guard that matches nothing passes
just as green as one that works. Plus an actor-count floor with cross-assembly
spot-checks, an allowlist-staleness test that fails any entry no longer matching a
flagged field, and the two per-role scans. Verified non-vacuous by emptying the
allowlist: the Central scan then names both real fields. Two composition-root
build tests force both validation flags on per role regardless of environment.

Doc: REQ-HOST-11 in Component-Host.md records all three rules and their guards.
2026-08-15 11:58:40 -04:00

384 lines
16 KiB
C#

using Akka.Actor;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// The guard's own self-tests. These are what make the production scans below trustworthy:
/// a reflection guard that silently matches nothing passes just as green as one that works,
/// so the positive controls are permanent fixtures, not scaffolding.
/// </summary>
public class CaptiveDependencyGuardSelfTests
{
/// <summary>A stand-in for any service the container registers <c>Scoped</c>.</summary>
private interface IPretendScopedService;
/// <summary>
/// POSITIVE CONTROL — the pre-row-45 shape, preserved permanently. An actor that caches a
/// scoped-typed service for its lifetime is exactly what <c>NotificationOutboxActor</c>
/// looked like before 9e769735, and the guard must report it.
/// </summary>
private sealed class CachesScopedServiceActor : ReceiveActor
{
#pragma warning disable CS0169, IDE0051, CA1823 // the unused field IS the fixture
private readonly IPretendScopedService? _captive;
#pragma warning restore CS0169, IDE0051, CA1823
}
/// <summary>POSITIVE CONTROL — caching the scope itself, the other half of row 45.</summary>
private sealed class CachesScopeActor : ReceiveActor
{
#pragma warning disable CS0169, IDE0051, CA1823
private readonly IServiceScope? _scope;
#pragma warning restore CS0169, IDE0051, CA1823
}
/// <summary>POSITIVE CONTROL — an EF context held for the actor's lifetime.</summary>
private sealed class CachesDbContextActor : ReceiveActor
{
#pragma warning disable CS0169, IDE0051, CA1823
private readonly ScadaBridgeDbContext? _db;
#pragma warning restore CS0169, IDE0051, CA1823
}
/// <summary>
/// NEGATIVE CONTROL — the correct pattern. Holding the root provider and/or the scope
/// factory for the actor's lifetime must NOT be flagged; every production actor does this.
/// </summary>
private sealed class CreatesScopePerOperationActor : ReceiveActor
{
#pragma warning disable CS0169, IDE0051, CA1823
private readonly IServiceProvider? _services;
private readonly IServiceScopeFactory? _scopeFactory;
#pragma warning restore CS0169, IDE0051, CA1823
}
/// <summary>POSITIVE CONTROL — an auto-property backing field must be caught too.</summary>
private sealed class ScopedAutoPropertyActor : ReceiveActor
{
private IPretendScopedService? Captive { get; init; }
}
private static readonly IReadOnlySet<Type> PretendScopedSet =
new HashSet<Type> { typeof(IPretendScopedService) };
[Fact]
public void Guard_Flags_ActorCachingAScopedService()
{
var violations = CaptiveDependencyGuard.Scan(
[typeof(CachesScopedServiceActor)], PretendScopedSet);
var violation = Assert.Single(violations);
Assert.Equal("_captive", violation.FieldName);
Assert.Equal(typeof(IPretendScopedService), violation.FieldType);
}
[Fact]
public void Guard_Flags_ActorCachingAServiceScope_EvenWhenTheScopedSetIsEmpty()
{
// An IServiceScope field is wrong whatever the container says, so this must not
// depend on the derived scoped set having caught anything.
var violations = CaptiveDependencyGuard.Scan(
[typeof(CachesScopeActor)], new HashSet<Type>());
var violation = Assert.Single(violations);
Assert.Equal("_scope", violation.FieldName);
}
[Fact]
public void Guard_Flags_ActorCachingADbContext()
{
var violations = CaptiveDependencyGuard.Scan(
[typeof(CachesDbContextActor)], new HashSet<Type>());
var violation = Assert.Single(violations);
Assert.Equal(typeof(ScadaBridgeDbContext), violation.FieldType);
}
[Fact]
public void Guard_Flags_ScopedAutoPropertyBackingField()
{
var violations = CaptiveDependencyGuard.Scan(
[typeof(ScopedAutoPropertyActor)], PretendScopedSet);
var violation = Assert.Single(violations);
Assert.Contains("Captive", violation.FieldName, StringComparison.Ordinal);
}
[Fact]
public void Guard_Allows_RootProviderAndScopeFactoryFields()
{
var violations = CaptiveDependencyGuard.Scan(
[typeof(CreatesScopePerOperationActor)], PretendScopedSet);
Assert.Empty(violations);
}
/// <summary>
/// The scoped set must be derived from real registrations rather than hand-listed, and it
/// must pick up BOTH sides of a descriptor — row 45 cached the interface, but caching the
/// concrete adapter would have been the same bug.
/// </summary>
[Fact]
public void ScopedTypes_CollectsServiceAndImplementationTypes()
{
var services = new ServiceCollection();
services.AddScoped<IPretendScopedService, PretendScopedService>();
services.AddSingleton<PretendSingleton>();
var scoped = CaptiveDependencyGuard.ScopedTypes(services);
Assert.Contains(typeof(IPretendScopedService), scoped);
Assert.Contains(typeof(PretendScopedService), scoped);
Assert.DoesNotContain(typeof(PretendSingleton), scoped);
}
private sealed class PretendScopedService : IPretendScopedService;
private sealed class PretendSingleton;
/// <summary>
/// The assembly walk must actually find the production actors. Without this floor a
/// broken loader would make every scan below vacuously green — the classic way a
/// reflection guard rots into a no-op.
/// </summary>
[Fact]
public void ProductionActorTypes_FindsTheShippedActors()
{
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
// 28 actor classes ship today; the floor is deliberately loose so adding or
// consolidating an actor does not fail this, while a loader that finds nothing does.
Assert.True(
actorTypes.Count >= 20,
$"Expected the ScadaBridge assembly walk to find at least 20 actors, found {actorTypes.Count}. " +
"A guard that scans no types passes vacuously.");
// Spot-check one actor from a site assembly and one from a central assembly, so a
// walk that only reached the Host assembly's own actors cannot satisfy the floor.
Assert.Contains(actorTypes, t => t.Name == "InstanceActor");
Assert.Contains(actorTypes, t => t.Name == "NotificationOutboxActor");
}
/// <summary>
/// The allowlist is an escape hatch, so it is itself pinned: every entry must name a
/// field that still exists and would still be flagged. An entry that has stopped matching
/// is either a stale exemption or — worse — a typo silently covering nothing.
/// </summary>
[Fact]
public void Allowlist_EntriesAllStillMatchARealFlaggedField()
{
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
// Scan with the allowlist disabled against a scoped set wide enough to include the
// repositories the allowlisted fields hold.
var everyScopedType = CaptiveDependencyGuard.ScopedTypes(BuildCentralishScopedProbe());
var unfiltered = CaptiveDependencyGuard.Scan(actorTypes, everyScopedType, applyAllowlist: false);
var flagged = unfiltered
.Select(v => $"{v.ActorType.FullName}.{v.FieldName}")
.ToHashSet(StringComparer.Ordinal);
foreach (var entry in CaptiveDependencyGuard.Allowlist)
{
Assert.True(
flagged.Contains(entry),
$"Allowlist entry '{entry}' no longer matches a flagged field. Remove it — a stale " +
"exemption is indistinguishable from an exemption that never worked.");
}
}
/// <summary>
/// A minimal descriptor set naming the two repository types the allowlisted fields hold,
/// so <see cref="Allowlist_EntriesAllStillMatchARealFlaggedField"/> does not need to boot a
/// host. Both are registered <c>Scoped</c> by <c>AddConfigurationDatabase</c>; the
/// role-scan tests below verify that against the real container.
/// </summary>
/// <returns>The probe descriptors.</returns>
private static IServiceCollection BuildCentralishScopedProbe()
{
var services = new ServiceCollection();
services.AddScoped<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository>(
_ => throw new NotSupportedException("registration shape only"));
services.AddScoped<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteCallAuditRepository>(
_ => throw new NotSupportedException("registration shape only"));
return services;
}
}
/// <summary>
/// Runs the guard against every shipped actor using the CENTRAL composition root's real
/// scoped registrations.
/// </summary>
[Collection(HostBootCollection.Name)]
public class CentralCaptiveDependencyGuardTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
private readonly CentralDbTestEnvironment _dbEnv;
private readonly List<ServiceDescriptor> _descriptors = [];
public CentralCaptiveDependencyGuardTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_dbEnv = new CentralDbTestEnvironment();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
["ScadaBridge:Security:Ldap:Server"] = "localhost",
["ScadaBridge:Security:Ldap:Port"] = "3893",
["ScadaBridge:Security:Ldap:Transport"] = "None",
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local",
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local",
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
builder.ConfigureServices(services =>
{
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
d.ServiceType == typeof(DbContextOptions) ||
d.ServiceType == typeof(ScadaBridgeDbContext) ||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
.ToList();
foreach (var d in descriptorsToRemove)
services.Remove(d);
services.AddDbContext<ScadaBridgeDbContext>(options =>
options.UseInMemoryDatabase($"CaptiveGuard_{Guid.NewGuid()}"));
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
// ConfigureServices runs after Program.cs has added everything, so this
// snapshot IS the Central composition root's registration set.
_descriptors.AddRange(services);
});
});
_ = _factory.Server;
}
public void Dispose()
{
try
{
_factory.Dispose();
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
_dbEnv.Dispose();
}
}
[Fact]
public void Central_NoActorHoldsCaptiveScopedState()
{
var scopedTypes = CaptiveDependencyGuard.ScopedTypes(_descriptors);
Assert.NotEmpty(scopedTypes);
var violations = CaptiveDependencyGuard.Scan(
CaptiveDependencyGuard.ProductionActorTypes(), scopedTypes);
Assert.True(
violations.Count == 0,
"Actors holding captive scope state (register row 45's bug class):\n "
+ string.Join("\n ", violations));
}
}
/// <summary>
/// The same scan against the SITE composition root's real scoped registrations. Both roles
/// are checked because the scoped set differs between them — a type that is a singleton on
/// central can be scoped on a site, and only the role that registers it scoped can catch an
/// actor caching it.
/// </summary>
public class SiteCaptiveDependencyGuardTests : IDisposable
{
private readonly string _tempDbPath;
private readonly string _tempTrackingDbPath;
private readonly string _tempLocalDbPath;
private readonly string _tempAuditDbPath;
private readonly IServiceCollection _services;
public SiteCaptiveDependencyGuardTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_{Guid.NewGuid()}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_track_{Guid.NewGuid()}.db");
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_localdb_{Guid.NewGuid()}.db");
_tempAuditDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_audit_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.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",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
["LocalDb:Path"] = _tempLocalDbPath,
["AuditLog:SiteWriter:DatabasePath"] = _tempAuditDbPath,
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
});
builder.Services.AddGrpc();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Registration only — the provider is never built here, so nothing resolves and no
// site database file is opened.
_services = builder.Services;
}
public void Dispose()
{
foreach (var path in new[] { _tempDbPath, _tempTrackingDbPath, _tempLocalDbPath, _tempAuditDbPath })
{
try { File.Delete(path); } catch { /* best effort */ }
}
}
[Fact]
public void Site_NoActorHoldsCaptiveScopedState()
{
var scopedTypes = CaptiveDependencyGuard.ScopedTypes(_services);
Assert.NotEmpty(scopedTypes);
var violations = CaptiveDependencyGuard.Scan(
CaptiveDependencyGuard.ProductionActorTypes(), scopedTypes);
Assert.True(
violations.Count == 0,
"Actors holding captive scope state (register row 45's bug class):\n "
+ string.Join("\n ", violations));
}
}