using Akka.Actor; using Akka.Cluster; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// UA1: cert-trust reconcile-on-join. When a site node (re)joins the cluster, the /// Deployment Manager singleton reads its own trusted-peer store and pushes each /// trusted certificate to the joining node's so the /// two per-node PKI stores converge — without this, a node that missed a trust /// broadcast while it was down stays permanently divergent. /// /// Needs a cluster provider (the DM subscribes to MemberUp in PreStart and /// addresses the joined node via a remote actor path), so this lives in its own /// cluster-enabled ActorSystem rather than the non-cluster DeploymentManagerActorTests. /// public class DeploymentManagerCertReconcileTests : TestKit, IDisposable { // In-memory TestTransport (not dot-netty): the cluster extension loads and // SelfAddress is available, but no socket is bound and the node never has to // form a real two-node cluster. Role must be "Site" (SiteClusterRole). private const string ClusterConfig = @" akka { actor { provider = cluster } remote { enabled-transports = [""akka.remote.test""] test { transport-class = ""Akka.Remote.Transport.TestTransport, Akka.Remote"" applied-adapters = [] registry-key = dm-cert-reconcile-test local-address = ""test://dm-cert@localhost:1"" maximum-payload-bytes = 128000b scheme-identifier = test } } cluster { roles = [""Site""] } loglevel = WARNING }"; private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly string _dbFile; public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile") { _dbFile = Path.Combine(Path.GetTempPath(), $"dm-cert-test-{Guid.NewGuid():N}.db"); _storage = new SiteStorageService( $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } /// Forwards every message it receives to a probe, preserving the original sender. private sealed class ForwardingActor : ReceiveActor { public ForwardingActor(IActorRef target) => ReceiveAny(msg => target.Forward(msg)); } private IActorRef CreateDeploymentManager() => ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger.Instance, null, null, null, null, null, null, null, null))); [Fact] public void SiteNodeJoined_PushesLocalTrustedCertsToJoinedNode() { // Stand a forwarder at the well-known cert-store name so both the local // export Ask and the remote-path write land on our probe (self address in // this single-node test resolves the remote path back to the local actor). var certStoreProbe = CreateTestProbe(); Sys.ActorOf(Props.Create(() => new ForwardingActor(certStoreProbe.Ref)), CertStoreActor.WellKnownName); var dm = CreateDeploymentManager(); dm.Tell(new DeploymentManagerActor.SiteNodeJoined(Cluster.Get(Sys).SelfAddress)); // 1) The singleton reads its own trusted store. certStoreProbe.ExpectMsg(); // 2) Feed the export back; the singleton pushes each cert to the joined node. dm.Tell(new LocalCertExport(true, null, new[] { new ExportedCert("ABC123", new byte[] { 1, 2, 3 }) })); var write = certStoreProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("ABC123", write.Thumbprint); Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), write.DerBase64); } }