Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs
T
Joseph Doherty cbfe296279 fix(secrets): close the pre-host hole in the store-path guard (0.6.1)
Found by the HistorianGateway adoption of 0.6.0, which probed the rejected paths
instead of observing a clean boot — a clean boot being equally consistent with the
validator not being wired at all.

An application that expands ${secret:} references before building its host composes
this subsystem in a throwaway ServiceCollection with no IHostEnvironment in it, then
migrates the store from that container. SecretsOptionsValidator resolves the content
root from IHostEnvironment and skips the under-content-root rule when it is absent, so
pre-host an absolute in-content-root path passed the only rule still applying, the
migrator CREATED the database, and the boot failed a second later once the real host
registered IHostEnvironment and the rule finally fired.

The observable result was a rejected boot that still left an empty store, with -wal and
-shm siblings, at the rejected path — the same artifact that made the MxGateway outage
read as "the database is there, it's just empty", which is precisely what this guard
exists to prevent. A relative path was unaffected because the absolute-path rule fires
with or without a content root; that asymmetry is what exposed it.

The mistake was conflating "no IHostEnvironment registered" with "no content root
exists". New AddZbSecrets overload takes the content root explicitly; the 3-argument
overload is unchanged and still falls back to IHostEnvironment, so this is additive.
SecretsOptionsValidator gains a string? constructor alongside the IHostEnvironment? one.

Reproduced first in an isolated harness before changing anything: a bare host rejects
both bad paths leaving no artifacts, and a bare ServiceCollection with no
IHostEnvironment migrates an in-content-root path to disk. Both are now regression tests,
the second paired with a positive control asserting the old behaviour still holds when no
content root is supplied — so the test is about the explicit content root rather than
about paths in general.

Also documents an upgrade note that applies to 0.6.0: a store seeded under the old
relative default is orphaned rather than migrated, and nothing signals it.
2026-08-11 08:37:53 -04:00

323 lines
14 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<string, string?>
{
["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<SecretsOptions>)));
}
[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<string, string?>
{
["Secrets:SqlitePath"] = "secrets.db",
})
.Build();
var services = new ServiceCollection();
services.AddZbSecrets(config, "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
var ex = Assert.Throws<OptionsValidationException>(
() => _ = provider.GetRequiredService<IOptions<SecretsOptions>>().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<string, string?>
{
["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<IOptions<SecretsOptions>>().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<string, string?>
{
["Secrets:SqlitePath"] = dbPath,
})
.Build();
var services = new ServiceCollection();
services.AddZbSecrets(config, "Secrets", contentRoot); // the content root, explicitly
using ServiceProvider provider = services.BuildServiceProvider();
Assert.Throws<OptionsValidationException>(
() => _ = provider.GetRequiredService<SecretsSqliteConnectionFactory>());
// 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<string, string?>
{
["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<SecretsSqliteConnectionFactory>());
}
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!;
}
}