218 lines
10 KiB
C#
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);
|
|
}
|
|
}
|