chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionTrieCacheTests
|
||||
{
|
||||
private static PermissionTrie Trie(string cluster, long generation) => new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
GenerationId = generation,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GetTrie_Empty_ReturnsNull()
|
||||
{
|
||||
new PermissionTrieCache().GetTrie("c1").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Install_ThenGet_RoundTrips()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 5));
|
||||
|
||||
cache.GetTrie("c1")!.GenerationId.ShouldBe(5);
|
||||
cache.CurrentGenerationId("c1").ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewGeneration_BecomesCurrent()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c1", 2));
|
||||
|
||||
cache.CurrentGenerationId("c1").ShouldBe(2);
|
||||
cache.GetTrie("c1", 1).ShouldNotBeNull("prior generation retained for in-flight requests");
|
||||
cache.GetTrie("c1", 2).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfOrder_Install_DoesNotDowngrade_Current()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 3));
|
||||
cache.Install(Trie("c1", 1)); // late-arriving older generation
|
||||
|
||||
cache.CurrentGenerationId("c1").ShouldBe(3, "older generation must not become current");
|
||||
cache.GetTrie("c1", 1).ShouldNotBeNull("but older is still retrievable by explicit lookup");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_DropsCluster()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c2", 1));
|
||||
|
||||
cache.Invalidate("c1");
|
||||
|
||||
cache.GetTrie("c1").ShouldBeNull();
|
||||
cache.GetTrie("c2").ShouldNotBeNull("sibling cluster unaffected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_RetainsMostRecent()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
for (var g = 1L; g <= 5; g++) cache.Install(Trie("c1", g));
|
||||
|
||||
cache.Prune("c1", keepLatest: 2);
|
||||
|
||||
cache.GetTrie("c1", 5).ShouldNotBeNull();
|
||||
cache.GetTrie("c1", 4).ShouldNotBeNull();
|
||||
cache.GetTrie("c1", 3).ShouldBeNull();
|
||||
cache.GetTrie("c1", 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_LessThanKeep_IsNoOp()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c1", 2));
|
||||
|
||||
cache.Prune("c1", keepLatest: 10);
|
||||
|
||||
cache.CachedTrieCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterIsolation()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c2", 9));
|
||||
|
||||
cache.CurrentGenerationId("c1").ShouldBe(1);
|
||||
cache.CurrentGenerationId("c2").ShouldBe(9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionTrieTests
|
||||
{
|
||||
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
|
||||
new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
||||
GenerationId = 1,
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = group,
|
||||
ScopeKind = scope,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = ns,
|
||||
UnsAreaId = area,
|
||||
UnsLineId = line,
|
||||
EquipmentId = equip,
|
||||
TagId = tag,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
private static NodeScope GalaxyTag(string cluster, string ns, string[] folders, string tag) =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = ns,
|
||||
FolderSegments = folders,
|
||||
TagId = tag,
|
||||
Kind = NodeHierarchyKind.SystemPlatform,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
||||
{
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, scopeId: null, NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=ops"]);
|
||||
|
||||
matches.Count.ShouldBe(1);
|
||||
matches[0].PermissionFlags.ShouldBe(NodePermissions.Read);
|
||||
matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentScope_DoesNotLeak_ToSibling()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["eq-A"] = new(new[] { "ns", "area1", "line1", "eq-A" }),
|
||||
};
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||
|
||||
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
|
||||
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
|
||||
|
||||
matchA.Count.ShouldBe(1);
|
||||
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiGroup_Union_OrsPermissionFlags()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
||||
};
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=readers", "cn=writers"]);
|
||||
|
||||
matches.Count.ShouldBe(2);
|
||||
var combined = matches.Aggregate(NodePermissions.None, (acc, m) => acc | m.PermissionFlags);
|
||||
combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMatchingGroup_ReturnsEmpty()
|
||||
{
|
||||
var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=ops"]);
|
||||
|
||||
matches.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["folder-A"] = new(new[] { "ns-gal", "folder-A" }),
|
||||
};
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "folder-A", NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||
|
||||
var matchA = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-A"], "tag1"), ["cn=ops"]);
|
||||
var matchB = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-B"], "tag1"), ["cn=ops"]);
|
||||
|
||||
matchA.Count.ShouldBe(1);
|
||||
matchB.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossCluster_Grant_DoesNotLeak()
|
||||
{
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read, clusterId: "c-other") };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=ops"]);
|
||||
|
||||
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsIdempotent()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||
Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
||||
};
|
||||
|
||||
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
trie1.Root.Grants.Count.ShouldBe(trie2.Root.Grants.Count);
|
||||
trie1.ClusterId.ShouldBe(trie2.ClusterId);
|
||||
trie1.GenerationId.ShouldBe(trie2.GenerationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TriePermissionEvaluatorTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
private readonly FakeTimeProvider _time = new();
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
public DateTime Utc { get; set; } = Now;
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags) =>
|
||||
new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
||||
GenerationId = 1,
|
||||
ClusterId = "c1",
|
||||
LdapGroup = group,
|
||||
ScopeKind = scope,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static UserAuthorizationState Session(string[] groups, DateTime? resolvedUtc = null, string clusterId = "c1") =>
|
||||
new()
|
||||
{
|
||||
SessionId = "sess",
|
||||
ClusterId = clusterId,
|
||||
LdapGroups = groups,
|
||||
MembershipResolvedUtc = resolvedUtc ?? Now,
|
||||
AuthGenerationId = 1,
|
||||
MembershipVersion = 1,
|
||||
};
|
||||
|
||||
private static NodeScope Scope(string cluster = "c1") =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = "ns",
|
||||
UnsAreaId = "area",
|
||||
UnsLineId = "line",
|
||||
EquipmentId = "eq",
|
||||
TagId = "tag",
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
||||
return new TriePermissionEvaluator(cache, _time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allow_When_RequiredFlag_Matched()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.Allow);
|
||||
decision.Provenance.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotGranted_When_NoMatchingGroup()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=unrelated"]), OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
decision.Provenance.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotGranted_When_FlagsInsufficient()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryRead_Requires_Its_Own_Bit()
|
||||
{
|
||||
// User has Read but not HistoryRead
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var liveRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||
var historyRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.HistoryRead, Scope());
|
||||
|
||||
liveRead.IsAllowed.ShouldBeTrue();
|
||||
historyRead.IsAllowed.ShouldBeFalse("HistoryRead uses its own NodePermissions flag, not Read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossCluster_Session_Denied()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
var otherSession = Session(["cn=ops"], clusterId: "c-other");
|
||||
|
||||
var decision = evaluator.Authorize(otherSession, OpcUaOperation.Read, Scope(cluster: "c1"));
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleSession_FailsClosed()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
var session = Session(["cn=ops"], resolvedUtc: Now);
|
||||
_time.Utc = Now.AddMinutes(10); // well past the 5-min AuthCacheMaxStaleness default
|
||||
|
||||
var decision = evaluator.Authorize(session, OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoCachedTrie_ForCluster_Denied()
|
||||
{
|
||||
var cache = new PermissionTrieCache(); // empty cache
|
||||
var evaluator = new TriePermissionEvaluator(cache, _time);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationToPermission_Mapping_IsTotal()
|
||||
{
|
||||
foreach (var op in Enum.GetValues<OpcUaOperation>())
|
||||
{
|
||||
// Must not throw — every OpcUaOperation needs a mapping or the compliance-check
|
||||
// "every operation wired" fails.
|
||||
TriePermissionEvaluator.MapOperationToPermission(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UserAuthorizationStateTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static UserAuthorizationState Fresh(DateTime resolved) => new()
|
||||
{
|
||||
SessionId = "s",
|
||||
ClusterId = "c1",
|
||||
LdapGroups = ["cn=ops"],
|
||||
MembershipResolvedUtc = resolved,
|
||||
AuthGenerationId = 1,
|
||||
MembershipVersion = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FreshlyResolved_Is_NotStale_NorNeedsRefresh()
|
||||
{
|
||||
var session = Fresh(Now);
|
||||
|
||||
session.IsStale(Now.AddMinutes(1)).ShouldBeFalse();
|
||||
session.NeedsRefresh(Now.AddMinutes(1)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsRefresh_FiresAfter_FreshnessInterval()
|
||||
{
|
||||
var session = Fresh(Now);
|
||||
|
||||
session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 5-min staleness ceiling — should be Stale, not NeedsRefresh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsRefresh_TrueBetween_Freshness_And_Staleness_Windows()
|
||||
{
|
||||
// Custom: freshness=2 min, staleness=10 min → between 2 and 10 min NeedsRefresh fires.
|
||||
var session = Fresh(Now) with
|
||||
{
|
||||
MembershipFreshnessInterval = TimeSpan.FromMinutes(2),
|
||||
AuthCacheMaxStaleness = TimeSpan.FromMinutes(10),
|
||||
};
|
||||
|
||||
session.NeedsRefresh(Now.AddMinutes(5)).ShouldBeTrue();
|
||||
session.IsStale(Now.AddMinutes(5)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStale_TrueAfter_StalenessWindow()
|
||||
{
|
||||
var session = Fresh(Now);
|
||||
|
||||
session.IsStale(Now.AddMinutes(6)).ShouldBeTrue("default AuthCacheMaxStaleness is 5 min");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user