Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs
T
Joseph Doherty 2e6102d48f feat(cluster): opt-in Akka.Remote TLS block, default off (ScadaBridge#64)
ScadaBridge:Cluster:RemotingTls (ClusterRemotingTlsOptions) renders
akka.remote.dot-netty.tcp enable-ssl + ssl { certificate, mutual auth,
hostname validation } only when Enabled. Chain validation is always on
(no suppress-validation switch). Certificate is a .pfx path + password
(${secret:} reference) or an OS-store thumbprint.

ClusterOptionsValidator, TLS on only: exactly one certificate source, a
legal store location, and akka.ssl.tcp:// on every seed - Akka.Remote
advertises akka.ssl.tcp addresses once SSL is on, so an akka.tcp seed is
never answered and the join retries forever with no startup error.

ClusterBootstrapGuard.SeedPattern accepted only one scheme segment, so
akka.ssl.tcp seeds parsed as nothing and the guard would silently never
engage on a TLS pair; it now accepts any number.

Default HOCON is byte-identical: 12 rendered documents (3 downing
strategies x guard on/off x Central/Site) captured before the change and
diffed after, all identical; a permanent test pins that a disabled block
with every field set renders the default document. A real ActorSystem
booted on the emitted TLS document comes up as akka.ssl.tcp (mutation
check: forcing enable-ssl=false turns it red).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:06:01 -05:00

186 lines
8.6 KiB
C#

namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests;
/// <summary>
/// Unit tests for <see cref="ClusterBootstrapGuard"/> — the pure decision core of the
/// simultaneous-cold-start split-brain guard. The runtime probe + JoinSeedNodes wiring
/// lives in <c>ClusterBootstrapCoordinator</c> and is covered by the real-ActorSystem
/// <c>ClusterBootstrapCoordinatorTests</c> in the integration suite.
/// </summary>
public class ClusterBootstrapGuardTests
{
private const string A = "akka.tcp://scadabridge@scadabridge-site-a-a:8082";
private const string B = "akka.tcp://scadabridge@scadabridge-site-a-b:8082";
/// <summary>Verifies that the node with the lower address is the founder and needs no probe.</summary>
[Fact]
public void Lower_address_node_is_the_founder_and_needs_no_probe()
{
// scadabridge-site-a-a < scadabridge-site-a-b, so on node-a the guard makes it the founder.
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "scadabridge-site-a-a", 8082);
Assert.True(role.Applies);
Assert.True(role.IsFounder);
Assert.Equal(new[] { A, B }, role.SelfFirstOrder);
}
/// <summary>Verifies that the node with the higher address is not the founder and probes its partner.</summary>
[Fact]
public void Higher_address_node_is_not_the_founder_and_targets_the_partner_for_probing()
{
// On node-b the partner is node-a (the lower/founder).
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
Assert.True(role.Applies);
Assert.False(role.IsFounder);
Assert.Equal("scadabridge-site-a-a", role.PartnerHost);
Assert.Equal(8082, role.PartnerPort);
}
/// <summary>Verifies that the founder tie-break gives the same result regardless of seed order.</summary>
[Fact]
public void Tie_break_is_symmetric_regardless_of_seed_order()
{
// Both nodes must agree on who founds no matter how each lists its seeds.
var onLower = ClusterBootstrapGuard.Analyze(new[] { A, B }, "scadabridge-site-a-a", 8082);
var onHigher = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
Assert.True(onLower.IsFounder);
Assert.False(onHigher.IsFounder);
// Exactly one founder.
Assert.True(onLower.IsFounder ^ onHigher.IsFounder);
}
/// <summary>Verifies that the tie-break ignores case, so a casing mismatch cannot make both nodes founders.</summary>
[Fact]
public void Tie_break_is_case_insensitive_so_a_casing_mismatch_cannot_make_both_founders()
{
// The two sides' seed config differ only in host casing (review note 1): if the tie-break
// were case-sensitive, both could conclude they are the founder and re-open the split.
var lowerSelf = "akka.tcp://scadabridge@SITE-A-A:8082";
var higherSelf = "akka.tcp://scadabridge@site-a-b:8082";
var onLower = ClusterBootstrapGuard.Analyze(new[] { lowerSelf, higherSelf }, "SITE-A-A", 8082);
var onHigher = ClusterBootstrapGuard.Analyze(new[] { higherSelf, lowerSelf }, "site-a-b", 8082);
Assert.True(onLower.IsFounder ^ onHigher.IsFounder);
Assert.True(onLower.IsFounder); // "site-a-a" < "site-a-b" ordinal-ignore-case
}
/// <summary>Verifies that the higher node joins the founder when the partner is reachable.</summary>
[Fact]
public void Higher_node_joins_the_founder_when_partner_is_reachable()
{
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
// Reachable ⇒ peer-first ⇒ JoinSeedNodeProcess ⇒ joins the founder, never self-forms.
Assert.Equal(new[] { A, B }, ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: true)); // partner (node-a) first
}
/// <summary>Verifies that the higher node forms a cluster alone when the partner is unreachable.</summary>
[Fact]
public void Higher_node_forms_alone_when_partner_is_unreachable()
{
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "scadabridge-site-a-b", 8082);
// Unreachable after the window ⇒ partner is dead ⇒ self-first ⇒ cold-start-alone preserved.
Assert.Equal(new[] { B, A }, ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: false)); // self (node-b) first
}
/// <summary>Verifies that the guard does not apply to a single seed node.</summary>
[Fact]
public void Guard_does_not_apply_to_a_single_seed_node()
{
// A single-node install lists exactly one seed (itself) — not a pair, guard is inert.
var role = ClusterBootstrapGuard.Analyze(new[] { "akka.tcp://scadabridge@lone:8081" }, "lone", 8081);
Assert.False(role.Applies);
}
/// <summary>Verifies that the guard does not apply when the local node is absent from the two seeds.</summary>
[Fact]
public void Guard_does_not_apply_when_self_is_absent_from_the_two_seeds()
{
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "scadabridge-central-a", 8081);
Assert.False(role.Applies);
}
/// <summary>Verifies that the guard does not apply to three or more seeds.</summary>
[Fact]
public void Guard_does_not_apply_to_three_or_more_seeds()
{
var role = ClusterBootstrapGuard.Analyze(
new[] { A, B, "akka.tcp://scadabridge@scadabridge-site-a-c:8082" }, "scadabridge-site-a-a", 8082);
Assert.False(role.Applies);
}
/// <summary>Verifies that the guard does not apply when a seed address is malformed.</summary>
[Fact]
public void Guard_does_not_apply_when_a_seed_is_malformed()
{
var role = ClusterBootstrapGuard.Analyze(new[] { A, "not-a-seed" }, "scadabridge-site-a-a", 8082);
Assert.False(role.Applies);
}
/// <summary>Verifies that parsing a seed address extracts the host and port.</summary>
/// <param name="seed">The seed address to parse.</param>
/// <param name="expectedHost">The expected host.</param>
/// <param name="expectedPort">The expected port.</param>
[Theory]
[InlineData("akka.tcp://scadabridge@scadabridge-site-a-a:8082", "scadabridge-site-a-a", 8082)]
[InlineData("akka.tcp://scadabridge@10.0.0.5:8082/", "10.0.0.5", 8082)]
[InlineData(" akka.tcp://scadabridge@host.lan:1234 ", "host.lan", 1234)]
// Akka.Remote's scheme once dot-netty TLS is on (ScadaBridge#64) has TWO dot segments.
[InlineData("akka.ssl.tcp://scadabridge@scadabridge-site-a-a:8082", "scadabridge-site-a-a", 8082)]
public void TryParse_extracts_host_and_port(string seed, string expectedHost, int expectedPort)
{
Assert.True(ClusterBootstrapGuard.TryParse(seed, out var host, out var port));
Assert.Equal(expectedHost, host);
Assert.Equal(expectedPort, port);
}
/// <summary>Verifies that parsing rejects malformed seed addresses.</summary>
/// <param name="seed">The malformed seed address.</param>
[Theory]
[InlineData("")]
[InlineData("garbage")]
[InlineData("akka.tcp://scadabridge@host")] // no port
public void TryParse_rejects_malformed_seeds(string seed)
{
Assert.False(ClusterBootstrapGuard.TryParse(seed, out _, out _));
}
/// <summary>
/// Verifies the guard still engages for a TLS pair (ScadaBridge#64): with <c>akka.ssl.tcp</c>
/// seeds the old single-segment scheme pattern parsed nothing, so <c>Applies</c> was false and
/// the guard silently never ran.
/// </summary>
[Fact]
public void Analyze_engages_for_a_tls_pair()
{
const string tlsA = "akka.ssl.tcp://scadabridge@scadabridge-site-a-a:8082";
const string tlsB = "akka.ssl.tcp://scadabridge@scadabridge-site-a-b:8082";
var role = ClusterBootstrapGuard.Analyze(new[] { tlsB, tlsA }, "scadabridge-site-a-b", 8082);
Assert.True(role.Applies);
Assert.False(role.IsFounder);
Assert.Equal("scadabridge-site-a-a", role.PartnerHost);
Assert.Equal(new[] { tlsB, tlsA }, role.SelfFirstOrder);
}
/// <summary>Verifies that the port decides the tie-break when the hosts are equal.</summary>
[Fact]
public void Port_participates_in_the_tie_break_when_hosts_are_equal()
{
// Shared-host loopback pair (test rig): same host, different ports — port breaks the tie.
const string low = "akka.tcp://scadabridge@127.0.0.1:8081";
const string high = "akka.tcp://scadabridge@127.0.0.1:8082";
Assert.True(ClusterBootstrapGuard.Analyze(new[] { low, high }, "127.0.0.1", 8081).IsFounder);
Assert.False(ClusterBootstrapGuard.Analyze(new[] { high, low }, "127.0.0.1", 8082).IsFounder);
}
}