Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs
T
Joseph Doherty 153609ef8a feat(central): one central cache-invalidation topic; role-mapping and external-definition caches follow peer edits (ScadaBridge#100 item 3, ScadaBridge#83)
The #96 site-psk-invalidation topic was the first cross-node invalidation broadcast.
It is generalised into ONE central DistributedPubSub topic, central-cache-invalidation,
carrying typed messages (Commons ICentralCacheInvalidation: SitePskInvalidated,
RoleMappingsInvalidated, ExternalDefinitionsInvalidated, InboundApiMethodInvalidated)
in an envelope stamped with the publisher's process-unique origin id.

- Host: CentralCacheInvalidationBus (ICentralCacheInvalidationPublisher, no-op until
  the mediator is set, never throws) and CentralCacheInvalidationSubscriber, which
  skips its own node's envelopes and hands the peer's to every registered
  ICentralCacheInvalidationHandler (a failing handler does not stop the rest).
  Replaces SitePskInvalidationSubscriber.
- Migration of #96: SitePskRotationInvalidator publishes through the bus and is the
  PSK handler; SitePskInvalidated keeps its type name and is also published bare on
  site-psk-invalidation, which the subscriber still listens to, so a mixed-version
  central pair keeps exchanging PSK invalidations. Remove after one release.
- Audit D1: RoleMappingCache is a handler; ManagementActor's post-commit role-mapping
  invalidation also publishes RoleMappingsInvalidated, so a revocation is immediate on
  the peer instead of after RoleMappingCacheSeconds.
- Audit D3 / #83 (peer half): ExternalDefinitionLookupCache is a handler; the 9
  ManagementActor handlers and the Transport bundle importer publish
  ExternalDefinitionsInvalidated after commit. (#83's local import half was already in.)

TTLs remain the fallback for a lost broadcast or a direct SQL edit. Tests: bus and
subscriber unit tests (origin filter, legacy topic), peer-node tests through the real
caches in ManagementService.Tests, importer publish, composition root, and a real
two-node cluster from the production HOCON (CentralCacheInvalidationTwoNodeTests)
proving every message type crosses with the production serializer. Docs: Host,
Communication, Security, ManagementService, ESG, and the #85 cache audit.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 16:04:28 -05:00

877 lines
43 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.Host.Services;
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
{
/// <summary>Removes only the Akka hosted service registration, leaving the Akka services resolvable.</summary>
/// <param name="services">The service collection to modify.</param>
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>
[Collection(HostBootCollection.Name)]
public class CentralCompositionRootTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
private readonly CentralDbTestEnvironment _dbEnv;
/// <summary>Initializes the tests by booting the central composition root with test configuration.</summary>
public CentralCompositionRootTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
// Supply the pepper and the G-4 secret-backed keys
// (ConfigurationDb / LDAP service-account password / JWT signing key) as
// whole-key env overrides so the pre-host config builder — which calls
// AddEnvironmentVariables() before the ${secret:...} expander runs — sees
// concrete values and the Central-role StartupValidator preflight passes.
_dbEnv = new CentralDbTestEnvironment();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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!",
// 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;
}
/// <summary>Disposes the web application factory and restores the environment variables.</summary>
public void Dispose()
{
// try/finally: a throwing host teardown must not strand the process-wide
// environment variables for the rest of the run.
try
{
_factory.Dispose();
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
_dbEnv.Dispose();
}
}
// --- Singletons ---
/// <summary>Verifies that the service resolves as a singleton from the central composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[Theory]
[MemberData(nameof(CentralSingletonServices))]
public void Central_ResolveSingleton(Type serviceType)
{
var service = _factory.Services.GetService(serviceType);
Assert.NotNull(service);
}
/// <summary>Gets the service types expected to resolve as singletons on the central node.</summary>
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, replacing the old scoped LdapAuthService.
new object[] { typeof(ILdapAuthService) },
};
// --- Scoped services ---
/// <summary>Verifies that the service resolves from a scope in the central composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[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);
}
/// <summary>Gets the service types expected to resolve as scoped services on the central node.</summary>
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
// 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 ---
/// <summary>Verifies that the service resolves as a transient from the central composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[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);
}
/// <summary>Gets the service types expected to resolve as transient services on the central node.</summary>
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 ---
/// <summary>Verifies that the options type resolves from the central composition root.</summary>
/// <param name="optionsType">The options type to resolve.</param>
[Theory]
[MemberData(nameof(CentralOptions))]
public void Central_ResolveOptions(Type optionsType)
{
var service = _factory.Services.GetService(optionsType);
Assert.NotNull(service);
}
/// <summary>Gets the options types expected to be registered on the central node.</summary>
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 ---
/// <summary>Verifies that the central health aggregator is registered as a hosted service.</summary>
[Fact]
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
{
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
}
// --- Active-node gate regression ---
/// <summary>
/// Regression test: the Central composition root MUST register a
/// concrete <see cref="ZB.MOM.WW.Health.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>
/// <remarks>
/// Gitea #80: the gate is the shared <see cref="ZB.MOM.WW.Health.IActiveNodeGate"/> backed by
/// <see cref="ZB.MOM.WW.Health.Akka.AkkaActiveNodeGate"/> — the same ClusterActiveNode rule as
/// the /health/active probe — not a ScadaBridge-private seam.
/// </remarks>
[Fact]
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
{
var gate = _factory.Services.GetService<ZB.MOM.WW.Health.IActiveNodeGate>();
Assert.NotNull(gate);
Assert.IsType<ZB.MOM.WW.Health.Akka.AkkaActiveNodeGate>(gate);
}
// --- ScadaBridge#22 regression ---
/// <summary>
/// ScadaBridge#22 regression: the Secrets.Ui components mounted at /admin/secrets inject
/// <see cref="ZB.MOM.WW.Audit.IAuditWriter"/> — the SHARED-lib seam, not the repo's own
/// <see cref="IAuditWriter"/> (deliberately distinct per its doc comment). The secrets
/// adoption mounted the UI without bridging that seam, so component activation threw and
/// the page 500'd on every render; the login wall hid it. This resolves the full
/// injection set of every Secrets.Ui component (SecretsList / SecretEditor /
/// RevealButton / ConfirmDeleteModal) out of the REAL central composition root, because
/// the gap is invisible until the graph is actually built — the same defect class as the
/// four caught in the secrets library itself.
/// </summary>
/// <param name="serviceType">The Secrets.Ui component dependency type to resolve.</param>
[Theory]
[InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretStore))]
[InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretCipher))]
[InlineData(typeof(ZB.MOM.WW.Audit.IAuditWriter))]
public void Central_Resolves_SecretsUiComponentDependencies(Type serviceType)
{
var service = _factory.Services.GetService(serviceType);
Assert.NotNull(service);
}
/// <summary>
/// The bridge must route secrets-UI audit events into the CENTRAL direct-write path
/// (<see cref="ICentralAuditWriter"/> → dbo.AuditLog), not the site SQLite chain — the
/// site writer chain is registered on central but by design never resolved there, and
/// events written to it would dead-end in a local file no forwarder ever drains.
/// </summary>
/// <summary>
/// Register row 108: the inbound API-key store is a per-node SQLite file UNLESS
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider</c> says <c>SqlServer</c>. This pins the
/// default half — an unset Provider must leave the existing SQLite composition byte-identical,
/// including registering NO import hosted service (the one-shot SQLite→SQL copy is meaningless,
/// and would rename the live key file out from under the node, on a SQLite-backed node).
/// </summary>
[Fact]
public void Central_InboundApiKeyStore_DefaultsToSqlite()
{
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteApiKeyStore>(
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore>());
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>());
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.DoesNotContain(hostedServices, s => s is InboundApiKeyStoreImportService);
Assert.Null(_factory.Services.GetService<IInboundApiKeyStoreImporter>());
}
/// <summary>
/// Gitea #96: the secrets write path's <see cref="ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator"/>
/// must be the PSK-aware wrapper on central, or an in-place site-PSK rotation through
/// /admin/secrets never reaches the PSK cache.
/// </summary>
[Fact]
public void Central_SecretCacheInvalidator_IsTheSitePskRotationInvalidator()
{
Assert.IsType<SitePskRotationInvalidator>(
_factory.Services.GetService<ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator>());
}
/// <summary>
/// ScadaBridge#100 item 3: central publishes peer invalidations through the shared bus, and
/// every cache that takes part (site PSK, role mappings, external definitions) is registered
/// as a handler for the subscriber.
/// </summary>
[Fact]
public void Central_CacheInvalidation_BusAndHandlersAreRegistered()
{
Assert.IsType<CentralCacheInvalidationBus>(
_factory.Services.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralCacheInvalidationPublisher>());
var handlers = _factory.Services
.GetServices<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralCacheInvalidationHandler>()
.Select(h => h.GetType())
.ToList();
Assert.Contains(typeof(SitePskRotationInvalidator), handlers);
Assert.Contains(typeof(ZB.MOM.WW.ScadaBridge.Security.RoleMappingCache), handlers);
Assert.Contains(typeof(ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.ExternalDefinitionLookupCache), handlers);
Assert.Equal(handlers.Count, handlers.Distinct().Count());
}
/// <summary>Verifies that the shared audit seam resolves to the central direct-write bridge.</summary>
[Fact]
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
{
var writer = _factory.Services.GetService<ZB.MOM.WW.Audit.IAuditWriter>();
Assert.NotNull(writer);
Assert.IsType<CentralSharedSeamAuditWriter>(writer);
}
}
/// <summary>
/// Register row 108 — the shared-store half of the inbound API-key composition. With
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer</c> both central nodes must resolve
/// the SQL Server-backed stores (one shared row set) instead of each node's own SQLite file, so a
/// key minted on one node authenticates on the other after a failover.
/// </summary>
/// <remarks>
/// <para>
/// The Provider (and, in the derived fixture, the explicit connection string) is supplied as
/// a process-wide ENVIRONMENT variable rather than through
/// <c>ConfigureAppConfiguration</c>: <c>Program</c> reads this key while it is still
/// registering services, and only the pre-host configuration builder — json + environment —
/// is populated at that point. Same reason <see cref="CentralDbTestEnvironment"/> supplies
/// its values that way, and the same reason this class must sit in
/// <see cref="HostBootCollection"/>.
/// </para>
/// <para>
/// There is no SQL Server in a unit test, so the two hosted services that would dial one at
/// host start are removed: the library's own migration service (which creates the
/// <c>zbauth</c> schema) and <see cref="InboundApiKeyStoreImportService"/>. Everything under
/// test is a registration shape, which needs no live database — every store is resolved,
/// never called.
/// </para>
/// </remarks>
[Collection(HostBootCollection.Name)]
public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable
{
/// <summary>Set by the derived fixture to pin an explicit connection string instead of the ConfigurationDb default.</summary>
private protected const string ExplicitConnectionString =
"Server=explicit-host,1433;Database=ExplicitKeyStore;Trusted_Connection=true;TrustServerCertificate=true";
private const string ProviderEnvKey = "ScadaBridge__InboundApi__ApiKeyStore__Provider";
private const string ConnectionStringEnvKey =
"ScadaBridge__InboundApi__ApiKeyStore__SqlServer__ConnectionString";
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
private readonly string? _previousProvider;
private readonly string? _previousConnectionString;
private readonly CentralDbTestEnvironment _dbEnv;
/// <summary>The provider the built host actually resolved services from.</summary>
private protected IServiceProvider Services => _factory.Services;
private protected CentralSqlServerKeyStoreCompositionRootTests(string? explicitConnectionString)
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_previousProvider = Environment.GetEnvironmentVariable(ProviderEnvKey);
Environment.SetEnvironmentVariable(ProviderEnvKey, "SqlServer");
_previousConnectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvKey);
Environment.SetEnvironmentVariable(ConnectionStringEnvKey, explicitConnectionString);
_dbEnv = new CentralDbTestEnvironment();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<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($"SqlServerKeyStoreCompositionRootTests_{Guid.NewGuid()}"));
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
// No SQL Server here: drop the two hosted services that would dial one at
// host start. ApiKeyMigrationHostedService is internal to the Auth library,
// so it is matched by name.
var sqlDialingHostedServices = services
.Where(d => d.ServiceType == typeof(IHostedService)
&& (d.ImplementationType?.Name == "ApiKeyMigrationHostedService"
|| d.ImplementationType == typeof(InboundApiKeyStoreImportService)))
.ToList();
foreach (var d in sqlDialingHostedServices)
services.Remove(d);
});
});
_ = _factory.Server;
}
/// <summary>Initializes the tests by booting the central composition root with the SQL Server key store provider.</summary>
public CentralSqlServerKeyStoreCompositionRootTests()
: this(explicitConnectionString: null)
{
}
/// <summary>Disposes the web application factory and restores the environment variables.</summary>
public void Dispose()
{
try
{
_factory.Dispose();
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
Environment.SetEnvironmentVariable(ProviderEnvKey, _previousProvider);
Environment.SetEnvironmentVariable(ConnectionStringEnvKey, _previousConnectionString);
_dbEnv.Dispose();
}
}
/// <summary>Verifies that the SQL Server provider resolves the shared stores to their SQL Server implementations.</summary>
/// <param name="serviceType">The service type to resolve.</param>
/// <param name="expectedImplementation">The implementation type expected for the service.</param>
[Theory]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStore))]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAdminStore))]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAuditStore))]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStoreMigrator))]
public void Central_SqlServerProvider_ResolvesSharedStores(Type serviceType, Type expectedImplementation)
{
var service = Services.GetService(serviceType);
Assert.NotNull(service);
Assert.IsType(expectedImplementation, service);
}
/// <summary>
/// The connection string defaults to the configuration database the pair already shares, under
/// the library's own <c>zbauth</c> schema — so enabling the shared store needs no new database,
/// no new secret and no new appsettings key beyond the Provider switch.
/// </summary>
[Fact]
public virtual void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
{
var options = Services
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
var configuration = Services.GetRequiredService<IConfiguration>();
Assert.Equal(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
Assert.False(string.IsNullOrWhiteSpace(options.ConnectionString));
Assert.Equal("zbauth", options.Schema);
}
/// <summary>
/// The admin facade must be composable against the shared store too — its migrator argument is
/// the provider-agnostic <c>IApiKeyStoreMigrator</c>, not the SQLite concrete type, which is
/// exactly the registration that would have thrown here had the switch been left half-done.
/// </summary>
[Fact]
public void Central_SqlServerProvider_ResolvesAdminCommandsAndImporter()
{
Assert.NotNull(Services.GetService<ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands>());
Assert.NotNull(Services.GetService<IInboundApiKeyStoreImporter>());
}
}
/// <summary>
/// An operator who puts the shared key store in its own database sets
/// <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString</c> explicitly; that value must
/// win over the ConfigurationDb default fill-in, which is only ever applied to a blank key.
/// </summary>
[Collection(HostBootCollection.Name)]
public sealed class CentralSqlServerKeyStoreExplicitConnectionStringTests
: CentralSqlServerKeyStoreCompositionRootTests
{
/// <summary>Initializes the tests with an explicit key store connection string.</summary>
public CentralSqlServerKeyStoreExplicitConnectionStringTests()
: base(ExplicitConnectionString)
{
}
/// <inheritdoc/>
[Fact]
public override void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
{
var options = Services
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
var configuration = Services.GetRequiredService<IConfiguration>();
Assert.Equal(ExplicitConnectionString, options.ConnectionString);
Assert.NotEqual(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
}
}
/// <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;
private readonly string _tempTrackingDbPath;
private readonly string _tempLocalDbPath;
/// <summary>Initializes the tests with temporary database paths for the site composition root.</summary>
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{Guid.NewGuid()}.db");
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["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,
// Retained for the site config DB; it no longer points the tracking store
// anywhere — that store now opens the consolidated database below.
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
// The consolidated site database. IOperationTrackingStore resolves ILocalDb
// and creates the file eagerly in its ctor, so this must be a temp path or
// the composition-root test litters the working directory.
["LocalDb:Path"] = _tempLocalDbPath,
// 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();
}
/// <summary>Disposes the host and deletes the temporary database files, ignoring failures.</summary>
public void Dispose()
{
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempTrackingDbPath); } catch { /* best effort */ }
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
/// <summary>Verifies that the operation tracking store resolves on a site node.</summary>
[Fact]
public void Site_Resolves_IOperationTrackingStore()
{
// Site Call Audit's site-local source of truth. AkkaHostedService and
// ScriptRuntimeContext resolve this with GetService and silently degrade
// when absent (audit-only telemetry, no cached-drain, no PullSiteCalls) —
// so its presence on the SITE composition root must be locked here
// (arch-review 08 round 2 NF4/T8: AddSiteRuntime never registered it,
// despite two comments claiming it did).
var store = _host.Services.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>();
Assert.NotNull(store);
}
// --- Singletons ---
/// <summary>Verifies that the service resolves as a singleton from the site composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[Theory]
[MemberData(nameof(SiteSingletonServices))]
public void Site_ResolveSingleton(Type serviceType)
{
var service = _host.Services.GetService(serviceType);
Assert.NotNull(service);
}
/// <summary>Gets the service types expected to resolve as singletons on a site node.</summary>
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(ISiteEventLogger) },
new object[] { typeof(IEventLogQueryService) },
new object[] { typeof(ISiteIdentityProvider) },
new object[] { typeof(IHealthReportTransport) },
// The active-node check must be registered on site nodes so
// EventLogPurge's log line can report whether this node is active (it no
// longer gates which node purges — every node purges its own site_events file).
new object[] { typeof(SiteEventLogActiveNodeCheck) },
};
// --- Scoped services ---
/// <summary>Verifies that the service resolves from a scope in the site composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[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);
}
/// <summary>Gets the service types expected to resolve as scoped services on a site node.</summary>
public static IEnumerable<object[]> SiteScopedServices => new[]
{
new object[] { typeof(IExternalSystemRepository) },
// INotificationRepository is intentionally absent — notification config is
// central-only; the site-side variant was removed.
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
};
// --- Implementation type assertions ---
/// <summary>Verifies that the external system repository resolves to the site implementation.</summary>
[Fact]
public void Site_ExternalSystemRepository_IsSiteImplementation()
{
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
Assert.IsType<SiteExternalSystemRepository>(repo);
}
/// <summary>Verifies that the notification repository is not registered on a site node.</summary>
[Fact]
public void Site_NotificationRepository_IsNotRegistered()
{
// Notification delivery is central-only (CLAUDE.md "Notification delivery is
// central-only"); the site-local notification_lists/smtp_configurations tables
// are purged on every artifact apply, so a site-side INotificationRepository
// could only ever serve empty results. It must not be registered at all.
using var scope = _host.Services.CreateScope();
var repo = scope.ServiceProvider.GetService<INotificationRepository>();
Assert.Null(repo);
}
// --- Options ---
/// <summary>Verifies that the options type resolves from the site composition root.</summary>
/// <param name="optionsType">The options type to resolve.</param>
[Theory]
[MemberData(nameof(SiteOptions))]
public void Site_ResolveOptions(Type optionsType)
{
var service = _host.Services.GetService(optionsType);
Assert.NotNull(service);
}
/// <summary>Gets the options types expected to be registered on a site node.</summary>
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 ---
/// <summary>Verifies that the hosted service is registered on a site node.</summary>
/// <param name="expectedType">The hosted service type expected to be registered.</param>
[Theory]
[MemberData(nameof(SiteHostedServices))]
public void Site_HostedServiceRegistered(Type expectedType)
{
var hostedServices = _host.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
}
/// <summary>Gets the hosted service types expected to be registered on a site node.</summary>
public static IEnumerable<object[]> SiteHostedServices => new[]
{
new object[] { typeof(HealthReportSender) },
new object[] { typeof(SiteStorageInitializer) },
new object[] { typeof(EventLogPurgeService) },
};
}