Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs
T
Joseph Doherty 944595ad32 feat(secrets): fail-closed store-path validation; default moves outside the content root
Secrets:SqlitePath is now validated at startup by SecretsOptionsValidator using two
rules in the new public SecretsStorePathRules. Both come from production incidents and
neither implies the other.

Must be absolute: a relative value resolves against the process working directory,
which for a Windows service is C:\Windows\System32, so the store lands outside the
installation; in a dev loop it lands in the source tree (a gateway was observed writing
its secrets database into src/, tripping that repo's own tree-hygiene test).

Must not be inside the content root: an absolute path can still point into the
directory every deployment procedure in this family renames on upgrade. On 2026-08-09 a
gateway's credential store sat inside its Server\ directory; the deploy renamed Server
to Server.bak.*, the database went with it, and the process created a fresh empty one
at the same path. Every consumer lost its credential, silently, for two days. The
existing "must be absolute" convention did not help — the path was absolute.

The rules reject rather than relocate. Silently moving a credential store presents later
as an authentication failure pointing nowhere near the configuration that caused it; a
boot error is seen immediately by whoever caused it. Validation is deliberately not
exempted in Development: a guard that never runs in a dev loop or in CI rots, and the
configuration behind the outage looked entirely reasonable.

BREAKING for configuration, not API. SecretsOptions.SqlitePath now defaults to an
absolute per-user path under LocalApplicationData; it was "secrets.db", which the new
rules reject. An application configuring a relative path will fail to boot — remove the
key or set an absolute one outside the content root. All five deployed instances of this
family were checked first and already set an absolute path outside their content root,
so the migration edge (a host relying on the OLD default now finding nothing and
creating an empty store — this bug, re-introduced by its own fix) does not apply to any
of them.

Per-user rather than machine-wide is deliberate: CommonApplicationData is /usr/share on
Linux and macOS, which a normal user cannot write, so a machine-wide library default
would fail every consuming application's boot on a developer machine. An application may
compute its own default; the configured value always wins.

Validator registration is idempotent. AddZbSecrets is legitimately called more than once
in real compositions, and TryAddEnumerable cannot de-duplicate a factory-built
descriptor — it de-duplicates on implementation type, which a factory does not carry, so
it throws on the second call rather than skipping. The existing suite caught this.

Tests cover both rules including the segment-boundary case (/srv/app-data must not read
as inside /srv/app — a false positive on a fail-closed startup rule is an application
that will not boot), that the shipped default satisfies the rules it ships with, that
the default is not machine-wide, idempotent registration, and — most importantly — that
a bad path actually throws when IOptions is resolved, since a correctly implemented
validator that is never invoked is the same "guard that does not fire" failure this
change exists to prevent.

Family bumps to 0.6.0. Adds a dependency on ZB.MOM.WW.Configuration 0.1.0 for the shared
OptionsValidatorBase/ValidationBuilder vocabulary, which every consumer already
references.
2026-08-11 08:18:37 -04:00

247 lines
10 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;
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);
}
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!;
}
}