using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; public sealed class RoleParserTests { /// Verifies that empty input yields an empty array. /// The raw input string to test (null, empty, or whitespace). [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void Empty_input_yields_empty_array(string? raw) { RoleParser.Parse(raw).ShouldBeEmpty(); } /// Verifies that a single role 'admin' is parsed correctly. [Fact] public void Single_role_admin() { RoleParser.Parse("admin").ShouldBe(new[] { "admin" }); } /// Verifies that two roles separated by comma are parsed correctly. [Fact] public void Two_roles_csv() { RoleParser.Parse("admin,driver").ShouldBe(new[] { "admin", "driver" }); } /// Verifies that parser tolerates surrounding whitespace. [Fact] public void Whitespace_tolerant() { RoleParser.Parse(" admin , driver ").ShouldBe(new[] { "admin", "driver" }); } /// Verifies that parser is case-insensitive and normalizes to lowercase. [Fact] public void Case_insensitive_normalizes_to_lower() { RoleParser.Parse("ADMIN,Driver").ShouldBe(new[] { "admin", "driver" }); } /// Verifies that duplicate roles are removed. [Fact] public void Duplicate_roles_deduped() { RoleParser.Parse("admin,admin,driver").ShouldBe(new[] { "admin", "driver" }); } /// Verifies that parser throws for unknown roles. [Fact] public void Unknown_role_throws() { Should.Throw(() => RoleParser.Parse("admin,master")); } /// Verifies that a single cluster-scoped role is accepted. [Theory] [InlineData("cluster-MAIN")] [InlineData("cluster-SITE-A")] public void Cluster_role_accepted(string role) { RoleParser.Parse(role).ShouldBe(new[] { role.ToLowerInvariant() }); } /// Verifies that cluster roles mix freely with the fixed roles. [Fact] public void Fixed_and_cluster_roles_mix() { RoleParser.Parse("driver,cluster-SITE-A").ShouldBe(new[] { "driver", "cluster-site-a" }); } /// Verifies that a cluster role with an empty ClusterId ("cluster-") is rejected. [Fact] public void Empty_cluster_id_throws() { Should.Throw(() => RoleParser.Parse("cluster-")); } /// Verifies IsClusterRole returns true for valid cluster roles and false otherwise. [Theory] [InlineData("cluster-MAIN", true)] [InlineData("cluster-SITE-A", true)] [InlineData("cluster-", false)] [InlineData("driver", false)] [InlineData("admin", false)] [InlineData("clusterx", false)] // Gitea #532: the prefix test is case-insensitive. Cluster:Roles binds verbatim, so an operator's // 'Cluster-SITE-A' used to classify as an ordinary role — the node then gossiped a cluster-scoped // role while resolving a null ClusterId for itself. [InlineData("Cluster-SITE-A", true)] [InlineData("CLUSTER-main", true)] [InlineData("Cluster-", false)] public void IsClusterRole_classifies_correctly(string role, bool expected) { RoleParser.IsClusterRole(role).ShouldBe(expected); } /// Verifies ClusterIdFromRole extracts the ClusterId suffix. [Fact] public void ClusterIdFromRole_extracts_suffix() { RoleParser.ClusterIdFromRole("cluster-SITE-A").ShouldBe("SITE-A"); } /// The suffix keeps the operator's spelling — consumers compare ClusterIds case-insensitively. [Fact] public void ClusterIdFromRole_preserves_the_authored_case() { // Normalizing here would silently rename the Akka role of a running deployment (the docker-dev // rig configures cluster-MAIN / cluster-SITE-A verbatim), so the case is carried, not folded. RoleParser.ClusterIdFromRole("Cluster-SITE-A").ShouldBe("SITE-A"); } /// Verifies the ClusterId derivation picks the first cluster role, in configuration order. [Fact] public void ClusterIdFromRoles_takes_the_first_cluster_role() { RoleParser.ClusterIdFromRoles(["driver", "cluster-SITE-A", "cluster-SITE-B"]).ShouldBe("SITE-A"); RoleParser.ClusterRoleFromRoles(["driver", "cluster-SITE-A"]).ShouldBe("cluster-SITE-A"); RoleParser.ClusterRolesIn(["driver", "cluster-SITE-A", "cluster-SITE-B"]).Count.ShouldBe(2); } /// Verifies a node with no cluster-scoped role derives a null ClusterId rather than throwing. [Fact] public void ClusterIdFromRoles_is_null_for_a_legacy_node() { RoleParser.ClusterIdFromRoles(["admin", "driver"]).ShouldBeNull(); RoleParser.ClusterIdFromRoles([]).ShouldBeNull(); RoleParser.ClusterIdFromRoles(null).ShouldBeNull(); } /// Verifies the mixed-case derivation gap #532 named: classification folds case, the id does not. [Fact] public void ClusterIdFromRoles_resolves_a_mixed_case_cluster_role() { // Before #532 this returned null, and a null ClusterId applies NO cluster filter — so // ConfigDbApiKeyStore's cross-cluster guard failed open on exactly this configuration. RoleParser.ClusterIdFromRoles(["driver", "Cluster-SITE-A"]).ShouldBe("SITE-A"); } /// Verifies Cluster:Roles wins outright over the environment fallback. [Fact] public void ResolveEffectiveRoles_prefers_the_configured_list() { RoleParser.ResolveEffectiveRoles(["admin"], "driver,cluster-SITE-A").ShouldBe(new[] { "admin" }); } /// Verifies an empty or absent Cluster:Roles falls back to OTOPCUA_ROLES. [Fact] public void ResolveEffectiveRoles_falls_back_to_the_environment() { RoleParser.ResolveEffectiveRoles([], "driver,cluster-SITE-A") .ShouldBe(new[] { "driver", "cluster-site-a" }); RoleParser.ResolveEffectiveRoles(null, "admin").ShouldBe(new[] { "admin" }); } /// Verifies neither source naming a role yields an empty list rather than a null or a throw. [Fact] public void ResolveEffectiveRoles_with_neither_source_is_empty() { RoleParser.ResolveEffectiveRoles(null, null).ShouldBeEmpty(); } /// Verifies the three well-known role constants match the historical literal values. [Fact] public void Well_known_role_constants() { RoleParser.Admin.ShouldBe("admin"); RoleParser.Driver.ShouldBe("driver"); RoleParser.Dev.ShouldBe("dev"); RoleParser.ClusterRolePrefix.ShouldBe("cluster-"); } }