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()) { // Must not throw — every OpcUaOperation needs a mapping or the compliance-check // "every operation wired" fails. TriePermissionEvaluator.MapOperationToPermission(op); } } }