using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// A split-topology node (one carrying a cluster-{ClusterId} role) left on a DPS transport /// mode does not error — the transport simply delivers nothing across the mesh boundary, and the /// symptom is a deployment that never applies or telemetry that never arrives. These tests make /// that misconfiguration a loud host-start failure instead. A node with no cluster-role is exempt /// (legacy / single-mesh / test), and the validator is a no-op for it. /// public class SplitTopologyTransportValidatorTests { private static ValidateOptionsResult Validate( string? meshMode, string? telemetryMode, string? telemetryDialMode, params string[] roles) { var pairs = new Dictionary(); for (var i = 0; i < roles.Length; i++) { pairs[$"Cluster:Roles:{i}"] = roles[i]; } if (meshMode is not null) pairs["MeshTransport:Mode"] = meshMode; if (telemetryMode is not null) pairs["Telemetry:Mode"] = telemetryMode; if (telemetryDialMode is not null) pairs["TelemetryDial:Mode"] = telemetryDialMode; var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build(); // The validated options instance is never read (all four values come from IConfiguration), so a // default MeshTransportOptions is a fine stand-in. return new SplitTopologyTransportValidator(configuration) .Validate(MeshTransportOptions.SectionName, new MeshTransportOptions()); } [Fact] public void Cluster_role_driver_node_on_Dps_mesh_transport_fails() { // Red-before-green anchor: a split-topology node on DistributedPubSub cannot deliver commands. var result = Validate( meshMode: MeshTransportOptions.ModeDps, telemetryMode: TelemetryOptions.ModeGrpc, telemetryDialMode: null, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:Mode"); result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient); result.FailureMessage.ShouldContain("SITE-A"); } [Fact] public void Cluster_role_driver_node_fully_configured_passes() { Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: TelemetryOptions.ModeGrpc, telemetryDialMode: null, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); } [Fact] public void Cluster_role_admin_node_fully_configured_passes() { Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: null, telemetryDialMode: TelemetryDialOptions.ModeGrpc, "admin", "cluster-SITE-A").Succeeded.ShouldBeTrue(); } [Fact] public void Cluster_role_driver_node_on_Dps_telemetry_fails() { var result = Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: TelemetryOptions.ModeDps, telemetryDialMode: null, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("Telemetry:Mode"); result.FailureMessage.ShouldContain(TelemetryOptions.ModeGrpc); } [Fact] public void Cluster_role_admin_node_on_Dps_telemetry_dial_fails() { var result = Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: null, telemetryDialMode: TelemetryDialOptions.ModeDps, "admin", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("TelemetryDial:Mode"); result.FailureMessage.ShouldContain(TelemetryDialOptions.ModeGrpc); } [Fact] public void Fused_cluster_role_node_needs_all_three_to_pass() { Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: TelemetryOptions.ModeGrpc, telemetryDialMode: TelemetryDialOptions.ModeGrpc, "admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue(); } [Fact] public void Fused_cluster_role_node_missing_telemetry_grpc_fails() { var result = Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: TelemetryOptions.ModeDps, telemetryDialMode: TelemetryDialOptions.ModeGrpc, "admin", "driver", "cluster-MAIN"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("Telemetry:Mode"); } [Fact] public void Fused_cluster_role_node_missing_telemetry_dial_grpc_fails() { var result = Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: TelemetryOptions.ModeGrpc, telemetryDialMode: TelemetryDialOptions.ModeDps, "admin", "driver", "cluster-MAIN"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("TelemetryDial:Mode"); } [Fact] public void Non_cluster_role_node_on_all_Dps_passes() { // The exemption that keeps the validator a no-op for every legacy / single-mesh / test node: // no cluster-{ClusterId} role means the split-topology rule does not apply at all. Validate( meshMode: MeshTransportOptions.ModeDps, telemetryMode: TelemetryOptions.ModeDps, telemetryDialMode: TelemetryDialOptions.ModeDps, "admin", "driver").Succeeded.ShouldBeTrue(); } [Fact] public void Node_with_no_roles_on_all_Dps_passes() { // Absolute default: nothing declared, nothing to enforce. Validate( meshMode: MeshTransportOptions.ModeDps, telemetryMode: TelemetryOptions.ModeDps, telemetryDialMode: TelemetryDialOptions.ModeDps).Succeeded.ShouldBeTrue(); } [Fact] public void Unset_modes_on_a_cluster_role_node_fail_as_the_Dps_default() { // A split node that never sets the mode keys inherits the Dps defaults — which the split // topology cannot use. Leaving them unset must fail exactly as setting them to Dps would. var result = Validate( meshMode: null, telemetryMode: null, telemetryDialMode: null, "admin", "driver", "cluster-MAIN"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:Mode"); } [Fact] public void Mode_matching_is_case_insensitive() { Validate( meshMode: "clusterclient", telemetryMode: "grpc", telemetryDialMode: "grpc", "admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue(); } [Fact] public void Cluster_role_from_OTOPCUA_ROLES_only_on_Dps_fails() { // Roles-source divergence: Cluster:Roles is empty, so the node's effective Akka roles come from // OTOPCUA_ROLES (via RoleParser.Parse) exactly as Program.cs resolves them. A validator that // read only Cluster:Roles would see no cluster role and pass silently on Dps — the documented // production-incident shape. using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A")) { var pairs = new Dictionary { ["MeshTransport:Mode"] = MeshTransportOptions.ModeDps }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build(); var result = new SplitTopologyTransportValidator(configuration) .Validate(MeshTransportOptions.SectionName, new MeshTransportOptions()); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:Mode"); } } [Fact] public void Cluster_role_from_OTOPCUA_ROLES_is_ignored_when_Cluster_Roles_is_set() { // When Cluster:Roles is populated it wins outright — the OTOPCUA_ROLES fallback is not consulted, // mirroring AkkaClusterOptions.Roles. A non-cluster Cluster:Roles therefore stays exempt even if // a stray OTOPCUA_ROLES carries a cluster role. using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A")) { Validate( meshMode: MeshTransportOptions.ModeDps, telemetryMode: MeshTransportOptions.ModeDps, telemetryDialMode: MeshTransportOptions.ModeDps, "admin", "driver").Succeeded.ShouldBeTrue(); } } [Fact] public void Mixed_case_cluster_and_role_names_on_Dps_fail() { // Case-sensitivity: the Cluster:Roles bind path is verbatim, so mixed-case entries must be // normalized here or they slip past the ordinal IsClusterRole / driver checks and pass silently. var result = Validate( meshMode: MeshTransportOptions.ModeDps, telemetryMode: TelemetryOptions.ModeGrpc, telemetryDialMode: null, "Driver", "Cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:Mode"); } [Fact] public void Cluster_role_node_with_neither_admin_nor_driver_only_needs_ClusterClient() { // A cluster-role node that is neither admin nor driver has no telemetry surface to constrain — // only the always-on MeshTransport=ClusterClient rule applies. Telemetry/TelemetryDial left on // their Dps defaults must not fail it. Validate( meshMode: MeshTransportOptions.ModeClusterClient, telemetryMode: null, telemetryDialMode: null, "cluster-SITE-A").Succeeded.ShouldBeTrue(); } /// Sets an environment variable for the scope's lifetime and restores its prior value on dispose. private sealed class EnvVarScope : IDisposable { private readonly string _name; private readonly string? _previous; public EnvVarScope(string name, string? value) { _name = name; _previous = Environment.GetEnvironmentVariable(name); Environment.SetEnvironmentVariable(name, value); } public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); } }