Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs
T

218 lines
10 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;";
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);
}
}