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);
}
}