8e12f99432
Routes both host-container secret registrations (central role in Program.cs,
site role in SiteServiceRegistration) through a new SecretsRegistration
composition seam that optionally enables ZB.MOM.WW.Secrets.Replicator.SqlServer
hub-replication mode: each node keeps a LOCAL store that syncs bidirectionally
with a shared central SQL hub, so a site cluster keeps resolving secrets
straight through a WAN outage to central.
OPT-IN GATE (the load-bearing part). AddZbSecretsSqlServerReplication validates
its options EAGERLY at registration time, so wiring it unconditionally would
make ScadaBridge fail to START anywhere Secrets:SqlServer:ConnectionString is
unset -- every dev box, every docker node, every existing deployment. The
SQL-Server package is therefore only touched when BOTH Secrets:Replication:
Enabled is true AND a non-blank connection string is present; otherwise the
registration is byte-identical to the previous plain AddZbSecrets call.
Enabled-without-a-connection-string falls back to local-only and logs a warning
rather than failing the node or silently looking healthy.
BOOTSTRAP CYCLE. The hub connection string is itself a secret and can never come
from the hub -- a node cannot read the hub to learn how to reach the hub. It must
arrive from outside the replicated set: an environment variable, or a ${secret:}
reference seeded in that node's own LOCAL store. appsettings.json therefore ships
ConnectionString empty with a _comment saying so (leaf keys starting with '_' are
skipped by the reference expander, verified in SecretReferenceExpander). No real
connection string is committed. The pre-host ${secret:} expander in Program.cs is
deliberately left on a plain local SQLite store for the same reason.
Per-node docker appsettings are intentionally NOT modified -- replication stays
off there for now.
Tests written before the wiring and confirmed red first (2 failed / 4 passed),
green after (6/6). They BUILD a container and RESOLVE from it rather than
asserting over ServiceDescriptors: a decorator can look correct as a descriptor
list and still throw on first resolve because the undecorated concrete store it
depends on is missing -- that exact defect shipped once in this library with all
descriptor-level tests green, so the undecorated SqliteSecretStore gets its own
resolution test.
Verified: Host.Tests 285/285 pass; full build (all projects except the
pre-existing AngleSharp NU1902 CentralUI.Tests restore break) 0 warnings,
0 errors.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
151 lines
6.1 KiB
C#
151 lines
6.1 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Replication;
|
|
using ZB.MOM.WW.Secrets.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>.
|
|
/// <para>
|
|
/// These deliberately BUILD a container and RESOLVE out of it rather than asserting over
|
|
/// <see cref="ServiceDescriptor"/>s. A decorator registration can look perfectly correct as a
|
|
/// descriptor list and still throw on the first resolve because the undecorated concrete store it
|
|
/// depends on is missing — that exact gap shipped once in this library, with every descriptor-level
|
|
/// unit test green. Resolution is the only assertion that catches it.
|
|
/// </para>
|
|
/// <para>
|
|
/// Registration must not need a reachable SQL Server: the SQL-Server package validates its options
|
|
/// eagerly, but validation only checks that the connection string is non-empty and the schema name
|
|
/// is a legal identifier. Nothing connects until a sweep or a write runs, and these tests do
|
|
/// neither — so the dummy connection string below never resolves to a real host.
|
|
/// </para>
|
|
/// </summary>
|
|
public class SecretsReplicationWiringTests
|
|
{
|
|
// Syntactically valid, deliberately unreachable. Never connected to by these tests.
|
|
private const string DummyConnectionString =
|
|
"Server=unused;Database=x;Integrated Security=true;";
|
|
|
|
private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides)
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-test.db"),
|
|
["Secrets:MasterKey:Source"] = "Environment",
|
|
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
|
|
["Secrets:RunMigrationsOnStartup"] = "false",
|
|
};
|
|
|
|
foreach ((string key, string value) in overrides)
|
|
{
|
|
settings[key] = value;
|
|
}
|
|
|
|
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
|
}
|
|
|
|
private static ServiceProvider BuildProvider(IConfiguration config)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddScadaBridgeSecrets(config);
|
|
return services.BuildServiceProvider(validateScopes: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Any hosted service contributed by the SQL-Server replicator package. Asserted by assembly
|
|
/// rather than by type because <c>SqlServerHubMigrationHostedService</c> is internal to it.
|
|
/// </summary>
|
|
private static bool IsReplicatorHostedService(ServiceDescriptor descriptor) =>
|
|
descriptor.ServiceType == typeof(IHostedService)
|
|
&& descriptor.ImplementationType?.Assembly.GetName().Name
|
|
== "ZB.MOM.WW.Secrets.Replicator.SqlServer";
|
|
|
|
[Fact]
|
|
public void ReplicationDisabledByDefault_ResolvesPlainSqliteStore()
|
|
{
|
|
using ServiceProvider provider = BuildProvider(BuildConfig());
|
|
|
|
var store = provider.GetRequiredService<ISecretStore>();
|
|
|
|
Assert.IsType<SqliteSecretStore>(store);
|
|
Assert.IsNotType<ReplicatingSecretStore>(store);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicationDisabledByDefault_RegistersNoReplicatorHostedServices()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddScadaBridgeSecrets(BuildConfig());
|
|
|
|
Assert.DoesNotContain(services, IsReplicatorHostedService);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicationEnabledWithoutConnectionString_StaysOnPlainSqliteStore()
|
|
{
|
|
// The gate requires BOTH the flag and a connection string. Enabling the flag alone must not
|
|
// reach the SQL-Server package, whose options validation would throw at registration and
|
|
// take the whole node down at startup.
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
|
|
IConfiguration config = BuildConfig(("Secrets:Replication:Enabled", "true"));
|
|
|
|
services.AddScadaBridgeSecrets(config);
|
|
|
|
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
|
|
|
|
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
|
|
Assert.DoesNotContain(services, IsReplicatorHostedService);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicationEnabledWithConnectionString_ResolvesReplicatingStore()
|
|
{
|
|
IConfiguration config = BuildConfig(
|
|
("Secrets:Replication:Enabled", "true"),
|
|
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
|
|
|
|
using ServiceProvider provider = BuildProvider(config);
|
|
|
|
Assert.IsType<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicationEnabledWithConnectionString_UndecoratedLocalStoreAlsoResolves()
|
|
{
|
|
// ReplicatingSecretStore is constructed from the CONCRETE SqliteSecretStore, not from
|
|
// ISecretStore (which is the decorator itself — that would recurse). If the concrete
|
|
// registration were ever dropped, the decorator would fail on first resolve. This is the
|
|
// exact defect that shipped once, so it gets its own test.
|
|
IConfiguration config = BuildConfig(
|
|
("Secrets:Replication:Enabled", "true"),
|
|
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
|
|
|
|
using ServiceProvider provider = BuildProvider(config);
|
|
|
|
Assert.NotNull(provider.GetRequiredService<SqliteSecretStore>());
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicationEnabledWithConnectionString_RegistersReplicatorHostedServices()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
|
|
IConfiguration config = BuildConfig(
|
|
("Secrets:Replication:Enabled", "true"),
|
|
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
|
|
|
|
services.AddScadaBridgeSecrets(config);
|
|
|
|
// Both the hub-schema migration service and the bidirectional sweep.
|
|
Assert.Equal(2, services.Count(IsReplicatorHostedService));
|
|
}
|
|
}
|