Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs
T
Joseph Doherty bc29dc412b fix(secrets): review round 1 — pre-host expander reads the same store the runtime serves, raw-string Replication:Enabled parse, leftover-key warning
- SecretsRegistration: UsesCentralSharedStore predicate shared by AddScadaBridgeSecrets and the
  new AddPreHostExpander, so a Central node's Layer-A ${secret:} expander composes against the
  shared SQL store whenever Secrets:Store:Provider=SqlServer does — closing the gap where a
  central migrated off the retired Grpc topology would boot-time-resolve config from per-node
  SQLite while serving the shared store.
- Program.cs: expander block now calls AddPreHostExpander + resolves the store-agnostic
  ISecretsStoreMigrator seam; stale Grpc-hub/replication comment rewritten.
- DeployedSecretsMirror.cs: stale "gRPC-hub follower half" doc comment rewritten to state the
  now-permanent local-SQLite-only invariant.
- StartupValidator: Secrets:Replication:Enabled read as a raw string (any non-empty, non-"false"
  value rejected) instead of GetValue<bool>, which threw a raw binder crash on "yes"/"1"; added a
  CollectWarnings entry for leftover Secrets:Replication/Secrets:GrpcHub child keys once Enabled
  is false/unset.
- Tests: +15 (AddPreHostExpander/UsesCentralSharedStore wiring, Replication:Enabled edge values,
  leftover-key warnings). Full solution build and full Host.Tests green (593/593).
2026-09-28 20:10:17 -04:00

346 lines
17 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>.
/// <para>
/// Task 11 retired every secrets REPLICATION mode in ScadaBridge — the pull-only gRPC hub (central
/// hosts, sites sweep) and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it — on
/// BOTH roles. What is left: the plain local SQLite store, which is every node's default and now a
/// Site's ONLY possible shape, and the still-supported Task 5
/// <see cref="SecretsRegistration.StoreProviderKey"/>=SqlServer direct shared store (Central-only,
/// no hub, no follower sweep — just two nodes reading and writing one database).
/// </para>
/// <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 write or read 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;";
/// <summary>
/// A fresh directory, never the one holding <see cref="BuildConfig"/>'s default
/// <c>Secrets:SqlitePath</c> (<c>Path.GetTempPath()</c> itself) — the SQLite path-rules
/// validator runs eagerly on the first <c>IOptions&lt;SecretsOptions&gt;.Value</c> read (i.e.
/// on resolving <c>ISecretStore</c> itself, not merely on migrating), so passing
/// <c>Path.GetTempPath()</c> as the content root here would make every SQLite-path expander
/// test fail on "path is inside the content root" instead of testing what it means to.
/// </summary>
private static string NewContentRoot() =>
Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-content-root-" + Guid.NewGuid().ToString("N"));
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,
SecretsNodeRole role = SecretsNodeRole.Central)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeSecrets(config, role);
return services.BuildServiceProvider(validateScopes: true);
}
/// <summary>
/// Any descriptor whose service OR implementation type lives in the SQL-Server package.
/// Honest bound: the package also registers <c>ISecretStore</c>/<c>ISecretsStoreMigrator</c>
/// through factory lambdas whose service type is the Abstractions interface and whose
/// ImplementationType is null — those two descriptors would evade this scan in isolation. The
/// site-purity pin still holds because the same extension unconditionally registers concrete
/// types from the target assembly first, which this scan does catch — so the package cannot
/// enter the container without tripping it, even though not every individual descriptor it
/// adds is individually detectable.
/// </summary>
private static bool IsFromSqlServerPackage(ServiceDescriptor descriptor) =>
descriptor.ServiceType.Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.SqlServer"
|| descriptor.ImplementationType?.Assembly.GetName().Name
== "ZB.MOM.WW.Secrets.Replicator.SqlServer";
// ---------------------------------------------------------------------------------------
// Site: the ONLY shape left, on every configuration — plain local SQLite, never a
// replication decorator (Task 11, ruling #1). DeployedSecretsMirror deletes a name from
// this store directly the moment a deploy artifact stops referencing it; a decorator would
// publish that delete to every other node sharing whatever it decorates against, turning
// one site's unreferencing of a secret into a fleet-wide deletion.
// ---------------------------------------------------------------------------------------
[Fact]
public void Site_ResolvesPlainSqliteStore_NeverAReplicationDecorator()
{
using ServiceProvider provider = BuildProvider(BuildConfig(), SecretsNodeRole.Site);
var store = provider.GetRequiredService<ISecretStore>();
// Assert.IsType checks the EXACT runtime type, not merely "is a SqliteSecretStore or a
// decorator wrapping one" — nothing may sit between the site and its local file.
Assert.IsType<SqliteSecretStore>(store);
}
[Fact]
public void Site_IgnoresStoreProvider_EvenSetToSqlServer()
{
// A Site node ignores Secrets:Store:Provider entirely and always keeps its local SQLite
// store — even with the value set to SqlServer and no connection string configured at
// all, which would fail closed on Central.
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[Fact]
public void Site_HasNoSqlServerPackageTypesInTheContainer()
{
// The sites-don't-talk-to-SQL pin: a site node's container must contain NOTHING from the
// SQL-Server package — not just resolve a SQLite store, but hold no descriptor that could
// ever open a connection to central's database.
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Site);
Assert.DoesNotContain(services, IsFromSqlServerPackage);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.Null(provider.GetService<SqlServerSecretStore>());
}
// ---------------------------------------------------------------------------------------
// Central, default — plain local SQLite, the shape every existing deployment is in.
// ---------------------------------------------------------------------------------------
[Fact]
public void Central_DefaultsToPlainSqliteStore()
{
using ServiceProvider provider = BuildProvider(BuildConfig());
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[Fact]
public void Central_StoreProviderSqlite_IsIndistinguishableFromUnset()
{
using ServiceProvider explicitProvider =
BuildProvider(BuildConfig(("Secrets:Store:Provider", "Sqlite")));
using ServiceProvider unsetProvider = BuildProvider(BuildConfig());
Assert.IsType<SqliteSecretStore>(explicitProvider.GetRequiredService<ISecretStore>());
Assert.IsType<SqliteSecretStore>(unsetProvider.GetRequiredService<ISecretStore>());
}
// ---------------------------------------------------------------------------------------
// Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store
// directly, without a hub (task 5). The one replication-adjacent feature Task 11 left
// standing, because it is a plain shared database, not a converging replica set.
// ---------------------------------------------------------------------------------------
[Fact]
public void Central_StoreProviderSqlServer_UsesSharedSqlStore()
{
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central);
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[Fact]
public void Central_StoreProviderSqlServer_BlankConnectionString_Throws()
{
var services = new ServiceCollection();
services.AddLogging();
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains(
$"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal);
}
/// <summary>
/// The store's own connection string can never be a <c>${secret:}</c> reference — the pre-host
/// expander needs the store this string points at in order to resolve it (bootstrap
/// circularity). Rejected by name rather than left to fail as a malformed connection string on
/// first use.
/// </summary>
[Fact]
public void Central_StoreProviderSqlServer_SecretReferenceConnectionString_Throws()
{
var services = new ServiceCollection();
services.AddLogging();
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", "${secret:hub-connection-string}"));
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal);
// The reference NAME is config, not secret material — but the message must still not echo
// the configured value, because a mistyped value here could be a pasted real credential.
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
}
// ---------------------------------------------------------------------------------------
// The Layer-A pre-host ${secret:} expander (SecretsRegistration.AddPreHostExpander, called
// from Program.cs) — review round 1, Important #1. It MUST compose against the SAME store
// AddScadaBridgeSecrets registers for the running node: an expander that resolved boot-time
// ${secret:} tokens (e.g. ScadaBridge:Security:JwtSigningKey) from the per-node SQLite file
// while the running node served the shared SQL store would let two central nodes disagree on
// a rotated value — exactly the per-node drift Store:Provider=SqlServer exists to prevent.
// UsesCentralSharedStore is the single predicate shared by both methods.
// ---------------------------------------------------------------------------------------
[Fact]
public void UsesCentralSharedStore_TrueOnlyForCentralWithProviderSqlServer()
{
IConfiguration sqlServerConfig = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
IConfiguration sqliteConfig = BuildConfig();
Assert.True(
SecretsRegistration.UsesCentralSharedStore(sqlServerConfig, SecretsNodeRole.Central));
Assert.False(
SecretsRegistration.UsesCentralSharedStore(sqlServerConfig, SecretsNodeRole.Site));
Assert.False(
SecretsRegistration.UsesCentralSharedStore(sqliteConfig, SecretsNodeRole.Central));
}
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_ResolvesSharedSqlStore()
{
// The bug this guards against: the expander used to be wired ONLY to the retired Grpc
// mode, so a Central node on Store:Provider=SqlServer (the retirement message's own
// prescribed replacement) resolved boot-time ${secret:} tokens from the wrong store.
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot());
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
// The store-agnostic migrator seam — resolving through it is what lets Program.cs's
// expander block stay store-agnostic instead of re-deriving which concrete migrator to
// ask for.
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
[Fact]
public void ExpanderCentral_Default_ResolvesSqliteStore()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(BuildConfig(), SecretsNodeRole.Central, NewContentRoot());
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
[Fact]
public void ExpanderSite_StoreProviderSqlServer_StillResolvesSqliteStore()
{
// A Site ignores Store:Provider entirely, exactly as the runtime registration does — the
// expander must not be a second place where that rule could be got wrong.
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(config, SecretsNodeRole.Site, NewContentRoot());
Assert.DoesNotContain(services, IsFromSqlServerPackage);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_BlankConnectionString_Throws()
{
var services = new ServiceCollection();
services.AddLogging();
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot()));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains(
$"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_SecretReferenceConnectionString_Throws()
{
// The bootstrap-circularity rule applies here too: this expander is exactly the code that
// would otherwise have to resolve the reference in order to build the store that resolves
// it.
var services = new ServiceCollection();
services.AddLogging();
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", "${secret:hub-connection-string}"));
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot()));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
}
}