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.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) => 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 { // 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; } [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_decorates_the_store_so_writes_publish() { // 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(...) — unavailable until the node has joined a cluster. Against a // plain ActorSystem that resolve hangs instead of failing. // // Akka.TestKit is the family convention for this (ScadaBridge's suites use it throughout) // and would be the right tool, but it is NOT available here: Akka.TestKit.Xunit2 is // xunit-v2-only, and this project is one of the 44 on xunit.v3. Adding it yields CS0433 // type conflicts. Directory.Packages.props documents the same constraint — AdminUI.Tests, // ControlPlane.Tests and Runtime.Tests are deliberately held on xunit v2 precisely because // no xunit.v3 TestKit ships as of Akka 1.5.62. Host wiring tests belong here, not in one of // those three, so the descriptor is the right seam until Akka ships a v3 TestKit. // // Nothing is lost: the registration order IS the defect, and the library's own // TwoNodeClusterReplicationTests already cover actor creation and convergence against a // genuine 2-node cluster. // // 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)); last.ImplementationFactory.ShouldNotBeNull( "with replication enabled the local store must be decorated so writes publish to peers"); } [Fact] public void Replication_enabled_still_registers_the_undecorated_concrete_store() { // 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_forces_actor_creation() { // 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); var starter = sp.GetServices().OfType().SingleOrDefault(); starter.ShouldNotBeNull( "replication must not depend on something happening to resolve ISecretStore later"); } [Fact] public void Replication_binds_a_real_replicator_not_the_no_op_sink() { // 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)); // 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] public void Replication_disabled_registers_no_startup_hook() { using var sp = BuildProvider(replicationEnabled: false); sp.GetServices().OfType().ShouldBeEmpty(); } }