using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Secrets.Configuration; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.Secrets.Tests.Configuration; /// /// Covers the two store-path rules and the validator that applies them. Both rules exist because /// of production incidents — a relative path landing in the process working directory, and an /// absolute path landing inside the directory a deployment renames away — so the cases here are /// written as those failures, not as abstract path arithmetic. /// public sealed class SecretsStorePathRulesTests { private static string Root(params string[] segments) => Path.Combine(OperatingSystem.IsWindows() ? @"C:\zbtest" : "/zbtest", Path.Combine(segments)); // ---------- AddIfNotRooted ---------- [Fact] public void NotRooted_rejects_a_relative_path() { var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfNotRooted("secrets.db", "relative", builder); Assert.Contains("relative", builder.Failures); } [Fact] public void NotRooted_accepts_an_absolute_path() { var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfNotRooted(Root("data", "secrets.db"), "relative", builder); Assert.True(builder.IsValid); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void NotRooted_leaves_blank_to_the_required_check(string? value) { var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfNotRooted(value, "relative", builder); Assert.True(builder.IsValid); } // ---------- AddIfUnderContentRoot ---------- [Fact] public void UnderContentRoot_rejects_a_store_inside_the_application_directory() { // The 2026-08-09 shape: absolute, and inside the directory the deploy renames away. var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfUnderContentRoot( Root("app", "Server", "secrets.db"), Root("app", "Server"), "inside", builder); Assert.Contains("inside", builder.Failures); } [Fact] public void UnderContentRoot_accepts_a_sibling_directory() { // The fix that was actually applied on the box: a sibling data\ directory. var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfUnderContentRoot( Root("app", "data", "secrets.db"), Root("app", "Server"), "inside", builder); Assert.True(builder.IsValid); } [Fact] public void UnderContentRoot_does_not_treat_a_name_prefixed_sibling_as_inside() { // "/zbtest/app-data" merely STARTS WITH "/zbtest/app". Without a separator before the // prefix test this is reported as inside the content root, and because the rule is // fail-closed at startup that false positive is an application that will not boot. var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfUnderContentRoot( Root("app-data", "secrets.db"), Root("app"), "inside", builder); Assert.True(builder.IsValid); } [Fact] public void UnderContentRoot_catches_a_path_that_only_escapes_syntactically() { // Normalisation matters: this resolves back inside the content root despite the "..". var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfUnderContentRoot( Path.Combine(Root("app", "Server"), "..", "Server", "secrets.db"), Root("app", "Server"), "inside", builder); Assert.Contains("inside", builder.Failures); } [Fact] public void UnderContentRoot_skips_when_the_content_root_is_unknown() { // Composed outside a host (the CLI does this). A rule about a directory the process does // not know it has must not become a boot failure. var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfUnderContentRoot(Root("anywhere", "secrets.db"), null, "inside", builder); Assert.True(builder.IsValid); } // ---------- the default ---------- [Fact] public void The_default_store_path_satisfies_the_rules_it_ships_with() { // A library whose own default violates its own guard would fail every consumer's first // boot after upgrading. var builder = new ValidationBuilder(); SecretsStorePathRules.AddIfNotRooted(SecretsOptions.DefaultSqlitePath, "relative", builder); Assert.True(builder.IsValid, $"default was '{SecretsOptions.DefaultSqlitePath}'"); } [Fact] public void The_default_store_path_is_not_machine_wide() { // Deliberately per-user: CommonApplicationData is /usr/share on Linux and macOS, which a // normal user cannot write, so a machine-wide default would fail every dev boot in the // family. Asserted rather than commented because it is the whole reason for the choice. string commonAppData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); if (!string.IsNullOrEmpty(commonAppData)) { Assert.DoesNotContain(commonAppData, SecretsOptions.DefaultSqlitePath, StringComparison.Ordinal); } } // ---------- the validator ---------- private static SecretsOptions OptionsWith(string path) => new() { SqlitePath = path }; [Fact] public void Validator_rejects_a_relative_path_and_names_the_key() { var validator = new SecretsOptionsValidator(hostEnvironment: null); ValidateOptionsResult result = validator.Validate(name: null, OptionsWith("secrets.db")); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("Secrets:SqlitePath", StringComparison.Ordinal)); } [Fact] public void Validator_rejects_a_store_under_the_content_root() { var env = new StubHostEnvironment { ContentRootPath = Root("app", "Server") }; var validator = new SecretsOptionsValidator(env); ValidateOptionsResult result = validator.Validate(name: null, OptionsWith(Root("app", "Server", "secrets.db"))); Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("content root", StringComparison.Ordinal)); } [Fact] public void Validator_accepts_an_absolute_path_outside_the_content_root() { var env = new StubHostEnvironment { ContentRootPath = Root("app", "Server") }; var validator = new SecretsOptionsValidator(env); ValidateOptionsResult result = validator.Validate(name: null, OptionsWith(Root("app", "data", "secrets.db"))); Assert.True(result.Succeeded); } // ---------- registration ---------- [Fact] public void AddZbSecrets_is_idempotent_and_registers_exactly_one_validator() { // AddZbSecrets is called twice in real compositions — a replication package calls it after // the application already has, and one consumer calls it twice directly. An earlier // implementation used TryAddEnumerable with a factory descriptor, which THROWS on the // second call ("indistinguishable from other services") rather than skipping. IConfiguration config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = Root("app", "data", "secrets.db"), }) .Build(); var services = new ServiceCollection(); services.AddZbSecrets(config, "Secrets"); services.AddZbSecrets(config, "Secrets"); Assert.Single(services.Where(d => d.ServiceType == typeof(SecretsOptionsValidator))); Assert.Single(services.Where(d => d.ServiceType == typeof(IValidateOptions))); } [Fact] public void A_bad_path_actually_fails_when_the_options_are_resolved() { // The rules and the validator can both be correct while the REGISTRATION is wrong, and a // validator that is never invoked is precisely the "guard that does not fire" failure this // whole change exists to prevent. So resolve the options for real and require the throw. IConfiguration config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = "secrets.db", }) .Build(); var services = new ServiceCollection(); services.AddZbSecrets(config, "Secrets"); using ServiceProvider provider = services.BuildServiceProvider(); var ex = Assert.Throws( () => _ = provider.GetRequiredService>().Value); Assert.Contains(ex.Failures, f => f.Contains("Secrets:SqlitePath", StringComparison.Ordinal)); } [Fact] public void A_good_path_resolves_cleanly() { // Positive control: without it, the test above would still pass if EVERY path were rejected. IConfiguration config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = Root("app", "data", "secrets.db"), }) .Build(); var services = new ServiceCollection(); services.AddZbSecrets(config, "Secrets"); using ServiceProvider provider = services.BuildServiceProvider(); Assert.Equal( Root("app", "data", "secrets.db"), provider.GetRequiredService>().Value.SqlitePath); } [Fact] public void A_pre_host_container_given_the_content_root_refuses_before_the_store_is_created() { // REGRESSION (found by the HistorianGateway adoption of 0.6.0). An application that expands // ${secret:} references before building its host composes this subsystem in a throwaway // ServiceCollection with NO IHostEnvironment, then migrates the store from that container. // The content-root rule was skipped there — so an absolute in-content-root path passed the // only rule still applying, the migrator CREATED the database, and the boot then failed a // second later once the real host registered IHostEnvironment. That left an empty store at // the rejected path: the exact artifact that made the outage this guard exists to prevent // read as "the database is there, it's just empty". string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-prehost-{Guid.NewGuid():N}"); Directory.CreateDirectory(contentRoot); string dbPath = Path.Combine(contentRoot, "store.db"); try { IConfiguration config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = dbPath, }) .Build(); var services = new ServiceCollection(); services.AddZbSecrets(config, "Secrets", contentRoot); // the content root, explicitly using ServiceProvider provider = services.BuildServiceProvider(); Assert.Throws( () => _ = provider.GetRequiredService()); // The point of the test: refused BEFORE anything touched the filesystem. Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file"); Assert.False(File.Exists(dbPath + "-wal")); Assert.False(File.Exists(dbPath + "-shm")); } finally { Directory.Delete(contentRoot, recursive: true); } } [Fact] public void Without_a_content_root_the_pre_host_container_still_cannot_be_told_it_is_fine() { // Positive control for the test above: the SAME path, with the content root NOT supplied, // is the pre-0.6.1 behaviour — the rule is skipped and nothing objects. This is what makes // the assertion above about the explicit content root rather than about paths in general. string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-prehost-{Guid.NewGuid():N}"); Directory.CreateDirectory(contentRoot); string dbPath = Path.Combine(contentRoot, "store.db"); try { IConfiguration config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = dbPath, }) .Build(); var services = new ServiceCollection(); services.AddZbSecrets(config, "Secrets"); // no content root — the CLI's situation using ServiceProvider provider = services.BuildServiceProvider(); // Absolute, so the rooted rule passes; the content-root rule cannot fire. Documented, // and the reason pre-host composition must pass the content root explicitly. Assert.NotNull(provider.GetRequiredService()); } finally { Directory.Delete(contentRoot, recursive: true); } } private sealed class StubHostEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = "Production"; public string ApplicationName { get; set; } = "Test"; public string ContentRootPath { get; set; } = string.Empty; public IFileProvider ContentRootFileProvider { get; set; } = null!; } }