Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/DependencyInjection/AddZbSecretsAkkaReplicationTests.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

135 lines
6.0 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.Replication;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection;
/// <summary>
/// Registration tests for <c>AddZbSecretsAkkaReplication</c>.
/// <para>
/// These exist because 0.2.0 shipped with Akka replication silently inert and every other test in
/// this assembly still passed. The bug was pure registration ORDER: the extension called
/// <c>AddZbSecrets</c> first, which does
/// <c>TryAddSingleton&lt;ISecretReplicator, NoOpSecretReplicator&gt;()</c>, so the package's own
/// <c>TryAddSingleton&lt;ISecretReplicator&gt;</c> found a descriptor already present and was
/// silently discarded. Writes then published into a no-op sink and no replication actor was ever
/// spawned — with no exception and no log line anywhere.
/// </para>
/// <para>
/// The equivalent SQL-Server package always had a test asserting its replicator type, and always
/// had the correct order. That asymmetry is exactly why the defect landed in one package and not
/// the other, so the gap is closed here.
/// </para>
/// <para>
/// These assert against the <see cref="IServiceCollection"/> because the 0.2.0 defect was entirely
/// about which descriptor wins, and the descriptor is where it is visible. Resolution through a
/// BUILT provider — where the scadaproj#1 singleton-cycle deadlock lived — is covered by
/// <see cref="HostedProcessResolutionTests"/>, and end-to-end actor behaviour by
/// <c>TwoNodeClusterReplicationTests</c>. (An earlier revision of this comment claimed a
/// provider-based test was impossible because <c>DistributedPubSub.Get</c> needs a joined
/// cluster; the hang it was avoiding was actually that DI deadlock, and a single self-joined
/// node is all the cluster the actor needs.)
/// </para>
/// </summary>
public sealed class AddZbSecretsAkkaReplicationTests
{
private static IServiceCollection Register()
{
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"zb-akkadi-{Guid.NewGuid():N}.db"),
["Secrets:Replication:AnnounceInterval"] = "00:00:30",
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddZbSecretsAkkaReplication(config, "Secrets", "Secrets:Replication");
return services;
}
private static ServiceDescriptor Descriptor<T>(IServiceCollection services) =>
services.Single(d => d.ServiceType == typeof(T));
[Fact]
public void ISecretReplicator_is_not_bound_to_the_no_op_sink()
{
// THE REGRESSION TEST. Before the fix this descriptor was NoOpSecretReplicator, so every
// replicated write went nowhere. A no-op secret replicator is the worst failure mode
// available: the cluster reports healthy and silently never converges.
ServiceDescriptor descriptor = Descriptor<ISecretReplicator>(Register());
Assert.NotEqual(typeof(NoOpSecretReplicator), descriptor.ImplementationType);
}
[Fact]
public void ISecretReplicator_is_registered_by_this_package_via_a_factory()
{
// The package registers through a factory (it needs the lazily-created actor ref), whereas
// the core no-op is registered by concrete type. A factory registration is therefore proof
// that this package's descriptor won the TryAdd race rather than core's.
ServiceDescriptor descriptor = Descriptor<ISecretReplicator>(Register());
Assert.NotNull(descriptor.ImplementationFactory);
Assert.Null(descriptor.ImplementationType);
}
[Fact]
public void ISecretStore_is_decorated_so_writes_actually_publish()
{
// AddSingleton (not TryAdd) means the decorator is appended, and the LAST registration for
// a service type is what the container resolves. If this regressed to the bare SQLite
// store, writes would never publish.
ServiceDescriptor last = Register()
.Last(d => d.ServiceType == typeof(ISecretStore));
Assert.NotNull(last.ImplementationFactory);
}
[Fact]
public void The_undecorated_concrete_store_is_registered()
{
// ReplicatingSecretStore resolves SqliteSecretStore by concrete type. Core registering it
// only behind ISecretStore was the OTHER defect this library shipped, so pin it here too.
IServiceCollection services = Register();
Assert.Contains(services, d => d.ServiceType == typeof(SqliteSecretStore));
}
[Fact]
public void The_store_migrator_seam_is_registered()
{
IServiceCollection services = Register();
Assert.Contains(services, d => d.ServiceType == typeof(ISecretsStoreMigrator));
}
[Fact]
public void The_content_root_argument_reaches_the_store_path_guard()
{
// scadaproj#31: the Akka node's local SQLite store is migrated by a pre-host expander too,
// so the extension must be able to hand the guard a content root (no IHostEnvironment here).
string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-akka-root-{Guid.NewGuid():N}");
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = Path.Combine(contentRoot, "s.db"),
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddZbSecretsAkkaReplication(config, contentRootPath: contentRoot);
using ServiceProvider provider = services.BuildServiceProvider();
Assert.Throws<OptionsValidationException>(
() => provider.GetRequiredService<IOptions<SecretsOptions>>().Value);
}
}