using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Deferred register row 50, mitigation 1: .NET's built-in DI scope validation is on in /// Development for BOTH composition roots, and both roots actually survive it. /// /// /// The build-and-validate form is the load-bearing one. Asserting that /// ValidateScopes is set proves only that a flag was written; asserting that the /// full Central and Site graphs BUILD with ValidateOnBuild on is what proves there /// is no latent singleton-captures-scoped violation to be surfaced — and it keeps /// proving it as registrations are added, on every CI run, without anyone having to boot /// a node in Development. /// /// Both roles force the flags on explicitly rather than relying on the environment, so /// the check runs identically in CI (where the environment is whatever the runner /// inherits) and so a future change to the environment plumbing cannot silently turn the /// coverage off — the exact failure mode this row is about. /// public class ServiceProviderValidationOptionsTests { [Fact] public void Apply_InDevelopment_EnablesBothValidations() { var options = new ServiceProviderOptions(); ServiceProviderValidation.Apply(options, isDevelopment: true); Assert.True(options.ValidateScopes); Assert.True(options.ValidateOnBuild); } [Fact] public void Apply_OutsideDevelopment_LeavesBothValidationsOff() { // Production pays neither the per-resolve scope check nor the boot-time graph // walk, and — more importantly — a validation failure cannot fail a production // node closed at boot. See ServiceProviderValidation's remarks. var options = new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }; ServiceProviderValidation.Apply(options, isDevelopment: false); Assert.False(options.ValidateScopes); Assert.False(options.ValidateOnBuild); } } /// /// Builds the REAL Central composition root (the whole Program.cs Central branch) /// with scope + build validation forced on, and asserts it builds clean. /// [Collection(HostBootCollection.Name)] public class CentralServiceProviderValidationTests : IDisposable { private readonly string? _previousEnv; private readonly CentralDbTestEnvironment _dbEnv; private WebApplicationFactory? _factory; public CentralServiceProviderValidationTests() { _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); _dbEnv = new CentralDbTestEnvironment(); } public void Dispose() { try { _factory?.Dispose(); } finally { Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); _dbEnv.Dispose(); } } /// /// A captive dependency anywhere in the Central graph — a singleton whose constructor /// takes a scoped service — makes ValidateOnBuild throw an /// here listing every offender, so this test names /// the violation rather than leaving it to be found by a rig log line months later /// (which is how row 45 was found). /// [Fact] public void Central_CompositionRoot_BuildsWithScopeValidationEnabled() { _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { // Force the flags on regardless of the runner's environment. This is the // same configuration Program.cs applies in Development, exercised here as // a hard assertion. builder.UseDefaultServiceProvider(options => { options.ValidateScopes = true; options.ValidateOnBuild = true; }); 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($"ScopeValidation_{Guid.NewGuid()}")); AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services); }); }); // Building the server is the assertion: ValidateOnBuild throws from // BuildServiceProvider, and ValidateScopes throws on the first offending resolve. var services = _factory.Services; Assert.NotNull(services); } } /// /// The Site half. This is the branch that actually gains coverage from the Program.cs /// change: the docker rig sets ASPNETCORE_ENVIRONMENT=Development on the central /// containers but not on the site containers, so a site node has always built its /// provider unvalidated. /// public class SiteServiceProviderValidationTests : IDisposable { private readonly string _tempDbPath; private readonly string _tempTrackingDbPath; private readonly string _tempLocalDbPath; private readonly string _tempAuditDbPath; private WebApplication? _host; public SiteServiceProviderValidationTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_{Guid.NewGuid()}.db"); _tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_track_{Guid.NewGuid()}.db"); _tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_localdb_{Guid.NewGuid()}.db"); _tempAuditDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_audit_{Guid.NewGuid()}.db"); } public void Dispose() { (_host as IDisposable)?.Dispose(); foreach (var path in new[] { _tempDbPath, _tempTrackingDbPath, _tempLocalDbPath, _tempAuditDbPath }) { try { File.Delete(path); } catch { /* best effort */ } } } [Fact] public void Site_CompositionRoot_BuildsWithScopeValidationEnabled() { 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", }); // The production Program.cs Site branch applies this via // ServiceProviderValidation.Configure; forced on here so the check does not depend // on the test runner's environment. builder.Host.UseDefaultServiceProvider(options => { options.ValidateScopes = true; options.ValidateOnBuild = true; }); // Mirrors the Program.cs site section. builder.Services.AddGrpc(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); SiteServiceRegistration.Configure(builder.Services, builder.Configuration); AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services); // Build is the assertion — ValidateOnBuild throws here if anything in the site // graph is a captive dependency. _host = builder.Build(); Assert.NotNull(_host.Services); } }