using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// A FetchAndCache node with no endpoint or no key does not error — it simply never /// applies a deployment, and the deploy times out naming a node that is otherwise healthy. These /// tests make that misconfiguration a loud host-start failure instead. /// public class ConfigSourceOptionsValidatorTests { private static ValidateOptionsResult Validate(ConfigSourceOptions o, params string[] roles) { var pairs = new Dictionary(); for (var i = 0; i < roles.Length; i++) { pairs[$"Cluster:Roles:{i}"] = roles[i]; } var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build(); return new ConfigSourceOptionsValidator(configuration).Validate(ConfigSourceOptions.SectionName, o); } private static ConfigSourceOptions ValidFetch() => new() { Mode = ConfigSourceOptions.ModeFetchAndCache, CentralFetchEndpoints = ["http://central-1:4055", "http://central-2:4055"], ApiKey = "shared-key", FetchTimeoutSeconds = 30, }; [Fact] public void Default_options_are_valid() { // The default is Direct with everything empty — the pre-Phase-3 behaviour. A node that never // sets this section must validate, which is the premise of the dark switch. Validate(new ConfigSourceOptions()).Succeeded.ShouldBeTrue(); } [Fact] public void Unknown_mode_fails() { var result = Validate(new ConfigSourceOptions { Mode = "cache" }); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("cache"); } [Fact] public void Mode_matching_is_case_insensitive() { Validate(new ConfigSourceOptions { Mode = "fetchandcache", CentralFetchEndpoints = ["http://central-1:4055"], ApiKey = "k", }).Succeeded.ShouldBeTrue(); } [Fact] public void FetchAndCache_with_no_endpoints_fails() { var o = ValidFetch(); o.CentralFetchEndpoints = []; var result = Validate(o); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.CentralFetchEndpoints)); } [Fact] public void FetchAndCache_with_a_non_http_endpoint_fails() { var o = ValidFetch(); o.CentralFetchEndpoints = ["central-1:4055"]; var result = Validate(o); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain("central-1:4055"); } [Fact] public void FetchAndCache_with_an_empty_key_fails() { var o = ValidFetch(); o.ApiKey = ""; var result = Validate(o); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.ApiKey)); } [Fact] public void FetchAndCache_with_a_non_positive_timeout_fails() { var o = ValidFetch(); o.FetchTimeoutSeconds = 0; var result = Validate(o); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain(nameof(ConfigSourceOptions.FetchTimeoutSeconds)); } [Fact] public void A_fully_populated_FetchAndCache_config_is_valid() { Validate(ValidFetch()).Succeeded.ShouldBeTrue(); } [Fact] public void Direct_ignores_the_empty_fetch_surface() { // Under Direct the endpoints/key are never read, so their emptiness must not fail validation — // otherwise every admin-only and every Direct node would be forced to carry fetch config. Validate(new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect, CentralFetchEndpoints = [], ApiKey = "", FetchTimeoutSeconds = 0, }).Succeeded.ShouldBeTrue(); } [Fact] public void Driver_only_node_on_Direct_fails() { // A driver-only node has no central ConfigDb connection (Phase 4) — Direct silently produces a // node that can never read its configuration. var result = Validate( new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect }, "driver"); result.Failed.ShouldBeTrue(); result.FailureMessage.ShouldContain(ConfigSourceOptions.ModeFetchAndCache); } [Fact] public void Fused_admin_and_driver_node_on_Direct_is_valid() { // A fused node holds the ConfigDb connection via its admin role, so Direct is still legitimate. Validate( new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect }, "admin", "driver").Succeeded.ShouldBeTrue(); } [Fact] public void Driver_only_node_on_FetchAndCache_is_valid() { Validate(ValidFetch(), "driver").Succeeded.ShouldBeTrue(); } [Fact] public void Admin_only_node_on_Direct_is_valid() { // An admin-only node has no driver role at all, so the driver-only rule does not apply to it. Validate( new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect }, "admin").Succeeded.ShouldBeTrue(); } }