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.
This commit is contained in:
+76
@@ -6,6 +6,7 @@ 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;
|
||||
|
||||
@@ -236,6 +237,81 @@ public sealed class SecretsStorePathRulesTests
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user