c3b07d6766
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
115 lines
4.8 KiB
C#
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);
|
|
}
|
|
}
|