feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka replicator" fork to build only the former; both are built here so the choice is a deployment decision (availability vs partition tolerance) rather than a library limitation. Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over distributed pub/sub). Core gains ISecretsStoreMigrator, one shared SecretLastWriterWins predicate so no two stores can disagree on a tie, the transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was inert and local writes would not have propagated at all. Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural divergence between the stores fails) and a 9-test in-process 2-node Akka cluster over real remoting. A post-build review caught six defects, all fixed and now covered: both replication modes could not resolve from the container (no test had built one), an unbounded fetch that broke past SQL Server's 2100-parameter cap, a poison row that aborted the rest of its batch forever, Enum.Parse on peer input that could restart the actor in a loop, null crypto blobs crossing the trust boundary, and a silently dropped pull-read failure. Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Container-resolution tests for both SQL-Server topologies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These exist because a code review caught that neither mode could resolve at all: the decorators
|
||||
/// ask for the concrete <see cref="SqliteSecretStore"/>, which the core package only registered
|
||||
/// behind <see cref="ISecretStore"/>. Every unit test passed and both modes were dead on arrival,
|
||||
/// because nothing built a provider. Resolving the graph is the assertion that matters.
|
||||
/// </remarks>
|
||||
public sealed class AddZbSecretsSqlServerTests
|
||||
{
|
||||
private static IConfiguration Config(string sqlitePath) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:SqlitePath"] = sqlitePath,
|
||||
["Secrets:RunMigrationsOnStartup"] = "false",
|
||||
["Secrets:MasterKey:Source"] = "Environment",
|
||||
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_TEST_KEY",
|
||||
["Secrets:SqlServer:ConnectionString"] = "Server=unused;Database=x;",
|
||||
}).Build();
|
||||
|
||||
private static string TempDb() => Path.Combine(Path.GetTempPath(), $"zb-di-{Guid.NewGuid():N}.db");
|
||||
|
||||
[Fact]
|
||||
public void SharedStore_mode_resolves_the_SqlServer_store_as_ISecretStore()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerStore(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
// The SQL-Server store displaces SQLite — no decorator, because there is nothing to replicate.
|
||||
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
Assert.IsType<SqlServerSecretsStoreMigrator>(provider.GetRequiredService<ISecretsStoreMigrator>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HubReplication_mode_resolves_a_decorated_local_store()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
Assert.IsType<ReplicatingSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
Assert.IsType<SqlServerSecretReplicator>(provider.GetRequiredService<ISecretReplicator>());
|
||||
// The local store is still provisioned by the core SQLite migrator, not the hub's.
|
||||
Assert.IsType<SqliteSecretsStoreMigrator>(provider.GetRequiredService<ISecretsStoreMigrator>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HubReplication_mode_registers_both_the_hub_migration_and_the_sync_service()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
IHostedService[] hosted = [.. provider.GetServices<IHostedService>()];
|
||||
|
||||
// The hub schema is nobody's "own" store, so every node must provision it at startup too.
|
||||
Assert.Contains(hosted, h => h is SqlServerSecretSyncService);
|
||||
Assert.Single(hosted, h => h.GetType().Name == "SqlServerHubMigrationHostedService");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_decorator_wraps_the_undecorated_local_store_rather_than_itself()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZbSecretsSqlServerReplication(Config(TempDb()));
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
// Guards against the self-referential registration that would stack-overflow on first use.
|
||||
var decorated = (ReplicatingSecretStore)provider.GetRequiredService<ISecretStore>();
|
||||
Assert.NotNull(decorated);
|
||||
Assert.NotSame(decorated, provider.GetRequiredService<SqliteSecretStore>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_connection_string_fails_at_registration_not_at_first_resolve()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:SqlitePath"] = TempDb(),
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddZbSecretsSqlServerStore(config));
|
||||
|
||||
Assert.Contains("ConnectionString", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user