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"); } /// /// Register row 61: the allowlist is now EMPTY and must stay that way. /// /// /// This replaces the former staleness self-test, which checked that each entry still named /// a field the scanner would flag. Its two entries — AuditLogIngestActor and /// SiteCallAuditActor's _injectedRepository — covered repository fields /// assigned only by test-mode constructors; those constructors have been deleted and the /// fixtures fold onto the IServiceProvider 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. /// /// [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."); } /// /// 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. /// [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"); } /// /// A minimal descriptor set naming the two repository types the deleted test-mode /// constructors used to cache, 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)); } }