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();
}
}