using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection; namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.DependencyInjection; /// /// scadaproj#31 (Secrets-002): in hub-replication mode the node's local SQLite store is migrated by /// a pre-host ${secret:} expander, so the under-content-root guard must be reachable — either /// through the extension's own contentRootPath or through a 4-arg AddZbSecrets in /// either order. Before the fix no registration order applied it: the extension's internal 3-arg /// call captured a null root first, and the core-first order lost the replicator (scadaproj#30). /// /// /// Every container here is pre-host — no IHostEnvironment — which is the case the guard /// exists for. The store file is asserted absent BEFORE the exception is asserted, so a regression /// that stops the throw but still creates the database fails on the artifact. /// public sealed class ContentRootTests : IDisposable { private readonly string _contentRoot = Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-root-{Guid.NewGuid():N}"); public ContentRootTests() => Directory.CreateDirectory(_contentRoot); public enum Composition { ExtensionArgument, ExtensionThenCore, CoreThenExtension, } private IConfiguration Config(string sqlitePath) => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = sqlitePath, ["Secrets:RunMigrationsOnStartup"] = "false", ["Secrets:SqlServer:ConnectionString"] = "Server=unused;Database=x;", }).Build(); private ServiceProvider Compose(Composition composition, IConfiguration config) { var services = new ServiceCollection(); services.AddLogging(); switch (composition) { case Composition.ExtensionArgument: services.AddZbSecretsSqlServerReplication(config, contentRootPath: _contentRoot); break; case Composition.ExtensionThenCore: services.AddZbSecretsSqlServerReplication(config); services.AddZbSecrets(config, "Secrets", _contentRoot); break; case Composition.CoreThenExtension: services.AddZbSecrets(config, "Secrets", _contentRoot); services.AddZbSecretsSqlServerReplication(config); break; } return services.BuildServiceProvider(); } [Theory] [InlineData(Composition.ExtensionArgument)] [InlineData(Composition.ExtensionThenCore)] [InlineData(Composition.CoreThenExtension)] public async Task Hub_mode_refuses_a_local_store_inside_the_content_root(Composition composition) { string dbPath = Path.Combine(_contentRoot, "data", "s.db"); using ServiceProvider provider = Compose(composition, Config(dbPath)); Exception? thrown = await Record.ExceptionAsync( () => provider.GetRequiredService().MigrateAsync(default)); Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file"); Assert.IsType(thrown); } [Theory] [InlineData(Composition.ExtensionArgument)] [InlineData(Composition.ExtensionThenCore)] [InlineData(Composition.CoreThenExtension)] public void Hub_mode_accepts_a_store_outside_the_content_root(Composition composition) { // Positive control: the same compositions with a legal path resolve cleanly, so the refusal // above is the content-root rule and not some other failure of the composition. string dbPath = Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-outside-{Guid.NewGuid():N}.db"); using ServiceProvider provider = Compose(composition, Config(dbPath)); Assert.Equal(dbPath, provider.GetRequiredService>().Value.SqlitePath); } [Fact] public void Shared_store_mode_forwards_the_content_root_too() { string dbPath = Path.Combine(_contentRoot, "s.db"); var services = new ServiceCollection(); services.AddLogging(); services.AddZbSecretsSqlServerStore(Config(dbPath), contentRootPath: _contentRoot); using ServiceProvider provider = services.BuildServiceProvider(); Assert.Throws( () => provider.GetRequiredService>().Value); } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(_contentRoot, recursive: true); } }