1ccc237cb6
Akka.TestKit is the family convention for non-end-to-end Akka testing and would be the right tool here, but Akka.TestKit.Xunit2 is xunit-v2-only and this project is on xunit.v3 - adding it produces CS0433 type conflicts. Directory.Packages.props already documents the same constraint for the three projects deliberately held on xunit v2 for TestKit. Revisit when Akka ships an xunit.v3 TestKit.
184 lines
9.2 KiB
C#
184 lines
9.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Guards the production DI wiring for cluster secret replication (<c>AddOtOpcUaSecrets</c>).
|
|
/// 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 <b>driver-role</b> nodes that have no
|
|
/// auth/AdminUI surface and whose only symptom of a broken registration is drivers failing to
|
|
/// open sessions:
|
|
/// <list type="number">
|
|
/// <item>
|
|
/// With replication off (the default), <see cref="ISecretStore"/> must resolve to the
|
|
/// plain <see cref="SqliteSecretStore"/> — byte-identical to the pre-replication host.
|
|
/// </item>
|
|
/// <item>
|
|
/// With replication on, <see cref="ISecretStore"/> must resolve to
|
|
/// <see cref="ReplicatingSecretStore"/> <b>and</b> the undecorated concrete
|
|
/// <see cref="SqliteSecretStore"/> must still resolve, because the decorator is
|
|
/// constructed from it. A missing concrete registration is the exact gap that shipped.
|
|
/// </item>
|
|
/// </list>
|
|
/// </summary>
|
|
public sealed class SecretsReplicationRegistrationTests
|
|
{
|
|
/// <summary>
|
|
/// Builds a container mirroring the host's registration order: Akka first (the host calls
|
|
/// <c>AddAkka</c> before <c>AddOtOpcUaSecrets</c>), then secrets.
|
|
/// </summary>
|
|
private static ServiceProvider BuildProvider(bool replicationEnabled, ActorSystem? actorSystem = null) =>
|
|
BuildServices(replicationEnabled, actorSystem).BuildServiceProvider();
|
|
|
|
/// <summary>
|
|
/// The registrations without building the provider, for assertions about which descriptor
|
|
/// won a <c>TryAdd</c> 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.
|
|
/// </summary>
|
|
private static IServiceCollection BuildServices(bool replicationEnabled, ActorSystem? actorSystem = null)
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
// 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<ISecretStore>();
|
|
|
|
store.ShouldBeOfType<SqliteSecretStore>(
|
|
"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<ISecretStore>());
|
|
}
|
|
|
|
[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<IHostedService>().OfType<SecretReplicationStarter>().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<ISecretReplicator, NoOpSecretReplicator>(), 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<IHostedService>().OfType<SecretReplicationStarter>().ShouldBeEmpty();
|
|
}
|
|
}
|