b3d1a26f38
0.2.0's AddZbSecretsAkkaReplication never bound its own ISecretReplicator: it called AddZbSecrets first, which TryAdds NoOpSecretReplicator, so the package's own TryAdd was silently discarded. Replication published into a no-op sink and no actor was ever spawned - no exception, no log line. Found by the Task 6 wiring work, which is the first code that ever built a container around that extension. Fixed upstream in 0.2.1. Test consequence worth recording: the replication-enabled registration tests previously passed against a plain ActorSystem only BECAUSE of that bug - the no-op never touched Akka. With 0.2.1 they resolve a real replicator, which spawns the actor, whose PreStart needs DistributedPubSub and therefore a joined cluster, so provider-based assertions hang. Standing a real cluster up inside this test assembly was attempted and did not work, so these assertions are now made against the ServiceCollection, which is precisely where the defect lives. Actor creation and convergence remain covered upstream by the library's TwoNodeClusterReplication tests against a genuine 2-node cluster. 7 registration tests pass in 23ms. Full solution builds 0 errors; no warnings originate from these files.
175 lines
8.6 KiB
C#
175 lines
8.6 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(...). 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));
|
|
|
|
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();
|
|
}
|
|
}
|