using Microsoft.Extensions.Configuration; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// is the single role authority (archreview R3-04, 01/S-12 = 03/C5, /// 01/C-9): one factory, one precedence (Cluster:Roles non-empty wins, else /// OTOPCUA_ROLES, else empty), two-layer normalization (plain roles lower-cased, a cluster /// role's ClusterId suffix preserved verbatim), and validation on BOTH sources. /// [Collection(RoleEnvironmentCollection.Name)] public sealed class EffectiveRolesTests { // ---- Precedence matrix ------------------------------------------------------------------- [Fact] public void Configured_only_resolves_from_Configured_source() { var roles = EffectiveRoles.Resolve(["admin", "driver"], null); roles.Source.ShouldBe(RoleSource.Configured); roles.Gossiped.ShouldBe(new[] { "admin", "driver" }); } [Fact] public void Environment_only_resolves_from_Environment_source() { var roles = EffectiveRoles.Resolve(null, "admin,driver"); roles.Source.ShouldBe(RoleSource.Environment); roles.Gossiped.ShouldBe(new[] { "admin", "driver" }); } [Fact] public void Both_agree_resolves_from_Configured_source() { var roles = EffectiveRoles.Resolve(["driver"], "driver"); roles.Source.ShouldBe(RoleSource.Configured); roles.Gossiped.ShouldBe(new[] { "driver" }); } [Fact] public void Both_differ_Configured_wins_outright() { var roles = EffectiveRoles.Resolve(["admin"], "driver,cluster-SITE-A"); roles.Source.ShouldBe(RoleSource.Configured); roles.Gossiped.ShouldBe(new[] { "admin" }); } [Fact] public void Neither_source_resolves_empty_with_None_source() { var roles = EffectiveRoles.Resolve(null, null); roles.Source.ShouldBe(RoleSource.None); roles.Gossiped.ShouldBeEmpty(); } [Fact] public void Empty_configured_array_falls_back_to_the_environment() { var roles = EffectiveRoles.Resolve([], "driver"); roles.Source.ShouldBe(RoleSource.Environment); roles.Gossiped.ShouldBe(new[] { "driver" }); } // ---- Gossiped normalization: two-layer (choice 2) ---------------------------------------- [Fact] public void Configured_plain_role_is_trimmed_and_lower_cased_in_Gossiped() { var roles = EffectiveRoles.Resolve([" Driver "], null); roles.Gossiped.ShouldBe(new[] { "driver" }); } /// /// THE falsification control for choice 2: a cluster role's ClusterId suffix must survive /// UNCHANGED — lower-casing it would rename the Akka role of a running deployment /// (cluster-SITE-A vs cluster-site-a are different gossiped roles). /// [Fact] public void Configured_cluster_role_suffix_case_is_preserved_in_Gossiped() { var roles = EffectiveRoles.Resolve(["Cluster-SITE-A"], null); // The "cluster-" prefix normalizes (it is a plain marker), the "SITE-A" suffix does not. roles.Gossiped.ShouldBe(new[] { "cluster-SITE-A" }); } [Fact] public void Configured_cluster_role_with_already_lowercase_prefix_keeps_suffix_verbatim() { var roles = EffectiveRoles.Resolve(["cluster-Site-A"], null); roles.Gossiped.ShouldBe(new[] { "cluster-Site-A" }); } [Fact] public void Environment_cluster_role_suffix_IS_lowercased_unlike_Configured() { // Unchanged from RoleParser.Parse's existing (documented) behaviour: the two sources // normalize differently on purpose (RoleParser.ClusterIdFromRole's remarks). var roles = EffectiveRoles.Resolve(null, "driver,cluster-SITE-A"); roles.Gossiped.ShouldBe(new[] { "driver", "cluster-site-a" }); } // ---- Has*/HasAdmin/HasDriver case-insensitivity ------------------------------------------ [Theory] [InlineData("driver")] [InlineData("Driver")] [InlineData("DRIVER")] [InlineData(" driver ")] public void HasDriver_true_for_every_casing(string configuredRole) { EffectiveRoles.Resolve([configuredRole], null).HasDriver.ShouldBeTrue(); } [Fact] public void HasAdmin_false_when_admin_is_not_present() { EffectiveRoles.Resolve(["driver"], null).HasAdmin.ShouldBeFalse(); } [Fact] public void Has_is_case_insensitive_for_an_arbitrary_role() { var roles = EffectiveRoles.Resolve(["Admin", "Driver"], null); roles.Has("admin").ShouldBeTrue(); roles.Has("ADMIN").ShouldBeTrue(); roles.Has("driver").ShouldBeTrue(); roles.Has("dev").ShouldBeFalse(); } // ---- Unknown / invalid role from EITHER source throws, naming the key -------------------- [Fact] public void Unknown_role_from_the_configured_source_throws_naming_the_key() { var ex = Should.Throw(() => EffectiveRoles.Resolve(["Drivr"], null)); ex.Message.ShouldContain("Cluster:Roles"); ex.Message.ShouldContain("Drivr"); } [Fact] public void Unknown_role_from_the_environment_source_throws() { // The env path already validated via RoleParser.Parse — this pins that it still does. Should.Throw(() => EffectiveRoles.Resolve(null, "admin,master")); } // ---- ClusterRole / ClusterId --------------------------------------------------------------- [Fact] public void ClusterRole_and_ClusterId_are_null_for_a_legacy_node() { var roles = EffectiveRoles.Resolve(["admin", "driver"], null); roles.ClusterRole.ShouldBeNull(); roles.ClusterId.ShouldBeNull(); } [Fact] public void ClusterRole_and_ClusterId_resolve_for_a_split_topology_node() { var roles = EffectiveRoles.Resolve(["driver", "cluster-SITE-A"], null); roles.ClusterRole.ShouldBe("cluster-SITE-A"); roles.ClusterId.ShouldBe("SITE-A"); } // ---- Resolve(IConfiguration) overload ------------------------------------------------------ [Fact] public void Resolve_from_configuration_reads_Cluster_Roles() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Cluster:Roles:0"] = "driver", ["Cluster:Roles:1"] = "cluster-SITE-A", }) .Build(); var roles = EffectiveRoles.Resolve(configuration); roles.Source.ShouldBe(RoleSource.Configured); roles.Gossiped.ShouldBe(new[] { "driver", "cluster-SITE-A" }); } [Fact] public void Resolve_from_configuration_falls_back_to_the_environment() { using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver")) { var configuration = new ConfigurationBuilder().Build(); var roles = EffectiveRoles.Resolve(configuration); roles.Source.ShouldBe(RoleSource.Environment); roles.Gossiped.ShouldBe(new[] { "admin", "driver" }); } } // ---- DisagreementWarning (choice 5, T6) ---------------------------------------------------- [Fact] public void Both_sources_set_and_agreeing_has_no_warning() { var roles = EffectiveRoles.Resolve(["driver"], "driver"); roles.DisagreementWarning.ShouldBeNull(); } [Fact] public void Only_one_source_set_has_no_warning() { EffectiveRoles.Resolve(["driver"], null).DisagreementWarning.ShouldBeNull(); EffectiveRoles.Resolve(null, "driver").DisagreementWarning.ShouldBeNull(); EffectiveRoles.Resolve(null, null).DisagreementWarning.ShouldBeNull(); } [Fact] public void Both_sources_set_and_disagreeing_produces_a_warning_naming_both_keys() { var roles = EffectiveRoles.Resolve(["admin"], "driver,cluster-SITE-A"); roles.DisagreementWarning.ShouldNotBeNull(); roles.DisagreementWarning.ShouldContain("Cluster:Roles"); roles.DisagreementWarning.ShouldContain(RoleParser.RolesEnvVar); // Configured still wins outright — the warning is observability, not a behaviour change. roles.Gossiped.ShouldBe(new[] { "admin" }); roles.Source.ShouldBe(RoleSource.Configured); } [Fact] public void Both_sources_set_but_only_differing_by_case_do_not_warn() { // Same normalized set spelled differently in each source is NOT the "Cluster:Roles wins // unintentionally" shape the warning exists for. var roles = EffectiveRoles.Resolve(["Driver"], "driver"); roles.DisagreementWarning.ShouldBeNull(); } }