Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/DependencyInjection/ContentRootTests.cs
T
Joseph Doherty c3b07d6766 fix(secrets): SqlServer/Akka extensions take contentRootPath; last explicit root wins (scadaproj#31)
The SQL-Server extensions called the 3-arg AddZbSecrets, and the store-path
validator captured the content root of whichever call registered it first.
In hub mode no registration order applied the pre-host under-content-root
guard: extension-first dropped the app's 4-arg root, core-first lost the
replicator (#30). That is the 0.6.0 store-path hole, reopened.

- AddZbSecretsSqlServerStore / AddZbSecretsSqlServerReplication /
  AddZbSecretsAkkaReplication gain a trailing optional contentRootPath,
  forwarded to the 4-arg AddZbSecrets
- the root now lives in one SecretsContentRoot instance holder per service
  collection; the last explicit root wins, null never erases one; the
  IHostEnvironment fallback is unchanged
- tests: ContentRootTests (hub mode, three compositions, artifact asserted
  before the throw, positive control), last-explicit-wins theory in
  SecretsStorePathRulesTests, Akka forwarding test
- code-reviews: Secrets-002 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00

115 lines
4.8 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.DependencyInjection;
/// <summary>
/// scadaproj#31 (Secrets-002): in hub-replication mode the node's local SQLite store is migrated by
/// a pre-host <c>${secret:}</c> expander, so the under-content-root guard must be reachable — either
/// through the extension's own <c>contentRootPath</c> or through a 4-arg <c>AddZbSecrets</c> in
/// either order. Before the fix no registration order applied it: the extension's internal 3-arg
/// call captured a null root first, and the core-first order lost the replicator (scadaproj#30).
/// </summary>
/// <remarks>
/// Every container here is pre-host — no <c>IHostEnvironment</c> — which is the case the guard
/// exists for. The store file is asserted absent BEFORE the exception is asserted, so a regression
/// that stops the throw but still creates the database fails on the artifact.
/// </remarks>
public sealed class ContentRootTests : IDisposable
{
private readonly string _contentRoot =
Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-root-{Guid.NewGuid():N}");
public ContentRootTests() => Directory.CreateDirectory(_contentRoot);
public enum Composition
{
ExtensionArgument,
ExtensionThenCore,
CoreThenExtension,
}
private IConfiguration Config(string sqlitePath) =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = sqlitePath,
["Secrets:RunMigrationsOnStartup"] = "false",
["Secrets:SqlServer:ConnectionString"] = "Server=unused;Database=x;",
}).Build();
private ServiceProvider Compose(Composition composition, IConfiguration config)
{
var services = new ServiceCollection();
services.AddLogging();
switch (composition)
{
case Composition.ExtensionArgument:
services.AddZbSecretsSqlServerReplication(config, contentRootPath: _contentRoot);
break;
case Composition.ExtensionThenCore:
services.AddZbSecretsSqlServerReplication(config);
services.AddZbSecrets(config, "Secrets", _contentRoot);
break;
case Composition.CoreThenExtension:
services.AddZbSecrets(config, "Secrets", _contentRoot);
services.AddZbSecretsSqlServerReplication(config);
break;
}
return services.BuildServiceProvider();
}
[Theory]
[InlineData(Composition.ExtensionArgument)]
[InlineData(Composition.ExtensionThenCore)]
[InlineData(Composition.CoreThenExtension)]
public async Task Hub_mode_refuses_a_local_store_inside_the_content_root(Composition composition)
{
string dbPath = Path.Combine(_contentRoot, "data", "s.db");
using ServiceProvider provider = Compose(composition, Config(dbPath));
Exception? thrown = await Record.ExceptionAsync(
() => provider.GetRequiredService<ISecretsStoreMigrator>().MigrateAsync(default));
Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file");
Assert.IsType<OptionsValidationException>(thrown);
}
[Theory]
[InlineData(Composition.ExtensionArgument)]
[InlineData(Composition.ExtensionThenCore)]
[InlineData(Composition.CoreThenExtension)]
public void Hub_mode_accepts_a_store_outside_the_content_root(Composition composition)
{
// Positive control: the same compositions with a legal path resolve cleanly, so the refusal
// above is the content-root rule and not some other failure of the composition.
string dbPath = Path.Combine(Path.GetTempPath(), $"zb-sqlsrv-outside-{Guid.NewGuid():N}.db");
using ServiceProvider provider = Compose(composition, Config(dbPath));
Assert.Equal(dbPath, provider.GetRequiredService<IOptions<SecretsOptions>>().Value.SqlitePath);
}
[Fact]
public void Shared_store_mode_forwards_the_content_root_too()
{
string dbPath = Path.Combine(_contentRoot, "s.db");
var services = new ServiceCollection();
services.AddLogging();
services.AddZbSecretsSqlServerStore(Config(dbPath), contentRootPath: _contentRoot);
using ServiceProvider provider = services.BuildServiceProvider();
Assert.Throws<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<SecretsOptions>>().Value);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(_contentRoot, recursive: true);
}
}