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 . /// /// Task 11 retired every secrets REPLICATION mode 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 Task 5 /// =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;"; 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 (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(); // 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); } [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()); } [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. // --------------------------------------------------------------------------------------- [Fact] public void Central_DefaultsToPlainSqliteStore() { using ServiceProvider provider = BuildProvider(BuildConfig()); Assert.IsType(provider.GetRequiredService()); } [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 (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(provider.GetRequiredService()); } [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); } }