Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs
T
Joseph Doherty 6ae605160c chore(auth): ScadaBridge unify dev LDAP base DN to dc=zb,dc=local (Task 1.6)
Replace dc=scadabridge,dc=local with dc=zb,dc=local in all dev/test LDAP
references — app config, docker test-cluster node configs (docker/ and
docker-env2/), GLAuth fixture, dev tooling, Host.Tests fixtures,
IntegrationTests factory, and operational test_infra docs. OU structure
(ou=SCADA-Admins,ou=users,etc.) preserved throughout. Email domains
(@scadabridge.local), hostnames, and container names are untouched.
Historical plan docs (2026-05-24-second-environment.md,
2026-05-31-folder-repo-rename-scadabridge-design.md) excluded as
point-in-time records. No synthetic dc=example,dc=com placeholders touched.
2026-06-02 06:54:14 -04:00

485 lines
20 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
using ZB.MOM.WW.ScadaBridge.Host.Health;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.NotificationService;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Removes AkkaHostedService from running as a hosted service while keeping it
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
/// </summary>
internal static class AkkaHostedServiceRemover
{
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
{
// Pattern used in Program.cs:
// services.AddSingleton<AkkaHostedService>(); // index N
// services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); // index N+1
// We keep the singleton so DI resolution works, but remove the IHostedService
// factory so StartAsync is never called.
int akkaIndex = -1;
for (int i = 0; i < services.Count; i++)
{
if (services[i].ServiceType == typeof(AkkaHostedService))
{
akkaIndex = i;
break;
}
}
if (akkaIndex < 0) return;
// The IHostedService factory is the next registration after the singleton
for (int i = akkaIndex + 1; i < services.Count; i++)
{
if (services[i].ServiceType == typeof(IHostedService)
&& services[i].ImplementationFactory != null)
{
services.RemoveAt(i);
break;
}
}
}
}
/// <summary>
/// Verifies every expected DI service resolves from the Central composition root.
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
/// </summary>
public class CentralCompositionRootTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
public CentralCompositionRootTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
// Supply the pepper so the Central-role StartupValidator preflight (1fcc4f5)
// passes. The pre-host config builder uses AddEnvironmentVariables(), which
// runs before WithWebHostBuilder.ConfigureAppConfiguration applies DI config.
Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper",
CentralDbTestEnvironment.TestPepper);
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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!",
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
// ServiceAccountDn is now required by the library's LdapOptionsValidator
// (ValidateOnStart), so it must be present for the host to start.
["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",
// Auth re-arch (C5): inbound-API keys live in the shared
// ZB.MOM.WW.Auth.ApiKeys SQLite store. The verifier reuses
// this same config key as its pepper secret (PepperSecretName),
// and AddZbApiKeyAuth fails fast if it is missing/weak — so a
// configured pepper is still required for the host to start.
["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 =>
{
// Replace SQL Server with in-memory database
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($"CompositionRootTests_{Guid.NewGuid()}"));
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
});
});
// Trigger host build
_ = _factory.Server;
}
public void Dispose()
{
_factory.Dispose();
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
Environment.SetEnvironmentVariable("ScadaBridge__InboundApi__ApiKeyPepper", null);
}
// --- Singletons ---
[Theory]
[MemberData(nameof(CentralSingletonServices))]
public void Central_ResolveSingleton(Type serviceType)
{
var service = _factory.Services.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
new object[] { typeof(ISiteHealthCollector) },
new object[] { typeof(CentralHealthAggregator) },
new object[] { typeof(ICentralHealthAggregator) },
new object[] { typeof(OperationLockManager) },
new object[] { typeof(OAuth2TokenService) },
new object[] { typeof(InboundScriptExecutor) },
// Security: the shared ZB.MOM.WW.Auth.Ldap service is registered as a stateless
// singleton by AddZbLdapAuth (Task 1.2), replacing the old scoped LdapAuthService.
new object[] { typeof(ILdapAuthService) },
};
// --- Scoped services ---
[Theory]
[MemberData(nameof(CentralScopedServices))]
public void Central_ResolveScoped(Type serviceType)
{
using var scope = _factory.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralScopedServices => new[]
{
// TemplateEngine
new object[] { typeof(TemplateService) },
new object[] { typeof(SharedScriptService) },
new object[] { typeof(InstanceService) },
new object[] { typeof(SiteService) },
new object[] { typeof(AreaService) },
new object[] { typeof(TemplateDeletionService) },
// DeploymentManager
new object[] { typeof(IFlatteningPipeline) },
new object[] { typeof(DeploymentService) },
new object[] { typeof(ArtifactDeploymentService) },
// Security (ILdapAuthService is now a singleton — see CentralSingletonServices)
new object[] { typeof(JwtTokenService) },
new object[] { typeof(RoleMapper) },
// InboundAPI — auth re-arch (C5): the legacy ApiKeyValidator was retired;
// inbound auth runs through the shared ZB.MOM.WW.Auth.ApiKeys verifier.
new object[] { typeof(RouteHelper) },
// ExternalSystemGateway
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
// NotificationService — central-only SMTP primitives. The orphan
// NotificationDeliveryService + INotificationDeliveryService were removed
// (NS-019) when sites stopped delivering notifications; the central
// EmailNotificationDeliveryAdapter is now the only resolver of these
// primitives.
new object[] { typeof(Func<ISmtpClientWrapper>) },
new object[] { typeof(OAuth2TokenService) },
// ConfigurationDatabase repositories
new object[] { typeof(ScadaBridgeDbContext) },
new object[] { typeof(ISecurityRepository) },
new object[] { typeof(ICentralUiRepository) },
new object[] { typeof(ITemplateEngineRepository) },
new object[] { typeof(IDeploymentManagerRepository) },
new object[] { typeof(ISiteRepository) },
new object[] { typeof(IExternalSystemRepository) },
new object[] { typeof(INotificationRepository) },
new object[] { typeof(IInboundApiRepository) },
new object[] { typeof(IAuditService) },
new object[] { typeof(IInstanceLocator) },
// CentralUI
new object[] { typeof(AuthenticationStateProvider) },
};
// --- Transient services ---
[Theory]
[MemberData(nameof(CentralTransientServices))]
public void Central_ResolveTransient(Type serviceType)
{
using var scope = _factory.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralTransientServices => new[]
{
new object[] { typeof(FlatteningService) },
new object[] { typeof(DiffService) },
new object[] { typeof(RevisionHashService) },
new object[] { typeof(ScriptCompiler) },
new object[] { typeof(SemanticValidator) },
new object[] { typeof(ValidationService) },
};
// --- Options ---
[Theory]
[MemberData(nameof(CentralOptions))]
public void Central_ResolveOptions(Type optionsType)
{
var service = _factory.Services.GetService(optionsType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> CentralOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
new object[] { typeof(IOptions<ClusterOptions>) },
new object[] { typeof(IOptions<DatabaseOptions>) },
new object[] { typeof(IOptions<CommunicationOptions>) },
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
new object[] { typeof(IOptions<NotificationOptions>) },
new object[] { typeof(IOptions<LoggingOptions>) },
new object[] { typeof(IOptions<SecurityOptions>) },
new object[] { typeof(IOptions<InboundApiOptions>) },
new object[] { typeof(IOptions<ManagementServiceOptions>) },
new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
};
// --- Hosted services ---
[Fact]
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
{
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
}
// --- InboundAPI-022 regression ---
/// <summary>
/// InboundAPI-022 regression: the Central composition root MUST register a
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
/// standby central node continues to serve the inbound API, racing the
/// active node and executing scripts against stale singleton state. The
/// design's "central cluster only (active node)" guarantee is enforced only
/// when the production gate is wired here.
///
/// Structural check on the built provider (not just <see cref="IServiceCollection"/>)
/// — a registration the framework cannot resolve to a concrete instance is
/// indistinguishable from "missing" at runtime, which is the failure mode
/// the finding describes.
/// </summary>
[Fact]
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
{
var gate = _factory.Services.GetService<IActiveNodeGate>();
Assert.NotNull(gate);
Assert.IsType<ActiveNodeGate>(gate);
}
}
/// <summary>
/// Verifies every expected DI service resolves from the Site composition root.
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
/// matches the real Program.cs registration (WebApplicationBuilder + gRPC).
/// </summary>
public class SiteCompositionRootTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Node:GrpcPort"] = "0",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
});
// gRPC server registration (mirrors Program.cs site section)
builder.Services.AddGrpc();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
_host = builder.Build();
}
public void Dispose()
{
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
}
// --- Singletons ---
[Theory]
[MemberData(nameof(SiteSingletonServices))]
public void Site_ResolveSingleton(Type serviceType)
{
var service = _host.Services.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
new object[] { typeof(ISiteHealthCollector) },
new object[] { typeof(SiteStorageService) },
new object[] { typeof(ScriptCompilationService) },
new object[] { typeof(SharedScriptLibrary) },
new object[] { typeof(SiteStreamManager) },
new object[] { typeof(ISiteStreamSubscriber) },
new object[] { typeof(SiteStreamGrpcServer) },
new object[] { typeof(IDataConnectionFactory) },
new object[] { typeof(StoreAndForwardStorage) },
new object[] { typeof(StoreAndForwardService) },
new object[] { typeof(ReplicationService) },
new object[] { typeof(ISiteEventLogger) },
new object[] { typeof(IEventLogQueryService) },
new object[] { typeof(ISiteIdentityProvider) },
new object[] { typeof(IHealthReportTransport) },
};
// --- Scoped services ---
[Theory]
[MemberData(nameof(SiteScopedServices))]
public void Site_ResolveScoped(Type serviceType)
{
using var scope = _host.Services.CreateScope();
var service = scope.ServiceProvider.GetService(serviceType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteScopedServices => new[]
{
new object[] { typeof(IExternalSystemRepository) },
new object[] { typeof(INotificationRepository) },
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
};
// --- Implementation type assertions ---
[Fact]
public void Site_ExternalSystemRepository_IsSiteImplementation()
{
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
Assert.IsType<SiteExternalSystemRepository>(repo);
}
[Fact]
public void Site_NotificationRepository_IsSiteImplementation()
{
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
Assert.IsType<SiteNotificationRepository>(repo);
}
// --- Options ---
[Theory]
[MemberData(nameof(SiteOptions))]
public void Site_ResolveOptions(Type optionsType)
{
var service = _host.Services.GetService(optionsType);
Assert.NotNull(service);
}
public static IEnumerable<object[]> SiteOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
new object[] { typeof(IOptions<ClusterOptions>) },
new object[] { typeof(IOptions<DatabaseOptions>) },
new object[] { typeof(IOptions<CommunicationOptions>) },
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
new object[] { typeof(IOptions<NotificationOptions>) },
new object[] { typeof(IOptions<LoggingOptions>) },
new object[] { typeof(IOptions<SiteRuntimeOptions>) },
new object[] { typeof(IOptions<DataConnectionOptions>) },
new object[] { typeof(IOptions<StoreAndForwardOptions>) },
new object[] { typeof(IOptions<SiteEventLogOptions>) },
};
// --- Hosted services ---
[Theory]
[MemberData(nameof(SiteHostedServices))]
public void Site_HostedServiceRegistered(Type expectedType)
{
var hostedServices = _host.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
}
public static IEnumerable<object[]> SiteHostedServices => new[]
{
new object[] { typeof(HealthReportSender) },
new object[] { typeof(SiteStorageInitializer) },
new object[] { typeof(EventLogPurgeService) },
};
}