Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs
T
Joseph Doherty 06c56d7719 fix(cluster,acl): OTOPCUA_ROLES fallback + one string-identity rule (#532, #533)
Two independent defects surfaced in review of #520 stage 3.

#532 — Cluster:Roles had no OTOPCUA_ROLES fallback, despite two doc sites
promising one. A node configured only through the env var — which drives ALL of
Program.cs's conditional wiring, so it reads as the authoritative knob — bound an
empty Roles array and joined the cluster carrying no Akka roles at all. It ran,
served, and logged nothing wrong, while central reported "no driver-role nodes
are Up" and every deployment to it timed out. Not hypothetical: the field fix on
wonder-app-vd03 was to hand-add Cluster__Roles__0/1 to a Windows service's
registry environment. Anyone reading the code to diagnose that box would have
concluded from the XML doc that the fallback was already applied.

AddOtOpcUaCluster now post-configures AkkaClusterOptions through the shared
RoleParser.ResolveEffectiveRoles, so BuildClusterOptions (Akka's own member
roles), ClusterRoleInfo, the ControlPlane singleton scope and
ConfigDbApiKeyStore's ClusterId all see one answer. Two sibling validators
(ConfigSource, Telemetry) read Cluster:Roles directly and were exempting the
exact node they exist for — an env-only driver-only node has no ConfigDb and
could sit on ConfigSource:Mode=Direct unchallenged. They resolve the same way now.

Also collapsed the four open-coded `Roles.FirstOrDefault(IsClusterRole)` +
ClusterIdFromRole copies into RoleParser.ClusterIdFromRoles. They had drifted:
three classified the cluster role ordinally, so a verbatim-bound `Cluster-SITE-A`
yielded a null ClusterId — and a null ClusterId applies NO cluster filter, which
made #528's cross-cluster over-grant guard fail OPEN. IsClusterRole is now
case-insensitive; the ClusterId suffix keeps its authored spelling, because
normalizing it would silently rename the Akka role of a running deployment.

#533 — three string-identity rules in one authorization path. AclScopeRef used
default (ordinal) equality while the trie builder filtered ClusterId and hashed
principals OrdinalIgnoreCase; the deploy gate's cross-cluster check was ordinal
too, which was the worse half — it could reject a whole deploy over a ClusterId
case difference the runtime would have accepted. It only worked because resolver
and builder were handed the same NodeAcl instances.

The rule is now stated once, on AclScopeRef, and applied throughout:
identifiers (ScopeId, ClusterId, Principal) compare OrdinalIgnoreCase; path
segments compare Ordinal. AclScopePathResolver re-keys its own maps rather than
documenting a comparer contract for callers, so the two independent producers —
EF at the gate, artifact JSON on the node — cannot diverge from the outside.
PermissionTrieNode.Children stays Ordinal: those are browse names, and Speed /
speed are two nodes that can coexist under one device.

Split PermissionTrieBuildDiagnosticReason.RetiredScopeKind out of
UnresolvedScope. A legacy FolderSegment row and a typo'd id had the same message
and different remedies.

Falsified: five mutations, each reddening only its own guard — remove the
PostConfigure (3 tests), revert IsClusterRole to ordinal (3), revert AclScopeRef
equality (1), revert the resolver's map comparer (6), revert the gate's
comparisons (2), remove the FolderSegment short-circuit (1).

Test isolation: OTOPCUA_ROLES is process-global and xUnit runs classes in
parallel, so the three validator suites that now resolve effective roles share a
non-parallel collection. Without it, cases passing no roles would intermittently
observe another class's env var.

Full solution build clean; full suite green apart from two pre-existing failures
confirmed identical on 3a6b2a1d (PrimaryGateFailoverTests, and
DriverProbeRegistrationTests expecting 10 probe types where 12 now register)
plus the AbLegacy integration suite, which needs a docker fixture.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 01:51:22 -04:00

179 lines
6.8 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
public sealed class RoleParserTests
{
/// <summary>Verifies that empty input yields an empty array.</summary>
/// <param name="raw">The raw input string to test (null, empty, or whitespace).</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_input_yields_empty_array(string? raw)
{
RoleParser.Parse(raw).ShouldBeEmpty();
}
/// <summary>Verifies that a single role 'admin' is parsed correctly.</summary>
[Fact]
public void Single_role_admin()
{
RoleParser.Parse("admin").ShouldBe(new[] { "admin" });
}
/// <summary>Verifies that two roles separated by comma are parsed correctly.</summary>
[Fact]
public void Two_roles_csv()
{
RoleParser.Parse("admin,driver").ShouldBe(new[] { "admin", "driver" });
}
/// <summary>Verifies that parser tolerates surrounding whitespace.</summary>
[Fact]
public void Whitespace_tolerant()
{
RoleParser.Parse(" admin , driver ").ShouldBe(new[] { "admin", "driver" });
}
/// <summary>Verifies that parser is case-insensitive and normalizes to lowercase.</summary>
[Fact]
public void Case_insensitive_normalizes_to_lower()
{
RoleParser.Parse("ADMIN,Driver").ShouldBe(new[] { "admin", "driver" });
}
/// <summary>Verifies that duplicate roles are removed.</summary>
[Fact]
public void Duplicate_roles_deduped()
{
RoleParser.Parse("admin,admin,driver").ShouldBe(new[] { "admin", "driver" });
}
/// <summary>Verifies that parser throws for unknown roles.</summary>
[Fact]
public void Unknown_role_throws()
{
Should.Throw<ArgumentException>(() => RoleParser.Parse("admin,master"));
}
/// <summary>Verifies that a single cluster-scoped role is accepted.</summary>
[Theory]
[InlineData("cluster-MAIN")]
[InlineData("cluster-SITE-A")]
public void Cluster_role_accepted(string role)
{
RoleParser.Parse(role).ShouldBe(new[] { role.ToLowerInvariant() });
}
/// <summary>Verifies that cluster roles mix freely with the fixed roles.</summary>
[Fact]
public void Fixed_and_cluster_roles_mix()
{
RoleParser.Parse("driver,cluster-SITE-A").ShouldBe(new[] { "driver", "cluster-site-a" });
}
/// <summary>Verifies that a cluster role with an empty ClusterId ("cluster-") is rejected.</summary>
[Fact]
public void Empty_cluster_id_throws()
{
Should.Throw<ArgumentException>(() => RoleParser.Parse("cluster-"));
}
/// <summary>Verifies IsClusterRole returns true for valid cluster roles and false otherwise.</summary>
[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);
}
/// <summary>Verifies ClusterIdFromRole extracts the ClusterId suffix.</summary>
[Fact]
public void ClusterIdFromRole_extracts_suffix()
{
RoleParser.ClusterIdFromRole("cluster-SITE-A").ShouldBe("SITE-A");
}
/// <summary>The suffix keeps the operator's spelling — consumers compare ClusterIds case-insensitively.</summary>
[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");
}
/// <summary>Verifies the ClusterId derivation picks the first cluster role, in configuration order.</summary>
[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);
}
/// <summary>Verifies a node with no cluster-scoped role derives a null ClusterId rather than throwing.</summary>
[Fact]
public void ClusterIdFromRoles_is_null_for_a_legacy_node()
{
RoleParser.ClusterIdFromRoles(["admin", "driver"]).ShouldBeNull();
RoleParser.ClusterIdFromRoles([]).ShouldBeNull();
RoleParser.ClusterIdFromRoles(null).ShouldBeNull();
}
/// <summary>Verifies the mixed-case derivation gap #532 named: classification folds case, the id does not.</summary>
[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");
}
/// <summary>Verifies Cluster:Roles wins outright over the environment fallback.</summary>
[Fact]
public void ResolveEffectiveRoles_prefers_the_configured_list()
{
RoleParser.ResolveEffectiveRoles(["admin"], "driver,cluster-SITE-A").ShouldBe(new[] { "admin" });
}
/// <summary>Verifies an empty or absent Cluster:Roles falls back to OTOPCUA_ROLES.</summary>
[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" });
}
/// <summary>Verifies neither source naming a role yields an empty list rather than a null or a throw.</summary>
[Fact]
public void ResolveEffectiveRoles_with_neither_source_is_empty()
{
RoleParser.ResolveEffectiveRoles(null, null).ShouldBeEmpty();
}
/// <summary>Verifies the three well-known role constants match the historical literal values.</summary>
[Fact]
public void Well_known_role_constants()
{
RoleParser.Admin.ShouldBe("admin");
RoleParser.Driver.ShouldBe("driver");
RoleParser.Dev.ShouldBe("dev");
RoleParser.ClusterRolePrefix.ShouldBe("cluster-");
}
}