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; /// /// DI-resolution tests for . /// /// Every secrets REPLICATION mode is retired in ScadaBridge — the pull-only gRPC hub (central /// hosts, sites sweep) and the SQL-Server ReplicatingSecretStore 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 /// =SqlServer direct shared store (Central-only, /// no hub, no follower sweep — just two nodes reading and writing one database). /// /// /// These deliberately BUILD a container and RESOLVE out of it rather than asserting over /// 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. /// /// /// 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. /// /// public class SecretsReplicationWiringTests { // Syntactically valid, deliberately unreachable. Never connected to by these tests. private const string DummyConnectionString = "Server=unused;Database=x;Integrated Security=true;"; /// /// A fresh directory, never the one holding 's default /// Secrets:SqlitePath (Path.GetTempPath() itself) — the SQLite path-rules /// validator runs eagerly on the first IOptions<SecretsOptions>.Value read (i.e. /// on resolving ISecretStore itself, not merely on migrating), so passing /// Path.GetTempPath() 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. /// 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 { ["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); } /// /// Any descriptor whose service OR implementation type lives in the SQL-Server package. /// Honest bound: the package also registers ISecretStore/ISecretsStoreMigrator /// 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. /// 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. 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. // --------------------------------------------------------------------------------------- /// Verifies that a site resolves the plain SQLite secret store and never a replication decorator. [Fact] public void Site_ResolvesPlainSqliteStore_NeverAReplicationDecorator() { using ServiceProvider provider = BuildProvider(BuildConfig(), SecretsNodeRole.Site); var store = provider.GetRequiredService(); // 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(store); } /// Verifies that a site ignores the store provider setting even when it is set to SqlServer. [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(provider.GetRequiredService()); } /// Verifies that the site container holds no SQL Server secret-store package types. [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(provider.GetRequiredService()); Assert.Null(provider.GetService()); } // --------------------------------------------------------------------------------------- // Central, default — plain local SQLite, the shape every existing deployment is in. // --------------------------------------------------------------------------------------- /// Verifies that central defaults to the plain SQLite secret store. [Fact] public void Central_DefaultsToPlainSqliteStore() { using ServiceProvider provider = BuildProvider(BuildConfig()); Assert.IsType(provider.GetRequiredService()); } /// Verifies that setting the store provider to Sqlite behaves the same as leaving it unset on central. [Fact] public void Central_StoreProviderSqlite_IsIndistinguishableFromUnset() { using ServiceProvider explicitProvider = BuildProvider(BuildConfig(("Secrets:Store:Provider", "Sqlite"))); using ServiceProvider unsetProvider = BuildProvider(BuildConfig()); Assert.IsType(explicitProvider.GetRequiredService()); Assert.IsType(unsetProvider.GetRequiredService()); } // --------------------------------------------------------------------------------------- // Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store // directly, without a hub. The one replication-adjacent feature that was left // standing, because it is a plain shared database, not a converging replica set. // --------------------------------------------------------------------------------------- /// Verifies that central uses the shared SQL store when the provider is SqlServer. [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(provider.GetRequiredService()); } /// Verifies that central throws when the provider is SqlServer and the connection string is blank. [Fact] public void Central_StoreProviderSqlServer_BlankConnectionString_Throws() { var services = new ServiceCollection(); services.AddLogging(); IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer")); var ex = Assert.Throws( () => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central)); Assert.Contains( SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal); Assert.Contains( $"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal); } /// /// The store's own connection string can never be a ${secret:} 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. /// [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( () => 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). 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. // --------------------------------------------------------------------------------------- /// Verifies that the shared-store check is true only for central with the SqlServer provider. [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)); } /// Verifies that the central pre-host expander resolves the shared SQL store when the provider is SqlServer. [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(provider.GetRequiredService()); // 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()); } /// Verifies that the central pre-host expander resolves the SQLite store by default. [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(provider.GetRequiredService()); Assert.NotNull(provider.GetRequiredService()); } /// Verifies that the site pre-host expander still resolves the SQLite store when the provider is SqlServer. [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(provider.GetRequiredService()); } /// Verifies that the central pre-host expander throws when the provider is SqlServer and the connection string is blank. [Fact] public void ExpanderCentral_StoreProviderSqlServer_BlankConnectionString_Throws() { var services = new ServiceCollection(); services.AddLogging(); IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer")); var ex = Assert.Throws( () => services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot())); Assert.Contains( SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal); Assert.Contains( $"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal); } /// Verifies that the central pre-host expander throws when the SqlServer connection string is itself a secret reference. [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( () => 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); } }