Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerCertReconcileTests.cs
T
Joseph Doherty 037798b367 feat(localdb)!: replicate site config + sf_messages via CDC, delete the bespoke replicators
Tasks 14, 15 and 16, landed as ONE commit.

PLAN DEFECT: these three tasks cannot compile separately. SiteReplicationActor
takes a ReplicationService and calls ReplaceAllAsync (Task 14 deletes both);
DeploymentManagerActor Tells message types declared in ReplicationMessages.cs
(Task 15 deletes it); AkkaHostedService constructs the actor (Task 16). Any
ordering leaves a broken intermediate. Combining them also strengthens the
invariant Task 14 already stated for itself — the two mechanisms never both
run, and never neither.

Registered 8 tables in SiteLocalDbSetup.OnReady: sf_messages plus the 7 site
config tables. notification_lists and smtp_configurations are deliberately NOT
registered — permanently empty by design, so registering them would open a
standing replication channel whose only historical payload was plaintext SMTP
passwords. Migrate stays the LAST call in OnReady, after all registrations, so
migrated rows enter the oplog through live capture triggers.

Deleted: SiteReplicationActor, ReplicationMessages.cs, ReplicationService,
StoreAndForwardStorage.ReplaceAllAsync, and 6 test files. ReplaceAllAsync is
not merely unused but unsafe to keep: a mass DELETE on a now-replicated table
would be captured and shipped to the peer.

Kept ActiveNodeEvaluator (delivery gate + heartbeat still need it) with its doc
corrected, and activeNodeCheck in AkkaHostedService (SiteCommunicationActor).

The positional-argument hazard the plan flagged was real: removing
DeploymentManagerActor's optional IActorRef? replicationActor shifted 6
trailing optionals, and 4 test call sites bound the wrong arguments with no
compile error at some positions. Converted them to named arguments where
possible — Props.Create builds an expression tree, which rejects out-of-position
named args, so the rest are padded positionally with a comment saying why.

The Task 7 'not yet registered' test was INVERTED rather than deleted, and is
exact in both directions: too few means a table silently stops replicating, too
many means the SMTP tables leak. Added a separate security-named test for those
two, and a composite-PK test (LWW keys on the full PK, so a truncated key set
would collapse distinct rows). The convergence suites now get their
registrations from the real OnReady — their temporary harness registration is
deleted, so they prove the cutover rather than agreeing with themselves.

Verified: build 0 warnings; SiteRuntime 512, StoreAndForward 130, Host 330,
AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97, LocalDb
integration 16 — all pass, 0 failures.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 04:20:05 -04:00

110 lines
4.7 KiB
C#

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;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// 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 <see cref="CertStoreActor"/> 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 <c>MemberUp</c> 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.
/// </summary>
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 TestLocalDb _localDb;
public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile")
{
_localDb = TestLocalDb.CreateTemp("dm-cert-test");
_storage = new SiteStorageService(
_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
// then dispose the database before deleting — the master connection anchors the WAL.
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
/// <summary>Forwards every message it receives to a probe, preserving the original sender.</summary>
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<DeploymentManagerActor>.Instance)));
[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<ExportLocalTrustedCerts>();
// 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<WriteCertToLocalStore>(TimeSpan.FromSeconds(5));
Assert.Equal("ABC123", write.Thumbprint);
Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), write.DerBase64);
}
}