chore(secrets): adopt ZB.MOM.WW.Secrets 0.6.2 and close the pre-host guard gap
0.6.x refuses a secret store whose path is relative or inside the content root,
because a store in the deployment directory is destroyed by an ordinary upgrade —
the failure that wiped the MxGateway API-key store on 2026-08-09 and read as an
auth outage rather than a deployment error.
The pin alone would not have protected this repo. Program.cs expands ${secret:}
before the host exists, composing secrets into a throwaway ServiceCollection with
no IHostEnvironment, so the guard would not run at the moment the migrator creates
the store. That composition now lives in SecretsRegistration with an explicit
content root — resolved to match what the host resolves later, including the
Windows-Service case where the pre-host CWD is still system32 — and is covered by
PreHostSecretsContentRootTests, verified by simulating the regression and
confirming it fails on the leftover file rather than on the exception.
The docker rig needed a fix too: /app/data is absolute but inside the container's
content root, so all 8 nodes would have failed to boot. Each node's data directory
is now mounted a second time at /data; same host directory, so existing stores
carry over untouched.
Verified: build clean, 29 test assemblies green (Playwright's 159 failures are the
pre-existing SEC-36 login baseline). Not yet deployed — the rig runs the old
config until someone redeploys.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Host;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the content-root argument on the Layer-A pre-host <c>${secret:}</c> expander
|
||||
/// (<see cref="SecretsRegistration.AddPreHostSqliteExpander"/>, called from <c>Program.cs</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// The argument is load-bearing and its absence is SILENT. The expander composes secrets into a
|
||||
/// throwaway <see cref="ServiceCollection"/> with no <c>IHostEnvironment</c>; without an explicit
|
||||
/// content root the under-content-root rule does not run, the migrator creates the store at the
|
||||
/// rejected path, and the boot then fails once the real host registers <c>IHostEnvironment</c>.
|
||||
/// The leftover empty database is the "the file is there, it's just empty" artifact that made the
|
||||
/// 2026-08-09 MxGateway outage read as corruption rather than as a deployment error.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>These tests must resolve and run <see cref="SqliteSecretsStoreMigrator"/>.</b> Resolving
|
||||
/// only the connection factory would make every file assertion here vacuous — that constructor
|
||||
/// never touches the filesystem, so no store is created whether the guard runs or not, and the
|
||||
/// tests would pass for the wrong reason. <see cref="Control_ValidPath_ActuallyCreatesAStore"/>
|
||||
/// exists to prove the harness really does create files.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Assertion order is deliberate: artifact first, outcome second.</b> Asserting "it threw"
|
||||
/// first masks the real finding — a regression that stops the throw but still creates the store
|
||||
/// would report "no exception was thrown" and never mention the database sitting at the rejected
|
||||
/// path, which IS the defect. Verified by simulating the regression (dropping to the 3-argument
|
||||
/// overload) and confirming these fail on the FILE assertion, not the exception one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PreHostSecretsContentRootTests
|
||||
{
|
||||
private static string NewDir() =>
|
||||
Path.Combine(Path.GetTempPath(), "prehost-secrets-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
private static IConfiguration Config(string sqlitePath) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:SqlitePath"] = sqlitePath,
|
||||
["Secrets:MasterKey:Source"] = "Environment",
|
||||
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
|
||||
}).Build();
|
||||
|
||||
/// <summary>
|
||||
/// Mimics the pre-host expander exactly: throwaway collection, no IHostEnvironment,
|
||||
/// register, then run the migrator — which is what creates the database.
|
||||
/// </summary>
|
||||
private static async Task<Exception?> ComposeAndMigrate(string contentRoot, string sqlitePath)
|
||||
{
|
||||
Directory.CreateDirectory(contentRoot);
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
return await Record.ExceptionAsync(async () =>
|
||||
{
|
||||
services.AddPreHostSqliteExpander(Config(sqlitePath), contentRoot);
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
await provider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertNoStoreAt(string path)
|
||||
{
|
||||
string[] leftovers = new[] { path, path + "-wal", path + "-shm" }
|
||||
.Where(File.Exists)
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
leftovers.Length == 0,
|
||||
"A rejected path must not be left holding a store file — an empty database at the "
|
||||
+ "rejected path is exactly the artifact this guard exists to prevent. Found: "
|
||||
+ string.Join(", ", leftovers));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Control_ValidPath_ActuallyCreatesAStore()
|
||||
{
|
||||
// Proves the harness is capable of creating a store, so that the "no file" assertions
|
||||
// in the other two tests are meaningful rather than vacuous.
|
||||
var storeDir = NewDir();
|
||||
Directory.CreateDirectory(storeDir);
|
||||
var path = Path.Combine(storeDir, "secrets.db");
|
||||
|
||||
var thrown = await ComposeAndMigrate(NewDir(), path);
|
||||
|
||||
Assert.Null(thrown);
|
||||
Assert.True(File.Exists(path), "the control must create a store, or the guards prove nothing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PathInsideContentRoot_IsRejected_AndLeavesNoStore()
|
||||
{
|
||||
var contentRoot = NewDir();
|
||||
var path = Path.Combine(contentRoot, "data", "scadabridge-secrets.db");
|
||||
|
||||
var thrown = await ComposeAndMigrate(contentRoot, path);
|
||||
|
||||
AssertNoStoreAt(path);
|
||||
Assert.NotNull(thrown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RelativePath_IsRejected_AndLeavesNoStore()
|
||||
{
|
||||
// Unique name: a stale scadabridge-secrets.db from an earlier run under the old relative
|
||||
// default sits in the test output directory and would make this assertion lie.
|
||||
var path = "prehost-probe-" + Guid.NewGuid().ToString("N") + ".db";
|
||||
|
||||
var thrown = await ComposeAndMigrate(NewDir(), path);
|
||||
|
||||
AssertNoStoreAt(path);
|
||||
Assert.NotNull(thrown);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user