using Akka.Actor; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Shouldly; 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; /// /// Guards the production DI wiring for cluster secret replication (AddOtOpcUaSecrets). /// This is the registration half of a defect class that has already shipped once in this library /// family: every unit test passed because nothing ever built a container, so a decorator that /// could not actually be constructed went unnoticed. /// /// Two properties matter here, and both are load-bearing on driver-role nodes that have no /// auth/AdminUI surface and whose only symptom of a broken registration is drivers failing to /// open sessions: /// /// /// With replication off (the default), must resolve to the /// plain — byte-identical to the pre-replication host. /// /// /// With replication on, must resolve to /// and the undecorated concrete /// must still resolve, because the decorator is /// constructed from it. A missing concrete registration is the exact gap that shipped. /// /// /// 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) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { // A per-test SQLite path keeps these hermetic; nothing here opens the file, because // resolving the store does not touch the database. ["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"), ["Secrets:MasterKey:Source"] = "Environment", ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY", ["Secrets:RunMigrationsOnStartup"] = "false", ["Secrets:Replication:Enabled"] = replicationEnabled ? "true" : "false", ["Secrets:Replication:AnnounceInterval"] = "00:00:30", ["Secrets:Replication:ActorName"] = "zb-secret-replication", }) .Build(); var services = new ServiceCollection(); services.AddLogging(); // The replicating store reaches the ActorSystem through SecretReplicationActorProvider. // The host supplies one via Akka.Hosting; a plain system is equivalent for registration. if (actorSystem is not null) services.AddSingleton(actorSystem); services.AddOtOpcUaSecrets(configuration); return services.BuildServiceProvider(); } [Fact] public void Replication_disabled_resolves_the_plain_sqlite_store() { using var sp = BuildProvider(replicationEnabled: false); var store = sp.GetRequiredService(); store.ShouldBeOfType( "replication is opt-in — with Secrets:Replication:Enabled false the host must resolve the " + "plain local store, so existing nodes are behaviourally unchanged"); } [Fact] public void Replication_disabled_does_not_require_an_actor_system() { // A driver-role node builds its container before the ActorSystem is reachable for secret // resolution; the default path must not have taken a dependency on it. using var sp = BuildProvider(replicationEnabled: false); Should.NotThrow(() => sp.GetRequiredService()); } [Fact] public void Replication_enabled_resolves_the_replicating_store() { using var system = ActorSystem.Create("secrets-registration-test-enabled"); using var sp = BuildProvider(replicationEnabled: true, system); var store = sp.GetRequiredService(); store.ShouldBeOfType( "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() { 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); } [Fact] public void Replication_enabled_registers_a_startup_hook_that_creates_the_replication_actor() { 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. 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() { using var system = ActorSystem.Create("secrets-registration-test-actor-created"); using var sp = BuildProvider(replicationEnabled: true, system); 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(); } [Fact] public void Replication_disabled_registers_no_startup_hook() { using var sp = BuildProvider(replicationEnabled: false); sp.GetServices().OfType().ShouldBeEmpty(); } }