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]