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();
}
}