using Microsoft.AspNetCore.Builder; using ZB.MOM.WW.MxGateway.Server; namespace ZB.MOM.WW.MxGateway.Tests.Configuration; // Mutates the process-global Secrets__SqlitePath that GatewayApplication.CreateBuilder reads; // serialized against every other collection so a parallel host-building test cannot inherit the // deliberately-rejected path. See GlobalEnvironmentCollection. [Collection(TestSupport.GlobalEnvironmentCollection.Name)] public sealed class SecretsStorePathGuardTests { private const string SqlitePathVariable = "Secrets__SqlitePath"; /// /// Verifies the store-path guard runs in the pre-host secrets container, which is the /// only place it matters. /// /// /// /// CreateBuilder resolves ${secret:} references before the host exists, using a /// throwaway that /// contains no IHostEnvironment — and it runs the store migrator, which creates the /// database. A library that infers the content root from IHostEnvironment alone /// cannot distinguish "no content root" from "no host registered" and skips the rule here, so /// the store is created at the rejected path and only then does the real host refuse to start. /// The leftover empty database with its -wal/-shm siblings is precisely the /// artifact that made the 2026-08-09 credential loss read as "the database is there, it's just /// empty". The gateway therefore passes the content root explicitly. /// /// /// The assertion that no file was created is the load-bearing one. A test that merely observed /// a failed boot would pass even while the store was being written, because the failure arrives /// afterwards either way — which is exactly how this defect survived its first release. /// /// [Fact] public void CreateBuilder_RejectsSecretsStoreUnderContentRoot_WithoutCreatingIt() { string? original = Environment.GetEnvironmentVariable(SqlitePathVariable); string contentRoot = ResolveContentRoot(); string rejected = Path.Combine(contentRoot, $"probe-secrets-{Guid.NewGuid():N}.db"); try { Environment.SetEnvironmentVariable(SqlitePathVariable, rejected); // Capture rather than Assert.ThrowsAny, so the store-creation assertions below are // reported first. Ordering matters here: "it threw" is the weaker claim, and asserting // it first would mask the stronger one — that nothing was written before it threw. Exception? thrown = Record.Exception(() => GatewayApplication.CreateBuilder([])); Assert.False(File.Exists(rejected), $"the rejected store was created at {rejected}"); Assert.False(File.Exists(rejected + "-wal"), "a write-ahead log was created for the rejected store"); Assert.False(File.Exists(rejected + "-shm"), "a shared-memory file was created for the rejected store"); Assert.NotNull(thrown); } finally { Environment.SetEnvironmentVariable(SqlitePathVariable, original); // Delete defensively: if the guard ever regresses this test writes a database into the // content root, which on a dev machine is the source tree. foreach (string leftover in new[] { rejected, rejected + "-wal", rejected + "-shm" }) { if (File.Exists(leftover)) { File.Delete(leftover); } } } } /// /// Verifies a store path outside the content root is accepted and actually used. /// /// /// The assertion is that the database exists afterwards, not merely that nothing threw. A /// not-null builder is very close to a tautology once no exception escaped, so it would pass /// even if the pre-host container had stopped opening the store altogether — which would also /// silently void the negative test above, since that one can only observe a file the migration /// would otherwise have written. Proving the accepted path gets a real database is what keeps /// the rejected-path assertion meaningful. /// [Fact] public void CreateBuilder_AcceptsSecretsStoreOutsideContentRoot_AndCreatesIt() { string? original = Environment.GetEnvironmentVariable(SqlitePathVariable); string directory = Directory.CreateTempSubdirectory("mxgw-secrets-ok").FullName; string accepted = Path.Combine(directory, "secrets.db"); try { Environment.SetEnvironmentVariable(SqlitePathVariable, accepted); WebApplicationBuilder builder = GatewayApplication.CreateBuilder([]); Assert.NotNull(builder); Assert.True(File.Exists(accepted), $"the accepted store was not created at {accepted}"); } finally { Environment.SetEnvironmentVariable(SqlitePathVariable, original); // The store runs in WAL mode with connection pooling, so a pooled handle can outlive the // migration and keep secrets.db (plus its -wal/-shm sidecars) open. Windows refuses to // delete a directory holding open files where Unix does not, so clear the pool first; // the catch is belt-and-braces for a sidecar whose handle outlasts even that. Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(directory, recursive: true); } catch (IOException) { // Best-effort cleanup of the temp store; a locked file must not fail the test. } catch (UnauthorizedAccessException) { // Best-effort cleanup of the temp store; a locked file must not fail the test. } } } // The content root CreateBuilder will use, taken from a builder created with the suite's normal // (valid) store path rather than assumed from the test's working directory. private static string ResolveContentRoot() => GatewayApplication.CreateBuilder([]).Environment.ContentRootPath; }