Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CaptiveDependencyGuardTests.cs
T
Joseph Doherty 92fe5acca7 refactor(auditlog+sitecallaudit): delete the _injectedRepository test ctors — register row 61
AuditLogIngestActor and SiteCallAuditActor each exposed constructors taking a scoped
repository and caching it on a field for the actor's lifetime. Safe only because no
production call site picked those overloads — i.e. one call site away from re-introducing
the row-45 captive-dependency bug — which is why CaptiveDependencyGuard had to allowlist
both fields.

Preferred fix applied: the test fixtures fold onto the IServiceProvider overload with a
stub provider, and the constructors are gone.

Product deletions:
  - AuditLogIngestActor: the (IAuditLogRepository, ILogger) ctor, the _injectedRepository
    field and its branch in OnIngestAsync, and OnCachedTelemetryWithoutDualWriteAsync (the
    "this ctor cannot dual-write" fallback handler that only that ctor installed).
    _serviceProvider is now non-nullable and the null-forgiving operators are gone.
  - SiteCallAuditActor: both test ctors (repo-only, and repo + reconciliation
    collaborators), the _injectedRepository field and its four branches, and the private
    SerializedRepository class. _backgroundTimersEnabled went with them: only the deleted
    repo-only ctor ever set it false, so it was a constant and its two guards are removed.
    The reconciliation tick is still gated — on the collaborators being resolvable, which
    is how production reaches the same state.

Test fixtures (12 call sites): two small TestSupport helpers build the stub providers.
SiteCallAuditActor's production wiring resolves a FRESH repository per scope, so the
serializing decorator existed only to protect fixtures that share one DbContext-backed
instance across every scope — it moves into the test project as
SerializingSiteCallAuditRepository, which is where the hazard is actually created.
Fixtures that relied on it keep it. The relay test's ICentralAuditWriter now arrives via
the provider, exactly as production resolves it.

Behaviour is preserved on the paths under test: the deleted audit ctor passed
redactor:null / failureCounter:null, and a provider registering neither resolves both to
null, so the SafeDefaultAuditRedactor fallback and counter-free logging are unchanged. No
fixture on the folded sites sends IngestCachedTelemetryCommand, so swapping to the real
dual-write handler is inert. The SiteCallAudit purge timer now arms in fixtures that
previously had timers off, but every one of them uses default purge settings whose first
tick is 5 minutes out — nothing fires inside a test.

One reconciliation test was renamed and rewritten rather than deleted:
TestCtor_RepositoryOnly_DoesNotStartReconciliationTick became
NoReconciliationCollaboratorsRegistered_DoesNotStartReconciliationTick — the same
guarantee, expressed the way production actually reaches it.

Guard: the allowlist is now EMPTY. The staleness self-test (every entry must still match a
flagged field) is replaced by two stronger pins — Allowlist_IsEmpty asserts nothing is
exempt at all, and FormerlyAllowlistedActors_NowScanClean scans both actors with the
allowlist DISABLED, so the empty allowlist is evidence the fields are gone rather than
evidence the scan stopped looking.

Negative control: re-adding an ISiteCallAuditRepository field to SiteCallAuditActor fails
both FormerlyAllowlistedActors_NowScanClean and the real
Central_NoActorHoldsCaptiveScopedState scan.

Verification: solution builds clean. AuditLog.Tests 3/3 (383), SiteCallAudit.Tests 3/3
(66), Host.Tests 2/2 (505), IntegrationTests 2/2 (115), Communication.Tests 2/2 (717).
2026-08-15 17:49:39 -04:00

411 lines
18 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>
/// Register row 61: the allowlist is now EMPTY and must stay that way.
///
/// <para>
/// This replaces the former staleness self-test, which checked that each entry still named
/// a field the scanner would flag. Its two entries — <c>AuditLogIngestActor</c> and
/// <c>SiteCallAuditActor</c>'s <c>_injectedRepository</c> — covered repository fields
/// assigned only by test-mode constructors; those constructors have been deleted and the
/// fixtures fold onto the <c>IServiceProvider</c> overload, so there is no longer a field
/// to exempt. An empty allowlist is a strictly stronger statement than a correct one:
/// nothing is exempt, so nothing can rot.
/// </para>
/// </summary>
[Fact]
public void Allowlist_IsEmpty_NoActorExemptFromTheCaptiveDependencyScan()
{
Assert.True(
CaptiveDependencyGuard.Allowlist.Count == 0,
"The captive-dependency allowlist must stay empty (register row 61). Entries present: " +
string.Join(", ", CaptiveDependencyGuard.Allowlist) +
". An entry means an actor caches a scoped service on a field — fix the actor " +
"(fold test fixtures onto the IServiceProvider overload) rather than exempting it.");
}
/// <summary>
/// Register row 61 (the other half of the pin): with the allowlist empty, the two actors
/// that used to need exemptions must now scan CLEAN against the scoped repository types
/// they resolve. This is what makes the empty allowlist meaningful — it proves the entries
/// were removed because the fields are gone, not because the scan stopped looking.
/// </summary>
[Fact]
public void FormerlyAllowlistedActors_NowScanClean()
{
var actorTypes = CaptiveDependencyGuard.ProductionActorTypes();
var everyScopedType = CaptiveDependencyGuard.ScopedTypes(BuildCentralishScopedProbe());
// Allowlist disabled, so a surviving _injectedRepository-shaped field would still show.
var unfiltered = CaptiveDependencyGuard.Scan(actorTypes, everyScopedType, applyAllowlist: false);
var offenders = unfiltered
.Where(v => v.ActorType.Name is "AuditLogIngestActor" or "SiteCallAuditActor")
.Select(v => v.ToString())
.ToList();
Assert.True(offenders.Count == 0,
"AuditLogIngestActor / SiteCallAuditActor must hold no scoped repository field " +
"(register row 61). Found: " + string.Join(" | ", offenders));
// Sanity: the scan really did examine those two actors, so the clean result above is
// evidence rather than an empty walk.
Assert.Contains(actorTypes, t => t.Name == "AuditLogIngestActor");
Assert.Contains(actorTypes, t => t.Name == "SiteCallAuditActor");
}
/// <summary>
/// A minimal descriptor set naming the two repository types the deleted test-mode
/// constructors used to cache, so <see cref="FormerlyAllowlistedActors_NowScanClean"/> 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));
}
}