using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests; /// /// CI-004: Tests that rejects the /// catastrophic misconfigurations the design doc warns against — a /// MinNrOfMembers other than 1, an unsupported split-brain strategy, /// empty seed nodes, and timings where the heartbeat is not below the /// failure-detection threshold. /// public class ClusterOptionsValidatorTests { private static ClusterOptions ValidOptions() => new() { SeedNodes = new List { "akka.tcp://scadabridge@node1:8081", "akka.tcp://scadabridge@node2:8081" }, SplitBrainResolverStrategy = "keep-oldest", StableAfter = TimeSpan.FromSeconds(15), HeartbeatInterval = TimeSpan.FromSeconds(2), FailureDetectionThreshold = TimeSpan.FromSeconds(10), MinNrOfMembers = 1, DownIfAlone = true }; [Fact] public void DefaultOptions_AreValid() { var result = new ClusterOptionsValidator().Validate(null, ValidOptions()); Assert.True(result.Succeeded, result.FailureMessage); } [Fact] public void MinNrOfMembers_NotOne_FailsValidation() { var options = ValidOptions(); options.MinNrOfMembers = 2; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("MinNrOfMembers", result.FailureMessage); } [Theory] [InlineData("keep-majority")] [InlineData("static-quorum")] [InlineData("nonsense")] public void UnsupportedSplitBrainStrategy_FailsValidation(string strategy) { var options = ValidOptions(); options.SplitBrainResolverStrategy = strategy; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("SplitBrainResolverStrategy", result.FailureMessage); } [Fact] public void EmptySeedNodes_FailsValidation() { var options = ValidOptions(); options.SeedNodes = new List(); var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("SeedNodes", result.FailureMessage); } [Fact] public void SingleSeed_WithoutAcknowledgement_Fails() { var options = ValidOptions(); options.SeedNodes = new List { "akka.tcp://scadabridge@h:8081" }; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("AllowSingleNodeCluster", result.FailureMessage); } [Fact] public void SingleSeed_WithAllowSingleNodeCluster_Passes() { // Review 01 [Low]: the shipped single-node artifact satisfied the 2-seed // rule with a phantom seed the node dials forever. An explicit // acknowledgement flag replaces the fiction. var options = ValidOptions(); options.SeedNodes = new List { "akka.tcp://scadabridge@h:8081" }; options.AllowSingleNodeCluster = true; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Succeeded, result.FailureMessage); } [Fact] public void EmptySeeds_FailsEvenWithFlag() { // Zero seeds is always invalid — the flag lowers the floor to 1, not 0. var options = ValidOptions(); options.SeedNodes = new List(); options.AllowSingleNodeCluster = true; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("SeedNodes", result.FailureMessage); } [Fact] public void SingleSeedNode_FailsValidation() { // CI-012: design doc says "both nodes are seed nodes" — a single-seed // configuration defeats the no-startup-ordering-dependency guarantee and // must be rejected by the contract owner's validator, not just by the // Host's startup validator. var options = ValidOptions(); options.SeedNodes = new List { "akka.tcp://scadabridge@node1:8081" }; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("SeedNodes", result.FailureMessage); Assert.Contains("2", result.FailureMessage); } [Fact] public void HeartbeatNotBelowFailureThreshold_FailsValidation() { var options = ValidOptions(); options.HeartbeatInterval = TimeSpan.FromSeconds(10); options.FailureDetectionThreshold = TimeSpan.FromSeconds(10); var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("HeartbeatInterval", result.FailureMessage); } [Fact] public void NonPositiveStableAfter_FailsValidation() { var options = ValidOptions(); options.StableAfter = TimeSpan.Zero; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("StableAfter", result.FailureMessage); } [Fact] public void DownIfAloneFalse_UnderKeepOldest_FailsValidation() { var options = ValidOptions(); options.SplitBrainResolverStrategy = "keep-oldest"; options.DownIfAlone = false; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("DownIfAlone", result.FailureMessage); } [Fact] public void AutoDownStrategy_Passes() { // Decision 2026-07-21: 'auto-down' is the supported availability-first // posture — either-node crash fails over; dual-active on a real partition // is the accepted trade. var options = ValidOptions(); options.SplitBrainResolverStrategy = "auto-down"; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Succeeded, result.FailureMessage); } [Fact] public void DownIfAloneFalse_UnderAutoDown_Passes() { // DownIfAlone is a keep-oldest knob; under auto-down it is inert and must // not block startup. var options = ValidOptions(); options.SplitBrainResolverStrategy = "auto-down"; options.DownIfAlone = false; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Succeeded, result.FailureMessage); } [Fact] public void Validate_AccumulatesAllFailures() { var options = new ClusterOptions { SeedNodes = new List(), SplitBrainResolverStrategy = "keep-majority", MinNrOfMembers = 2, StableAfter = TimeSpan.Zero, HeartbeatInterval = TimeSpan.FromSeconds(20), FailureDetectionThreshold = TimeSpan.FromSeconds(10) }; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("SeedNodes", result.FailureMessage); Assert.Contains("SplitBrainResolverStrategy", result.FailureMessage); Assert.Contains("MinNrOfMembers", result.FailureMessage); Assert.Contains("StableAfter", result.FailureMessage); Assert.Contains("HeartbeatInterval", result.FailureMessage); } // ---- BootstrapGuard timing validation (Gitea #33) ---- [Fact] public void BootstrapGuard_disabled_with_zero_timings_passes_validation() { // The knobs are inert when the guard is off, so a disabled guard must never block a boot — // even if the timing values are nonsense (0 here). var options = ValidOptions(); options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = false, PartnerProbeSeconds = 0, PartnerProbeIntervalMs = 0, ProbeConnectTimeoutMs = 0, }; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Succeeded, result.FailureMessage); } [Fact] public void BootstrapGuard_enabled_with_default_timings_passes_validation() { var options = ValidOptions(); options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true }; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Succeeded, result.FailureMessage); } [Fact] public void BootstrapGuard_enabled_with_nonpositive_probe_seconds_fails_validation() { // A zero PartnerProbeSeconds silently degrades the guard to "never wait, always conclude the // partner is dead" — the higher node forms alone immediately and re-opens the split. Reject it. var options = ValidOptions(); options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true, PartnerProbeSeconds = 0 }; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("PartnerProbeSeconds", result.FailureMessage); } [Fact] public void BootstrapGuard_enabled_with_nonpositive_interval_or_timeout_fails_validation() { var options = ValidOptions(); options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true, PartnerProbeIntervalMs = 0, ProbeConnectTimeoutMs = -1, }; var result = new ClusterOptionsValidator().Validate(null, options); Assert.True(result.Failed); Assert.Contains("PartnerProbeIntervalMs", result.FailureMessage); Assert.Contains("ProbeConnectTimeoutMs", result.FailureMessage); } }