06be0b9e64
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
799 lines
37 KiB
C#
799 lines
37 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
|
|
{
|
|
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;
|
|
|
|
public CentralCompositionRootTests()
|
|
{
|
|
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
|
|
|
// Supply the pepper, MachineDataDb 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!",
|
|
// 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()
|
|
{
|
|
// 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 ---
|
|
|
|
[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);
|
|
}
|
|
|
|
// --- 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>
|
|
[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>());
|
|
}
|
|
|
|
[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;
|
|
}
|
|
|
|
public CentralSqlServerKeyStoreCompositionRootTests()
|
|
: this(explicitConnectionString: null)
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
_factory.Dispose();
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
|
Environment.SetEnvironmentVariable(ProviderEnvKey, _previousProvider);
|
|
Environment.SetEnvironmentVariable(ConnectionStringEnvKey, _previousConnectionString);
|
|
_dbEnv.Dispose();
|
|
}
|
|
}
|
|
|
|
[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
|
|
{
|
|
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;
|
|
|
|
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();
|
|
}
|
|
|
|
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 */ }
|
|
}
|
|
|
|
[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 ---
|
|
|
|
[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(ISiteEventLogger) },
|
|
new object[] { typeof(IEventLogQueryService) },
|
|
new object[] { typeof(ISiteIdentityProvider) },
|
|
new object[] { typeof(IHealthReportTransport) },
|
|
// M2.15 (#29): the active-node check must be registered on site nodes so
|
|
// EventLogPurge's log line can report whether this node is active (arch-review
|
|
// 2026-08-19 P4-4: it no longer gates which node purges — every node purges its
|
|
// own site_events file).
|
|
new object[] { typeof(SiteEventLogActiveNodeCheck) },
|
|
};
|
|
|
|
// --- 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) },
|
|
// INotificationRepository is intentionally absent — notification config is
|
|
// central-only (arch-review 08 §1.3/#23); 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 ---
|
|
|
|
[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_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 ---
|
|
|
|
[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) },
|
|
};
|
|
}
|