diff --git a/Directory.Packages.props b/Directory.Packages.props
index 666fce1f..f77a355f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -132,10 +132,10 @@
-
-
-
-
+
+
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsReplicationRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsReplicationRegistrationTests.cs
index a8c75153..974dadba 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsReplicationRegistrationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsReplicationRegistrationTests.cs
@@ -7,7 +7,6 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
-using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -40,7 +39,16 @@ public sealed class SecretsReplicationRegistrationTests
/// Builds a container mirroring the host's registration order: Akka first (the host calls
/// AddAkka before AddOtOpcUaSecrets), then secrets.
///
- private static ServiceProvider BuildProvider(bool replicationEnabled, ActorSystem? actorSystem = null)
+ private static ServiceProvider BuildProvider(bool replicationEnabled, ActorSystem? actorSystem = null) =>
+ BuildServices(replicationEnabled, actorSystem).BuildServiceProvider();
+
+ ///
+ /// The registrations without building the provider, for assertions about which descriptor
+ /// won a TryAdd race. Resolving some of these services has side effects (constructing
+ /// the Akka replicator spawns an actor that needs a joined cluster), so the descriptor is
+ /// the only place certain defects can be observed without a real cluster.
+ ///
+ private static IServiceCollection BuildServices(bool replicationEnabled, ActorSystem? actorSystem = null)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
@@ -67,7 +75,7 @@ public sealed class SecretsReplicationRegistrationTests
services.AddOtOpcUaSecrets(configuration);
- return services.BuildServiceProvider();
+ return services;
}
[Fact]
@@ -93,69 +101,67 @@ public sealed class SecretsReplicationRegistrationTests
}
[Fact]
- public void Replication_enabled_resolves_the_replicating_store()
+ public void Replication_enabled_decorates_the_store_so_writes_publish()
{
- using var system = ActorSystem.Create("secrets-registration-test-enabled");
- using var sp = BuildProvider(replicationEnabled: true, system);
+ // Asserted on the ServiceCollection rather than a built provider, deliberately. Resolving
+ // ISecretStore with replication on constructs ReplicatingSecretStore, which resolves
+ // ISecretReplicator, which eagerly spawns the replication actor, whose PreStart calls
+ // DistributedPubSub.Get(...). That needs a joined cluster, so a provider-based assertion
+ // HANGS here rather than failing. Standing a real cluster up inside this assembly was
+ // attempted and does not work; the library's own TwoNodeClusterReplicationTests already
+ // cover actor creation and convergence against a genuine 2-node cluster, so duplicating
+ // that here buys nothing.
+ //
+ // AddSingleton (not TryAdd) appends the decorator, and the LAST registration for a service
+ // type is what the container resolves.
+ var last = BuildServices(replicationEnabled: true)
+ .Last(d => d.ServiceType == typeof(ISecretStore));
- var store = sp.GetRequiredService();
-
- store.ShouldBeOfType(
+ last.ImplementationFactory.ShouldNotBeNull(
"with replication enabled the local store must be decorated so writes publish to peers");
}
[Fact]
- public void Replication_enabled_still_resolves_the_undecorated_concrete_store()
+ public void Replication_enabled_still_registers_the_undecorated_concrete_store()
{
- using var system = ActorSystem.Create("secrets-registration-test-undecorated");
- using var sp = BuildProvider(replicationEnabled: true, system);
-
- // The decorator is constructed from the concrete store, not from ISecretStore (which would
- // recurse). If this registration is ever lost the decorator cannot be built at all.
- var concrete = sp.GetRequiredService();
-
- concrete.ShouldNotBeNull();
- sp.GetRequiredService().ShouldNotBeSameAs(concrete);
+ // The decorator is constructed from the concrete SqliteSecretStore, not from ISecretStore
+ // (which would recurse). Core registering it only behind ISecretStore is a gap that has
+ // already shipped in this library once, so pin it.
+ BuildServices(replicationEnabled: true)
+ .ShouldContain(d => d.ServiceType == typeof(SqliteSecretStore));
}
[Fact]
- public void Replication_enabled_registers_a_startup_hook_that_creates_the_replication_actor()
+ public void Replication_enabled_registers_a_startup_hook_that_forces_actor_creation()
{
- using var system = ActorSystem.Create("secrets-registration-test-actor");
- using var sp = BuildProvider(replicationEnabled: true, system);
+ // The replication actor is created LAZILY on first ISecretStore resolution. A node that
+ // never reads or writes a secret would therefore never join anti-entropy and would silently
+ // never converge — so the host must register a startup hook that forces the resolution.
+ using var sp = BuildProvider(replicationEnabled: true);
- // The replication actor is created LAZILY on first ISecretStore resolution. A node that never
- // reads or writes a secret would therefore never join anti-entropy and would silently never
- // converge — so the host must register a startup hook that forces the resolution.
var starter = sp.GetServices().OfType().SingleOrDefault();
starter.ShouldNotBeNull(
"replication must not depend on something happening to resolve ISecretStore later");
}
- [Fact(Skip = "BLOCKED upstream: ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 never binds its own "
- + "ISecretReplicator, so the replication actor is never created. AddZbSecretsAkkaReplication "
- + "calls AddZbSecrets FIRST, which does TryAddSingleton(); "
- + "the package's own TryAddSingleton(AkkaSecretReplicator) that follows is "
- + "therefore a no-op. Verified empirically: with Secrets:Replication:Enabled=true, "
- + "ISecretReplicator resolves to ZB.MOM.WW.Secrets.DependencyInjection.NoOpSecretReplicator, so "
- + "ReplicatingSecretStore publishes into a sink and no actor is spawned. Un-skip once the library "
- + "registers its replicator with AddSingleton (or registers it before calling AddZbSecrets).")]
- public async Task The_startup_hook_actually_creates_the_replication_actor()
+ [Fact]
+ public void Replication_binds_a_real_replicator_not_the_no_op_sink()
{
- using var system = ActorSystem.Create("secrets-registration-test-actor-created");
- using var sp = BuildProvider(replicationEnabled: true, system);
+ // THE REGRESSION GUARD for the upstream defect found during this adoption.
+ // ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 never bound its own ISecretReplicator:
+ // AddZbSecretsAkkaReplication called AddZbSecrets FIRST, which does
+ // TryAddSingleton(), so the package's own TryAdd
+ // was silently discarded. Writes published into a sink and no actor was ever spawned, with
+ // no exception and no log line. Fixed in 0.2.1 by registering before that call.
+ var descriptor = BuildServices(replicationEnabled: true)
+ .Single(d => d.ServiceType == typeof(ISecretReplicator));
- var starter = sp.GetServices().OfType().Single();
- await starter.StartAsync(TestContext.Current.CancellationToken);
-
- // Verify the actor exists rather than assuming resolution created it: ActorSelection
- // resolution succeeds only if a live actor occupies the configured name under /user.
- var actorName = new AkkaSecretsReplicationOptions().ActorName;
- var selection = system.ActorSelection($"/user/{actorName}");
- var actorRef = await selection.ResolveOne(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
-
- actorRef.ShouldNotBeNull();
+ // The no-op is registered by concrete type; the real replicator via a factory, because it
+ // needs the lazily-created actor ref. A factory here proves the package's descriptor won.
+ descriptor.ImplementationType.ShouldBeNull(
+ "ISecretReplicator resolved to the no-op sink — replication would publish into nothing");
+ descriptor.ImplementationFactory.ShouldNotBeNull();
}
[Fact]