using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.Secrets; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// Positive-control probes of the Secrets 0.6.x fail-closed store-path validation as composed by /// THIS host — a clean boot is equally consistent with the validator never being wired, so the /// load-bearing assertions here are the rejections (the approach that caught the library's own /// 0.6.0 pre-host hole during the family adoption). Covers both compositions the host has: /// /// in-host via AddOtOpcUaSecrets (an supplies the /// content root); /// the pre-host ${secret:} expander shape from Program.cs — a bare /// with NO , where the content /// root must be passed explicitly or the under-content-root rule silently vanishes at exactly /// the moment the migrator creates the store file (the 0.6.0 defect). /// /// public sealed class SecretsStorePathValidationTests : IDisposable { private readonly string _contentRoot = Directory.CreateTempSubdirectory("otopcua-secrets-croot-").FullName; public void Dispose() { try { Directory.Delete(_contentRoot, recursive: true); } catch { /* best effort */ } } private static IConfiguration ConfigWithPath(string sqlitePath) => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = sqlitePath, ["Secrets:MasterKey:Source"] = "Environment", ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY", ["Secrets:RunMigrationsOnStartup"] = "false", }).Build(); private ServiceProvider BuildHostShapedProvider(string sqlitePath) { var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new FakeHostEnvironment(_contentRoot)); services.AddOtOpcUaSecrets(ConfigWithPath(sqlitePath)); return services.BuildServiceProvider(); } [Fact] public void A_relative_store_path_is_rejected() { using var sp = BuildHostShapedProvider("otopcua-secrets.db"); var ex = Should.Throw( () => sp.GetRequiredService>().Value); ex.Message.ShouldContain("absolute", Case.Insensitive, "the rejection must name the rule, or the operator who caused it cannot act on it"); } [Fact] public void An_absolute_path_inside_the_content_root_is_rejected() { // Absolute does NOT imply safe — this is the MxGateway outage shape: the path that lost // the credential store WAS absolute; it sat in the directory the deploy renames. var inside = Path.Combine(_contentRoot, "data", "otopcua-secrets.db"); using var sp = BuildHostShapedProvider(inside); var ex = Should.Throw( () => sp.GetRequiredService>().Value); ex.Message.ShouldContain("content root", Case.Insensitive); File.Exists(inside).ShouldBeFalse("a rejected path must never gain a store file"); } [Fact] public void An_absolute_path_outside_the_content_root_passes() { // The control for the two rejections above: proves the failures fail because of the RULES, // not because the whole subsystem is unresolvable in this composition. var outside = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"); using var sp = BuildHostShapedProvider(outside); sp.GetRequiredService>().Value.SqlitePath.ShouldBe(outside); } [Fact] public async Task The_prehost_expander_rejects_an_in_content_root_path_before_creating_the_store() { // Mirrors Program.cs's ${secret:} expansion container byte-for-byte: bare ServiceCollection, // no IHostEnvironment, content root passed EXPLICITLY (the 0.6.1 four-argument overload), // then RUN the migration — resolving the migrator alone never touches the filesystem, so a // resolve-only probe leaves the file assertions vacuous. The rejection must land BEFORE // creation: an empty store with -wal/-shm at the rejected path is the artifact that made // the MxGateway outage read as "the database is there, it's just empty". var inside = Path.Combine(_contentRoot, "data", "otopcua-secrets.db"); await using var sp = new ServiceCollection() .AddZbSecrets(ConfigWithPath(inside), "Secrets", _contentRoot) .BuildServiceProvider(); // Record rather than Should.Throw, so the ARTIFACT is asserted first: a regression that // stops the throw but still writes the store must fail on the file — which is the defect — // not on a missing exception that never mentions the database at the rejected path. var ex = await Record.ExceptionAsync(() => sp.GetRequiredService() .MigrateAsync(TestContext.Current.CancellationToken)); File.Exists(inside).ShouldBeFalse("a rejected path must not be left holding a store file"); File.Exists(inside + "-wal").ShouldBeFalse(); File.Exists(inside + "-shm").ShouldBeFalse(); ex.ShouldBeOfType(); } [Fact] public async Task Without_the_explicit_content_root_the_prehost_hole_reopens() { // Falsification control for the test above, and the reason Program.cs's four-argument call // is load-bearing: the same composition WITHOUT the explicit root has no IHostEnvironment // to consult, the under-content-root rule is skipped, and the migration WRITES the store at // the rejected path. Running the migration (not just resolving) is what makes this a true // control — it proves the file assertions above are capable of failing. If the library ever // closes this differently (making this test fail), the source-scan pin on Program.cs can be // retired with it. var inside = Path.Combine(_contentRoot, "data", "otopcua-secrets.db"); await using var sp = new ServiceCollection() .AddZbSecrets(ConfigWithPath(inside), "Secrets") .BuildServiceProvider(); await sp.GetRequiredService() .MigrateAsync(TestContext.Current.CancellationToken); File.Exists(inside).ShouldBeTrue( "the 3-argument overload with no IHostEnvironment must create the store at the " + "in-content-root path — the exact hole the explicit content root closes"); } private sealed class FakeHostEnvironment(string contentRoot) : IHostEnvironment { public string EnvironmentName { get; set; } = "Production"; public string ApplicationName { get; set; } = "OtOpcUa.Tests"; public string ContentRootPath { get; set; } = contentRoot; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } }