using Opc.Ua; 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; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Unit tests for and /// — Phase 6.2 Stream C method-Call /// gating covering the Part 9 alarm Acknowledge / Confirm methods plus generic /// driver-exposed method nodes. /// [Trait("Category", "Unit")] public sealed class CallGatingTests { [Fact] public void MapCallOperation_Acknowledge_maps_to_AlarmAcknowledge() { DriverNodeManager.MapCallOperation(MethodIds.AcknowledgeableConditionType_Acknowledge) .ShouldBe(OpcUaOperation.AlarmAcknowledge); } [Fact] public void MapCallOperation_Confirm_maps_to_AlarmConfirm() { DriverNodeManager.MapCallOperation(MethodIds.AcknowledgeableConditionType_Confirm) .ShouldBe(OpcUaOperation.AlarmConfirm); } [Fact] public void MapCallOperation_generic_method_maps_to_Call() { // Arbitrary driver-exposed method NodeId — falls through to generic Call. DriverNodeManager.MapCallOperation(new NodeId("driver-method", 2)) .ShouldBe(OpcUaOperation.Call); } [Fact] public void Gate_null_leaves_errors_untouched() { var calls = new List { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge) }; var errors = new List { (ServiceResult)null! }; DriverNodeManager.GateCallMethodRequests(calls, errors, new UserIdentity(), gate: null, scopeResolver: null); errors[0].ShouldBeNull(); } [Fact] public void Denied_Acknowledge_call_gets_BadUserAccessDenied() { var calls = new List { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), }; var errors = new List { (ServiceResult)null! }; var gate = MakeGate(strict: true, rows: []); // no grants → deny DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1")); ServiceResult.IsBad(errors[0]).ShouldBeTrue(); errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied); } [Fact] public void Allowed_Acknowledge_passes_through() { var calls = new List { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), }; var errors = new List { (ServiceResult)null! }; var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]); DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1")); errors[0].ShouldBeNull(); } [Fact] public void Mixed_batch_gates_per_item() { var calls = new List { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge), NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Confirm), }; var errors = new List { (ServiceResult)null!, (ServiceResult)null! }; // Grant Acknowledge but not Confirm — mixed outcome per item. var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]); DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1")); errors[0].ShouldBeNull("Acknowledge granted"); ServiceResult.IsBad(errors[1]).ShouldBeTrue("Confirm not granted"); } [Fact] public void Pre_populated_error_is_preserved() { var calls = new List { NewCall("c1/area/line/eq/alarm1", NodeId.Null) }; var errors = new List { new(StatusCodes.BadMethodInvalid) }; var gate = MakeGate(strict: true, rows: []); DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1")); errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadMethodInvalid); } // ---- helpers ----------------------------------------------------------- private static CallMethodRequest NewCall(string objectFullRef, NodeId methodId) => new() { ObjectId = new NodeId(objectFullRef, 2), MethodId = methodId, }; private static NodeAcl Row(string group, NodePermissions flags) => new() { NodeAclRowId = Guid.NewGuid(), NodeAclId = Guid.NewGuid().ToString(), GenerationId = 1, ClusterId = "c1", LdapGroup = group, ScopeKind = NodeAclScopeKind.Cluster, ScopeId = null, PermissionFlags = flags, }; private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows) { var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build("c1", 1, rows)); var evaluator = new TriePermissionEvaluator(cache); return new AuthorizationGate(evaluator, strictMode: strict); } private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups); private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer { public FakeIdentity(string name, IReadOnlyList groups) { DisplayName = name; LdapGroups = groups; } public new string DisplayName { get; } public IReadOnlyList LdapGroups { get; } } }