using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// Guards the self-first seed-ordering rule at the only moment it can still be fixed cheaply: boot. /// /// /// /// The invariant (decision 2026-07-22) is conditional: if a node's own address /// appears in its own , it must be entry 0. Akka /// runs FirstSeedNodeProcess — the only path that can form a NEW cluster when no peer /// answers InitJoin — exclusively for seed-nodes[0]; every other node retries /// InitJoin forever. A node listed second therefore cannot cold-start while its peer /// is down, and it fails silently: the process is healthy, the port is open, the node /// simply never becomes a cluster member. That is why the rule is enforced loudly here. /// /// /// The conditional form is required, not incidental: a flat "self must be first" rule would /// reject every driver-only site node, which is seeded solely by central-1 and is /// legitimately not a seed of anything — /// see . /// /// public sealed class AkkaClusterOptionsValidatorTests { private static AkkaClusterOptions CentralNode(string publicHostname, params string[] seeds) => new() { // The docker-dev shape: bind to every interface, advertise the container DNS name. Hostname = "0.0.0.0", PublicHostname = publicHostname, Port = 4053, Roles = new[] { "admin", "driver" }, SeedNodes = seeds, }; private static string Seed(string host, int port = 4053) => $"akka.tcp://otopcua@{host}:{port}"; /// The shipped central-1 / central-2 shape: own address first, partner second. [Fact] public void Self_first_seed_order_passes() { var options = CentralNode("central-2", Seed("central-2"), Seed("central-1")); var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeFalse(result.FailureMessage); } /// /// The pre-2026-07-22 ordering — partner first — is the boot-alone outage gap, and must not /// start. This is the shape docker-dev's central-2 carried. /// [Fact] public void Peer_first_seed_order_fails() { var options = CentralNode("central-2", Seed("central-1"), Seed("central-2")); var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldNotBeNull().ShouldContain("SeedNodes"); result.FailureMessage.ShouldContain("must list this node itself first"); } /// /// THE conditional exemption. A driver-only site node lists only central-1 — it is not a /// seed at all, is never legitimately first, and must pass. An unconditional "self must be /// first" rule would refuse to boot every site node in the fleet. /// [Fact] public void Node_absent_from_its_own_seed_list_is_exempt() { var options = CentralNode("site-a-1", Seed("central-1")); var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeFalse(result.FailureMessage); } /// /// The bind address is NOT the node's identity. In docker-dev Cluster:Hostname is /// 0.0.0.0 and Cluster:PublicHostname is the container name — the value Akka puts /// in SelfAddress and therefore the only one a seed entry can match. A validator that /// compared against Hostname would find no match anywhere, silently exempt every node, /// and pass this misordered config. /// [Fact] public void Identity_is_the_public_hostname_not_the_bind_address() { var options = CentralNode("central-2", Seed("central-1"), Seed("central-2")); options.Hostname.ShouldBe("0.0.0.0", "the trap only exists when the bind address is a wildcard"); var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeTrue("matching on the 0.0.0.0 bind address would make this rule vacuous"); } /// /// With PublicHostname blank Akka advertises the bind hostname, so that is the identity to /// match — otherwise a loopback/bare-metal pair configured that way would be silently exempt. /// [Fact] public void Identity_falls_back_to_the_bind_hostname_when_no_public_hostname_is_set() { var options = CentralNode("central-2", Seed("node-a"), Seed("node-b")); options.Hostname = "node-b"; options.PublicHostname = string.Empty; var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldNotBeNull().ShouldContain("must list this node itself first"); } /// /// Host AND port: a two-node install sharing one hostname and differing only by port (a /// loopback/dev pair) is a real topology, and matching on host alone would clear the misordered /// node. /// [Fact] public void Self_match_compares_host_and_port() { var options = CentralNode("127.0.0.1", Seed("127.0.0.1", 4053), Seed("127.0.0.1", 4054)); options.Port = 4054; var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeTrue("this node is 127.0.0.1:4054, which is the SECOND seed"); } /// /// Positive control for the pair above: the same host on the same port really is a match, so the /// port comparison cannot be passing merely by never matching anything. /// [Fact] public void Self_match_on_the_same_host_and_port_passes() { var options = CentralNode("127.0.0.1", Seed("127.0.0.1", 4054), Seed("127.0.0.1", 4053)); options.Port = 4054; var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeFalse(result.FailureMessage); } /// /// An empty seed list is the single-node/dev default and says nothing about ordering — the rule /// has nothing to bind to and must not invent a failure. /// [Fact] public void Empty_seed_list_passes() { var options = CentralNode("central-1"); var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeFalse(result.FailureMessage); } /// /// A malformed seed URI is Akka's error to report, with Akka's wording. This rule must not /// crash on it, and must not turn a parse problem into a misleading "ordering" complaint — /// here the ordering is correct and only a later entry is unparseable. /// [Fact] public void Malformed_seed_entry_does_not_throw() { var options = CentralNode("central-1", Seed("central-1"), "not-an-akka-address"); var result = new AkkaClusterOptionsValidator().Validate(null, options); result.Failed.ShouldBeFalse(result.FailureMessage); } }