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
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #532 — <c>AkkaClusterOptions.Roles</c> must actually apply the <c>OTOPCUA_ROLES</c> fallback
|
||||
/// its own XML doc has always described.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These go through <c>AddOtOpcUaCluster</c> + a real service provider rather than calling
|
||||
/// <c>RoleParser.ResolveEffectiveRoles</c> directly, because the defect was never in the parsing —
|
||||
/// it was that nothing <em>called</em> it on the way to the bound options. A unit test of the
|
||||
/// helper alone would have passed against the broken tree.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The consequence being pinned: <c>BuildClusterOptions</c> feeds <c>Roles</c> straight to Akka as
|
||||
/// the node's gossiped member roles. Empty there means a node that joins, comes Up, serves OPC UA
|
||||
/// — and is invisible to <c>RoleLeader</c>, to <c>MembersWithRole("driver")</c>, to every
|
||||
/// role-scoped singleton, and to <c>ConfigPublishCoordinator</c>'s expected-ack set. The field
|
||||
/// symptom was "no driver-role nodes are Up" with nothing wrong in the node's own logs
|
||||
/// (<c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(RoleEnvironmentCollection.Name)]
|
||||
public class AkkaClusterOptionsRoleFallbackTests
|
||||
{
|
||||
private static AkkaClusterOptions Bind(params (string Key, string Value)[] settings)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(settings.ToDictionary(s => s.Key, s => (string?)s.Value))
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOtOpcUaCluster(configuration);
|
||||
|
||||
return services.BuildServiceProvider().GetRequiredService<IOptions<AkkaClusterOptions>>().Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roles_fall_back_to_the_environment_when_Cluster_Roles_is_empty()
|
||||
{
|
||||
using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver"))
|
||||
{
|
||||
Bind().Roles.ShouldBe(new[] { "admin", "driver" });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Akka_member_roles_carry_the_fallback()
|
||||
{
|
||||
// The end that matters. This is the value handed to Akka.Cluster.Hosting, so an empty array here
|
||||
// is a role-less member — the exact production shape #532 describes.
|
||||
using (new EnvVarScope(RoleParser.RolesEnvVar, "driver"))
|
||||
{
|
||||
ServiceCollectionExtensions.BuildClusterOptions(Bind()).Roles.ShouldBe(new[] { "driver" });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_roles_win_over_the_environment()
|
||||
{
|
||||
// Negative control for the fallback: without this, a PostConfigure that unconditionally
|
||||
// overwrote Roles from the environment would pass the test above and silently ignore every
|
||||
// deployment's explicit Cluster:Roles.
|
||||
using (new EnvVarScope(RoleParser.RolesEnvVar, "admin,driver,cluster-SITE-B"))
|
||||
{
|
||||
Bind(("Cluster:Roles:0", "driver"), ("Cluster:Roles:1", "cluster-SITE-A")).Roles
|
||||
.ShouldBe(new[] { "driver", "cluster-SITE-A" });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_roles_from_either_source_stays_empty()
|
||||
{
|
||||
using (new EnvVarScope(RoleParser.RolesEnvVar, null))
|
||||
{
|
||||
Bind().Roles.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_cluster_role_from_the_environment_reaches_the_ClusterId_derivation()
|
||||
{
|
||||
// The over-grant fix in ConfigDbApiKeyStore reads its ClusterId off these options, and a null
|
||||
// ClusterId applies no cluster filter at all — so an env-only node failed OPEN before #532.
|
||||
using (new EnvVarScope(RoleParser.RolesEnvVar, "driver,cluster-SITE-A"))
|
||||
{
|
||||
RoleParser.ClusterIdFromRoles(Bind().Roles).ShouldBe("site-a");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Collection(RoleEnvironmentCollection.Name)]
|
||||
public class ConfigSourceOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(ConfigSourceOptions o, params string[] roles)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
/// <summary>Sets an environment variable for the scope's lifetime and restores its prior value on dispose.</summary>
|
||||
public sealed class EnvVarScope : IDisposable
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly string? _previous;
|
||||
|
||||
/// <summary>Sets <paramref name="name"/> to <paramref name="value"/> until disposed.</summary>
|
||||
/// <param name="name">The environment variable name.</param>
|
||||
/// <param name="value">The value to set, or null to unset.</param>
|
||||
public EnvVarScope(string name, string? value)
|
||||
{
|
||||
_name = name;
|
||||
_previous = Environment.GetEnvironmentVariable(name);
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes every test class that reads or writes the <c>OTOPCUA_ROLES</c> process environment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Load-bearing since Gitea #532.</b> Environment variables are process-global while xUnit runs
|
||||
/// test classes in an assembly <i>concurrently</i>, so a class that sets <c>OTOPCUA_ROLES</c> is
|
||||
/// visible to every class running beside it. That was harmless while
|
||||
/// <c>SplitTopologyTransportValidator</c> was the only code applying the fallback — its own tests both
|
||||
/// set and read the variable. It stopped being harmless when <c>ConfigSourceOptionsValidator</c> and
|
||||
/// <c>TelemetryOptionsValidator</c> started resolving effective roles the same way: their cases that
|
||||
/// pass NO roles would intermittently observe another class's <c>driver,cluster-SITE-A</c> and change
|
||||
/// verdict. Any test touching that variable, directly or through
|
||||
/// <c>RoleParser.ResolveEffectiveRoles</c>, belongs in this collection.
|
||||
/// </remarks>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class RoleEnvironmentCollection
|
||||
{
|
||||
/// <summary>The collection name to put on <c>[Collection]</c>.</summary>
|
||||
public const string Name = "role-environment";
|
||||
}
|
||||
@@ -89,6 +89,12 @@ public sealed class RoleParserTests
|
||||
[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);
|
||||
@@ -101,6 +107,65 @@ public sealed class RoleParserTests
|
||||
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()
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
/// that misconfiguration a loud host-start failure instead. A node with no cluster-role is exempt
|
||||
/// (legacy / single-mesh / test), and the validator is a no-op for it.
|
||||
/// </summary>
|
||||
[Collection(RoleEnvironmentCollection.Name)]
|
||||
public class SplitTopologyTransportValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(
|
||||
@@ -248,20 +249,4 @@ public class SplitTopologyTransportValidatorTests
|
||||
telemetryDialMode: null,
|
||||
"cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Sets an environment variable for the scope's lifetime and restores its prior value on dispose.</summary>
|
||||
private sealed class EnvVarScope : IDisposable
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly string? _previous;
|
||||
|
||||
public EnvVarScope(string name, string? value)
|
||||
{
|
||||
_name = name;
|
||||
_previous = Environment.GetEnvironmentVariable(name);
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
/// telemetry, and whoever is watching sees nothing with no stack trace to point at. These tests
|
||||
/// make that misconfiguration a loud host-start failure instead.
|
||||
/// </summary>
|
||||
[Collection(RoleEnvironmentCollection.Name)]
|
||||
public class TelemetryOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(TelemetryOptions o, params string[] roles)
|
||||
@@ -112,6 +113,7 @@ public class TelemetryOptionsValidatorTests
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="TelemetryOptionsValidatorTests"/> for the central dial-side options.
|
||||
/// </summary>
|
||||
[Collection(RoleEnvironmentCollection.Name)]
|
||||
public class TelemetryDialOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(TelemetryDialOptions o) =>
|
||||
|
||||
@@ -245,6 +245,46 @@ public sealed class DraftValidatorNodeAclScopeTests
|
||||
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #533 — the gate's own identifier comparisons follow the same rule as the resolver's:
|
||||
/// identifiers compare <c>OrdinalIgnoreCase</c>. Both were ordinal, which put the gate and the
|
||||
/// runtime into disagreement in the direction that costs the most: the deploy gate would reject a
|
||||
/// whole configuration as cross-cluster over a <c>ClusterId</c> case difference that
|
||||
/// <c>PermissionTrieBuilder</c> — which has always matched case-insensitively — would have accepted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_case_drifted_ClusterId_is_not_reported_as_cross_cluster()
|
||||
{
|
||||
var acl = Acl(NodeAclScopeKind.RawDevice, "dev-1");
|
||||
acl.ClusterId = Own.ToUpperInvariant();
|
||||
|
||||
var errors = DraftValidator.Validate(Draft(acl));
|
||||
|
||||
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
|
||||
errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same rule applied to the <c>ScopeId</c> lookups, so a row this gate passes is a row
|
||||
/// <c>AclScopePathResolver</c> resolves — the property the gate's whole value rests on. A gate
|
||||
/// stricter than the resolver blocks deploys that would have worked; a gate looser than it lets
|
||||
/// through the silently-dropped grants it exists to catch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_case_drifted_ScopeId_resolves_at_the_gate_too()
|
||||
{
|
||||
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "DEV-1")))
|
||||
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Negative control: relaxing case must not stop a genuinely foreign cluster from being caught.</summary>
|
||||
[Fact]
|
||||
public void A_genuinely_different_ClusterId_is_still_cross_cluster()
|
||||
{
|
||||
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2")))
|
||||
.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster");
|
||||
}
|
||||
|
||||
/// <summary>The error must name both clusters — which one the grant is in and which one owns the
|
||||
/// entity — because "re-author under the other cluster" and "repoint at the local entity" are different
|
||||
/// fixes and the operator picks between them from this message alone.</summary>
|
||||
|
||||
@@ -258,6 +258,66 @@ public sealed class AclScopePathResolverTests
|
||||
resolved.ShouldContainKey(new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NodeAclScopeKind.RawFolder, "rf-line1")]
|
||||
[InlineData(NodeAclScopeKind.RawDevice, "dv-1")]
|
||||
[InlineData(NodeAclScopeKind.Equipment, "eq-MIXER")]
|
||||
[InlineData(NodeAclScopeKind.Tag, "TAG-SPEED")]
|
||||
public void A_case_drifted_scope_id_still_resolves(NodeAclScopeKind kind, string scopeId)
|
||||
{
|
||||
// Gitea #533 — the identifier half of the rule. ScopeId is an opaque handle, and it is operator
|
||||
// free-text in the ACL editor until the stage-7 picker lands, so two spellings always mean one
|
||||
// entity. Ordinal lookups made a case slip drop EVERY grant on that scope: fail-closed and
|
||||
// diagnosed, but indistinguishable from a genuine typo and invisible to the author.
|
||||
Fixture().ResolveOne(kind, scopeId).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_case_drifted_scope_id_resolves_to_the_SAME_paths_as_the_authored_one()
|
||||
{
|
||||
// Stronger than "resolves to something": the paths must be identical, because the segments are
|
||||
// entity NAMES and must keep their own spelling. A lookup that folded case into the emitted path
|
||||
// would produce an ACL path that no longer matches the RawPath forming the node's NodeId.
|
||||
var drifted = Fixture().ResolveOne(NodeAclScopeKind.RawDevice, "dv-1").ShouldHaveSingleItem();
|
||||
var authored = Fixture().ResolveOne(NodeAclScopeKind.RawDevice, "DV-1").ShouldHaveSingleItem();
|
||||
|
||||
drifted.Segments.ShouldBe(authored.Segments);
|
||||
drifted.Segments.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_case_drifted_scope_ref_hits_the_same_dictionary_entry()
|
||||
{
|
||||
// The key type's own equality, which is what the trie builder looks the resolver's output up by.
|
||||
// Resolver and builder can be fed from different snapshots (EF at the deploy gate, artifact JSON
|
||||
// on the node) — the whole reason this resolver is input-shape agnostic — so their agreement
|
||||
// cannot rest on the two sides holding the same string instances.
|
||||
var resolved = Fixture().Resolve([Acl("acl-1", NodeAclScopeKind.Equipment, "EQ-mixer")]);
|
||||
|
||||
resolved.ShouldContainKey(new AclScopeRef(NodeAclScopeKind.Equipment, "eq-MIXER"));
|
||||
new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")
|
||||
.ShouldBe(new AclScopeRef(NodeAclScopeKind.Equipment, "eq-mixer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Case_insensitive_ids_do_NOT_merge_different_scope_kinds()
|
||||
{
|
||||
// Negative control for the widening: relaxing the id comparison must not relax the kind, or the
|
||||
// wrong-node authorization the composite key exists to prevent comes back through the case door.
|
||||
new AclScopeRef(NodeAclScopeKind.RawFolder, "dup")
|
||||
.ShouldNotBe(new AclScopeRef(NodeAclScopeKind.UnsArea, "DUP"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_case_drifted_tag_id_still_reaches_every_UNS_projection()
|
||||
{
|
||||
// The reference scan is a linear compare rather than a dictionary lookup, so it needed the rule
|
||||
// applied separately — and this is the path where missing it is worst: a Tag grant that resolved
|
||||
// its raw path but none of its UNS paths would leave the tag writable through the UNS NodeId it
|
||||
// silently skipped.
|
||||
Fixture().ResolveOne(NodeAclScopeKind.Tag, "tag-speed").Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
private static NodeAcl Acl(string id, NodeAclScopeKind kind, string? scopeId) => new()
|
||||
{
|
||||
NodeAclId = id,
|
||||
|
||||
@@ -349,4 +349,64 @@ public sealed class PermissionTrieBuilderTests
|
||||
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||
trie.Root.Grants.ShouldBeEmpty("an empty path list must not become a cluster-wide grant");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #533 — the retired <see cref="NodeAclScopeKind.FolderSegment"/> gets its own reason. It
|
||||
/// used to arrive as <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/>, which told
|
||||
/// an operator holding a legacy v2 row to go hunting for a typo in a <c>ScopeId</c> that was never
|
||||
/// the problem — the kind itself has no v3 entity behind it and can never resolve. The two have
|
||||
/// different remedies, so they need different reasons.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_Retired_FolderSegment_Row_Emits_RetiredScopeKind()
|
||||
{
|
||||
var row = Row("cn=ops", NodeAclScopeKind.FolderSegment, "seg-1", NodePermissions.Read);
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
var trie = PermissionTrieBuilder.Build(
|
||||
"c1", 1, [row], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
|
||||
|
||||
diagnostics.Count.ShouldBe(1);
|
||||
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.RetiredScopeKind);
|
||||
diagnostics[0].ScopeId.ShouldBe("seg-1", "the id is still reported, so the operator can find the row");
|
||||
trie.Root.Grants.ShouldBeEmpty("a retired-kind row must not fall through to the cluster root");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A genuinely dangling id must keep reporting <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/>.
|
||||
/// Without this, moving <c>FolderSegment</c> to its own reason could be "achieved" by relabelling
|
||||
/// every drop, and the split would carry no information.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_A_Live_Kind_With_A_Dangling_Id_Still_Emits_UnresolvedScope()
|
||||
{
|
||||
var row = Row("cn=ops", NodeAclScopeKind.RawFolder, "RF-gone", NodePermissions.Read);
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
PermissionTrieBuilder.Build(
|
||||
"c1", 1, [row], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
|
||||
|
||||
diagnostics.ShouldHaveSingleItem().Reason
|
||||
.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #533 — the identifier rule reaches the builder's own <c>ClusterId</c> filter, which has
|
||||
/// always been <c>OrdinalIgnoreCase</c>. Pinned because the two spellings genuinely occur: a
|
||||
/// ClusterId arrives lower-cased through <c>OTOPCUA_ROLES</c> and verbatim through
|
||||
/// <c>Cluster:Roles</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_Matches_The_Cluster_Case_Insensitively()
|
||||
{
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
|
||||
|
||||
PermissionTrieBuilder.Build(
|
||||
"C1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>())
|
||||
.Root.Grants.ShouldHaveSingleItem();
|
||||
|
||||
PermissionTrieBuilder.Build(
|
||||
"c2", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>())
|
||||
.Root.Grants.ShouldBeEmpty("a DIFFERENT cluster must still be excluded");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user