using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// Every failure this validator catches otherwise surfaces as an absence — a deployment /// that never arrives, an alarm ack that does nothing, a node whose ack is dropped without a /// trace. Absences are the hardest class of fault to attribute in the field, so the whole point /// of these tests is that the host refuses to start instead. /// /// /// Role-dependent facts run in because the validator /// resolves roles through RoleParser.ResolveEffectiveRoles, which reads /// OTOPCUA_ROLES when Cluster:Roles is empty — a process-wide env var, so the /// whole class is serialized against the other role-reading suites. /// [Collection(RoleEnvironmentCollection.Name)] public class MeshTransportOptionsValidatorTests { private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles) => Validate(o, new Dictionary(), roles); /// /// As , plus arbitrary configuration /// keys — the admin-side rule cross-reads ConfigServe:GrpcListenPort, which lives /// outside this options class entirely. /// private static ValidateOptionsResult Validate( MeshTransportOptions o, Dictionary extra, params string[] roles) { var pairs = new Dictionary(extra); for (var i = 0; i < roles.Length; i++) { pairs[$"Cluster:Roles:{i}"] = roles[i]; } var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build(); return new MeshTransportOptionsValidator(configuration, EffectiveRoles.Resolve(configuration)) .Validate(MeshTransportOptions.SectionName, o); } private static Dictionary ConfigServePort(string? value) => new() { ["ConfigServe:GrpcListenPort"] = value }; private static MeshTransportOptions WellFormedGrpc() => new() { Mode = MeshTransportOptions.ModeGrpc, GrpcPsk = "not-a-real-key", CentralGrpcEndpoints = ["http://central-1:4055", "https://central-2:4055"], }; [Fact] public void Default_options_are_valid() { // The default is Dps — the pre-Phase-2 transport. A node that never sets this section must // behave exactly as it did before, which is the whole premise of the dark switch. Validate(new MeshTransportOptions()).Succeeded.ShouldBeTrue(); } [Fact] public void Unknown_mode_fails() { var result = Validate(new MeshTransportOptions { Mode = "not-a-transport" }); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("not-a-transport"); } [Fact] public void Unknown_mode_message_names_both_accepted_values() { // An operator who mistypes the mode gets the full menu, not a partial one. var result = Validate(new MeshTransportOptions { Mode = "not-a-mode" }); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain(MeshTransportOptions.ModeDps); result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc); } [Fact] public void The_retired_ClusterClient_mode_fails_rather_than_falling_back_to_Dps() { // A node left carrying the deleted transport's mode value must be refused at boot. Silently // treating it as Dps would leave a split-topology node running, listening, and delivering // nothing across its mesh boundary — the exact absence this validator exists to prevent. var result = Validate(new MeshTransportOptions { Mode = "ClusterClient" }); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:Mode"); result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc); } [Fact] public void Mode_matching_is_case_insensitive() { Validate(new MeshTransportOptions { Mode = "dps" }).Succeeded.ShouldBeTrue(); } [Fact] public void Non_positive_contact_refresh_fails() { var result = Validate(new MeshTransportOptions { ContactRefreshSeconds = 0 }); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.ContactRefreshSeconds)); } // ---- Grpc mode (the cross-mesh command plane) ----------------------------------------------- [Fact] public void Grpc_is_an_accepted_mode() { Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); } [Fact] public void Grpc_mode_matching_is_case_insensitive() { var options = WellFormedGrpc(); options.Mode = "grpc"; Validate(options, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); } [Fact] public void Grpc_driver_node_without_a_psk_fails() { var options = WellFormedGrpc(); options.GrpcPsk = null; var result = Validate(options, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:GrpcPsk"); result.FailureMessage.ShouldContain("MeshTransport__GrpcPsk"); } [Fact] public void Grpc_driver_node_with_an_empty_psk_fails() { // Empty is the shape an unset environment variable actually produces, and it is exactly as // fatal as null — a blank bearer authenticates nothing. var options = WellFormedGrpc(); options.GrpcPsk = " "; Validate(options, "driver", "cluster-SITE-A").Failed.ShouldBeTrue(); } [Fact] public void The_psk_value_is_never_echoed_in_a_failure_message() { // The whole point of a validator that reads a secret is that its diagnostics are safe to // paste into a ticket. Naming the key is the contract; printing it is a leak. var options = WellFormedGrpc(); options.GrpcPsk = "super-secret-mesh-psk"; options.CentralGrpcEndpoints = ["central-1:4055"]; var result = Validate(options, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldNotContain("super-secret-mesh-psk"); } [Fact] public void Grpc_driver_node_without_central_endpoints_fails() { var options = WellFormedGrpc(); options.CentralGrpcEndpoints = []; var result = Validate(options, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints"); } [Fact] public void A_central_endpoint_that_is_not_an_http_url_fails() { var options = WellFormedGrpc(); options.CentralGrpcEndpoints = ["central-1:4055"]; var result = Validate(options, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("central-1:4055"); result.FailureMessage.ShouldContain("http://"); } [Fact] public void One_bad_central_endpoint_among_good_ones_still_fails() { var options = WellFormedGrpc(); options.CentralGrpcEndpoints = ["http://central-1:4055", "akka.tcp://otopcua@central-2:4053"]; var result = Validate(options, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("akka.tcp://otopcua@central-2:4053"); } [Fact] public void Grpc_driver_node_with_a_non_positive_dispatch_timeout_fails() { var options = WellFormedGrpc(); options.DispatchTimeoutSeconds = 0; var result = Validate(options, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:DispatchTimeoutSeconds"); } [Fact] public void Grpc_driver_node_with_a_non_positive_ack_timeout_fails() { var options = WellFormedGrpc(); options.AckTimeoutSeconds = -1; var result = Validate(options, "driver", "cluster-SITE-A"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("MeshTransport:AckTimeoutSeconds"); } [Fact] public void Grpc_admin_only_node_needs_none_of_the_node_side_keys() { // The node-side surface is what a DRIVER uses to dial central and post its ack. An admin-only // central node dials nodes instead and resolves per-cluster keys at runtime, so requiring // these here would make an admin node carry a PSK and an endpoint list it never reads. // (The ConfigServe port IS required of an admin node under Grpc — supplied here so this test // stays about the node-side keys and nothing else.) Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, ConfigServePort("4055"), "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); } [Fact] public void Grpc_admin_node_without_a_config_serve_listener_fails() { // Central hosts CentralCommandService on the ConfigServe h2c listener, so an unset port means // the service is never mapped and every node's ack has nowhere to land. Nothing errors at // runtime — the deployment simply times out naming nodes that applied perfectly, which is the // exact absence this validator exists to convert into a refusal to start. var result = Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, "admin", "cluster-MAIN"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("ConfigServe:GrpcListenPort"); result.FailureMessage.ShouldContain("ConfigServe__GrpcListenPort"); } [Fact] public void Grpc_admin_node_with_a_zero_config_serve_port_fails() { // 0 is the documented "disabled" value, not merely a missing key — it must fail exactly as an // absent one does, because it binds nothing just as surely. Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, ConfigServePort("0"), "admin", "cluster-MAIN").Failed.ShouldBeTrue(); } [Fact] public void Grpc_admin_node_with_an_unparseable_config_serve_port_fails() { // A typo'd port binds nothing, so it belongs on the same side of the line as an absent one. Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, ConfigServePort("40o55"), "admin", "cluster-MAIN").Failed.ShouldBeTrue(); } [Fact] public void Grpc_admin_node_with_a_config_serve_listener_passes() { Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }, ConfigServePort("4055"), "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); } [Fact] public void Grpc_driver_only_node_needs_no_config_serve_listener() { // A driver node dials central; it serves no central command endpoint at all, and requiring an // admin-side listener port of it would demand configuration it never reads. Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); } [Fact] public void Dps_admin_node_needs_no_config_serve_listener() { // The no-new-obligations invariant, admin side: a central node still on the default transport // publishes on DistributedPubSub and hosts no command endpoint. Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps }, "admin", "cluster-MAIN").Succeeded.ShouldBeTrue(); } [Fact] public void The_admin_role_is_also_read_from_OTOPCUA_ROLES() { // Same roles-source divergence as the driver case (Gitea #532), on the side that hosts the // endpoint: a central node configured only through the env var is still the node whose // listener every ack depends on. using (new EnvVarScope("OTOPCUA_ROLES", "admin,cluster-MAIN")) { Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }) .Failed.ShouldBeTrue(); } } [Fact] public void Grpc_role_less_node_needs_none_of_the_node_side_keys() { // Every in-process test host and every single-mesh node has no declared role. Requiring the // Grpc surface of them would turn a dark-switch value into a tree-wide config obligation. Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }).Succeeded.ShouldBeTrue(); } [Fact] public void Roles_come_from_OTOPCUA_ROLES_when_Cluster_Roles_is_empty() { // Roles-source divergence (Gitea #532): a node configured only through the env var is still a // driver node in Akka. A validator reading Cluster:Roles alone would exempt the exact node // that has to dial central, and its acks would be rejected unauthenticated at runtime. using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A")) { var options = WellFormedGrpc(); options.GrpcPsk = null; Validate(options).Failed.ShouldBeTrue(); } } [Fact] public void Dps_mode_requires_none_of_the_new_Grpc_keys() { // The no-new-obligations invariant: adding a third mode must not make any existing // deployment's configuration invalid. A Dps driver node that sets nothing new still passes. Validate( new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps }, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue(); } [Fact] public void Grpc_fused_admin_and_driver_node_must_satisfy_BOTH_surfaces() { // The central pair is fused (admin,driver,cluster-MAIN), so it is the one node shape that owes // both halves at once — and the two blocks are independent `if`s, not an `if`/`else if`. Were // they chained, a fused node would be graded on the driver surface only and its missing // ConfigServe listener would pass validation, leaving central with no place for any node's ack // to land. Asserted from BOTH sides so a future chaining in either order is caught. var missingNodeSide = new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }; var withPort = Validate(missingNodeSide, ConfigServePort("4055"), "driver", "admin", "cluster-MAIN"); withPort.Failed.ShouldBeTrue(); withPort.FailureMessage.ShouldContain("MeshTransport:GrpcPsk"); withPort.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints"); var missingAdminSide = Validate(WellFormedGrpc(), "driver", "admin", "cluster-MAIN"); missingAdminSide.Failed.ShouldBeTrue(); missingAdminSide.FailureMessage.ShouldContain("ConfigServe:GrpcListenPort"); // And the fully-configured fused node passes — the rule is "both", not "neither". Validate(WellFormedGrpc(), ConfigServePort("4055"), "driver", "admin", "cluster-MAIN") .Succeeded.ShouldBeTrue(); } [Fact] public void ClusterPsks_defaults_to_an_empty_case_insensitive_map() { // Production leaves the map empty and the runtime provider falls back to the Secrets store; // the rig populates it. Case-insensitivity matters because a ClusterId reaches this lookup // from three sources — a role suffix, a DB column and an env key — that do not agree on case. var options = new MeshTransportOptions(); options.ClusterPsks.ShouldBeEmpty(); options.ClusterPsks["MAIN"] = "rig-key"; options.ClusterPsks.ContainsKey("main").ShouldBeTrue(); } [Fact] public void ClusterPsks_stays_case_insensitive_after_binding_configuration() { // Asserting the comparer on a fresh instance proves nothing about the shape that reaches // production: the options binder could construct its own Dictionary and drop the comparer // silently, and the symptom would be a rig lookup that misses on a ClusterId spelled the way // the DB spells it. Bind for real and check the comparer survived. var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["MeshTransport:ClusterPsks:SITE-A"] = "rig-key", }) .Build(); var options = new MeshTransportOptions(); configuration.GetSection(MeshTransportOptions.SectionName).Bind(options); options.ClusterPsks["site-a"].ShouldBe("rig-key"); } }