using System.Diagnostics.CodeAnalysis;
using Akka.Actor;
using Akka.Cluster.Hosting;
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication;
///
/// Guards the one thing every other test in this project structurally cannot see: whether an
/// admin node boots with the real registration graph behind the mesh command boundary.
///
///
///
/// The Phase 1A live gate failed here. WithOtOpcUaControlPlaneSingletons's first
/// starter resolved IMeshCommandDialer and IMeshPskProvider eagerly, and their
/// graph reaches back into the actor registry — dialer → IMeshPskProvider →
/// ISecretResolver → AddZbSecrets → IAuditWriter →
/// ActorAuditWriter, whose constructor calls
/// registry.Get<AuditWriterActorKey>() for a singleton registered further down
/// the SAME builder. Every central node died on
/// MissingActorRegistryEntryException, in every transport mode, before serving
/// anything.
///
///
/// Why the existing tests were blind. MeshGrpcCommandDialerTests activates the
/// dialer from a ServiceCollection that has no secrets registration at all, so its
/// graph stops one hop in; the CentralCommunicationActorTests facts inject fakes
/// directly and never touch DI; and the existing whole-host boots register no mesh provider,
/// so the eager lookups returned and resolved nothing. Every one of
/// them passed while no central node could start.
///
///
/// So the assertion is StartAsync itself. The failure happens while the Akka
/// builder's starters execute, which is inside StartAsync — building the
/// ServiceProvider and resolving from it by hand would not reproduce it. The graph
/// below is the production one from IMeshCommandDialer down to the registry-bound
/// audit writer; only the secret store itself is a stand-in, because a real one would add a
/// KEK and a SQLite file to a test whose subject is starter ORDER.
///
///
public sealed class ControlPlaneStarterOrderTests
{
[Theory]
[InlineData(MeshTransportOptions.ModeDps)]
[InlineData(MeshTransportOptions.ModeClusterClient)]
[InlineData(MeshTransportOptions.ModeGrpc)]
public async Task An_admin_node_boots_with_the_real_mesh_dependency_graph(string mode)
{
// Every mode, because the eager resolve was mode-independent: the starter resolved both
// dependencies before anything looked at MeshTransport:Mode, so Dps — the DEFAULT, and the
// rollback target — died exactly like Grpc did.
using var host = BuildAdminHost(mode);
await Should.NotThrowAsync(() => host.StartAsync());
try
{
var registry = host.Services.GetRequiredService();
registry.TryGet(out _).ShouldBeTrue(
"the command boundary must be registered on a booted admin node");
registry.TryGet(out _).ShouldBeTrue(
"the audit singleton the eager resolve reached for too early must exist by now");
}
finally
{
await host.StopAsync();
}
}
[Theory]
[InlineData(MeshTransportOptions.ModeDps)]
[InlineData(MeshTransportOptions.ModeClusterClient)]
[InlineData(MeshTransportOptions.ModeGrpc)]
public async Task The_deferred_dependencies_are_resolved_and_used_once_the_host_is_up(string mode)
{
// Deferring must not become "never resolved" — that would trade a loud boot failure for a
// silent one, with the known-cluster gate permanently unpublished and the dialer permanently
// absent. So this asserts the far end of the deferral: the actor resolved the PSK provider
// FROM THE CONTAINER and drove it, through the ordinary contact-refresh path. A Theory over
// all three modes because publication is mode-independent (the gate follows the endpoint).
//
// The provider is registered as a TYPE with an ISecretResolver constructor, so the resolve
// walks the same registry-bound chain the production MeshPskProvider does — which makes
// this fact the race detector for the PreStart-vs-starter-#4 window: a lost race that
// stayed null forever would never publish, and the TaskCompletionSource below would time
// out red. (Memoize-on-first-use in the actor is what heals the lost race at the first
// RefreshContacts message.)
//
// The signal is a TaskCompletionSource rather than a counter, because PreStart runs on the
// actor's own thread and the contact load is a piped DB read: there is no instant after
// StartAsync at which a synchronous assertion is correct.
using var host = BuildAdminHost(mode, useSignallingProvider: true);
await host.StartAsync();
try
{
var signal = (SignallingPskProvider)host.Services.GetRequiredService();
await signal.Published.WaitAsync(TimeSpan.FromSeconds(20));
}
finally
{
await host.StopAsync();
}
}
///
/// Builds an admin host through the production cluster bootstrap and the production control-plane
/// starter registration, with the mesh dependency graph wired as it is on a real central node.
///
/// The mesh transport mode to boot under.
///
/// Substitutes for the real .
/// Only the resolution test sets it; the boot test keeps the production type so the graph it
/// walks is the production graph. The signalling provider is registered as a factory taking
/// from the container, so its resolution walks the same
/// registry-bound chain either way.
///
/// The unstarted host.
private static IHost BuildAdminHost(string mode, bool useSignallingProvider = false)
{
var clusterOptions = new AkkaClusterOptions
{
Port = 0,
Hostname = "127.0.0.1",
PublicHostname = "127.0.0.1",
SeedNodes = [],
Roles = ["admin"],
};
return Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton>(Options.Create(clusterOptions));
services.AddSingleton>(Options.Create(
new MeshTransportOptions { Mode = mode, DispatchTimeoutSeconds = 10 }));
services.AddDbContextFactory(
o => o.UseInMemoryDatabase($"starter-order-{Guid.NewGuid():N}"));
services.AddSingleton(new StubClusterRoleInfo());
// THE GRAPH. Production types from the dialer down to the registry-bound writer; the
// order of these registrations is deliberately irrelevant, because the defect was
// never about registration order — it was about WHEN the graph is first walked.
services.AddSingleton();
services.AddSingleton();
if (useSignallingProvider)
services.AddSingleton(sp =>
new SignallingPskProvider(sp.GetRequiredService()));
else
services.AddSingleton();
services.AddSingleton();
services.AddAkka("otopcua-starter-order-test", (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp);
ab.WithOtOpcUaControlPlaneSingletons();
});
})
.Build();
}
///
/// Stands in for ActorAuditWriter: an that resolves a
/// cluster singleton from the in its constructor. That
/// constructor is the tripwire — it throws
/// MissingActorRegistryEntryException for exactly as long as the audit singleton has
/// not been registered yet.
///
private sealed class RegistryBoundAuditWriter : IAuditWriter
{
private readonly IActorRef _proxy;
public RegistryBoundAuditWriter(ActorRegistry registry) => _proxy = registry.Get();
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
_proxy.Tell(evt);
return Task.CompletedTask;
}
}
///
/// An that completes a task the first time the actor publishes
/// the known-cluster set. Takes it never uses, purely so that
/// resolving it still walks the same registry-bound chain the real provider does.
///
private sealed class SignallingPskProvider : IMeshPskProvider
{
private readonly TaskCompletionSource _published =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public SignallingPskProvider(ISecretResolver resolver) => ArgumentNullException.ThrowIfNull(resolver);
/// Completes once has been called.
public Task Published => _published.Task;
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
{
psk = null;
return false;
}
public bool Matches(string clusterId, string presented) => false;
public void SetKnownClusters(IReadOnlyCollection clusterIds) => _published.TrySetResult();
public void Invalidate(string clusterId) { }
public void InvalidateAll() { }
}
///
/// Stands in for the shared secret store's resolver. Its only load-bearing property is the
/// one that broke boot: it depends on , exactly as
/// AddZbSecrets' resolver registration does.
///
private sealed class AuditingSecretResolver : ISecretResolver
{
public AuditingSecretResolver(IAuditWriter auditWriter) => ArgumentNullException.ThrowIfNull(auditWriter);
public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null);
}
/// Minimal ; the admin singleton factories read it.
private sealed class StubClusterRoleInfo : IClusterRoleInfo
{
public string? ClusterRole => null;
public string? ClusterId => null;
public NodeId LocalNode => NodeId.Parse("127.0.0.1:0");
public IReadOnlySet LocalRoles => new HashSet();
public bool HasRole(string role) => false;
public IReadOnlyList MembersWithRole(string role) => [];
public NodeId? RoleLeader(string role) => null;
public event EventHandler? RoleLeaderChanged { add { } remove { } }
}
}