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; /// /// 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. /// public class CaptiveDependencyGuardSelfTests { /// A stand-in for any service the container registers Scoped. private interface IPretendScopedService; /// /// POSITIVE CONTROL — the pre-row-45 shape, preserved permanently. An actor that caches a /// scoped-typed service for its lifetime is exactly what NotificationOutboxActor /// looked like before 9e769735, and the guard must report it. /// 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 } /// POSITIVE CONTROL — caching the scope itself, the other half of row 45. private sealed class CachesScopeActor : ReceiveActor { #pragma warning disable CS0169, IDE0051, CA1823 private readonly IServiceScope? _scope; #pragma warning restore CS0169, IDE0051, CA1823 } /// POSITIVE CONTROL — an EF context held for the actor's lifetime. private sealed class CachesDbContextActor : ReceiveActor { #pragma warning disable CS0169, IDE0051, CA1823 private readonly ScadaBridgeDbContext? _db; #pragma warning restore CS0169, IDE0051, CA1823 } /// /// 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. /// 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 } /// POSITIVE CONTROL — an auto-property backing field must be caught too. private sealed class ScopedAutoPropertyActor : ReceiveActor { private IPretendScopedService? Captive { get; init; } } private static readonly IReadOnlySet PretendScopedSet = new HashSet { 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()); var violation = Assert.Single(violations); Assert.Equal("_scope", violation.FieldName); } [Fact] public void Guard_Flags_ActorCachingADbContext() { var violations = CaptiveDependencyGuard.Scan( [typeof(CachesDbContextActor)], new HashSet()); 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); } /// /// 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. /// [Fact] public void ScopedTypes_CollectsServiceAndImplementationTypes() { var services = new ServiceCollection(); services.AddScoped(); services.AddSingleton(); 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; /// /// 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. /// [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"); } /// /// 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. /// [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."); } } /// /// A minimal descriptor set naming the two repository types the allowlisted fields hold, /// so does not need to boot a /// host. Both are registered Scoped by AddConfigurationDatabase; the /// role-scan tests below verify that against the real container. /// /// The probe descriptors. 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; } } /// /// Runs the guard against every shipped actor using the CENTRAL composition root's real /// scoped registrations. /// [Collection(HostBootCollection.Name)] public class CentralCaptiveDependencyGuardTests : IDisposable { private readonly WebApplicationFactory _factory; private readonly string? _previousEnv; private readonly CentralDbTestEnvironment _dbEnv; private readonly List _descriptors = []; public CentralCaptiveDependencyGuardTests() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); _dbEnv = new CentralDbTestEnvironment(); _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["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) || 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(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)); } } /// /// 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. /// 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 { ["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)); } }