using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Task 11 + write-outcome self-correction — the inbound operator-write authz/availability gate and the /// compare-and-revert decision. The OnWriteValue handler on a writable equipment-tag node extracts the /// caller's , gates on the /// role (deny otherwise) AND on the gateway being wired /// (BadNotWritable otherwise), then returns Good (optimistic write) and dispatches the write /// fire-and-forget through the . When the async device /// outcome comes back FAILED, the node self-corrects back to its real pre-write value — but only while the /// node still holds the optimistic value (so a fresh poll isn't clobbered). /// /// Both decisions are extracted as pure statics so they're unit-testable without booting an SDK server: /// (role + availability) and /// (compare-and-revert). /// public sealed class EquipmentWriteGateTests { /// (a) A null identity (anonymous / no role-carrying identity on the context) is denied with /// BadUserAccessDenied — the gate fails closed. [Fact] public void Null_identity_is_denied() { var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate( identity: null, gatewayWired: true); result.ShouldNotBeNull(); result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); } /// (b) An identity WITHOUT the WriteOperate role is denied with /// BadUserAccessDenied. [Fact] public void Identity_without_WriteOperate_is_denied() { var identity = IdentityWith("ReadOnly", OpcUaDataPlaneRoles.AlarmAck); // no WriteOperate var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate( identity, gatewayWired: true); result.ShouldNotBeNull(); result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); } /// (c) An identity WITH the WriteOperate role and a wired gateway passes the gate /// (returns null — proceed). The role match is case-insensitive (the role set + gate both use /// OrdinalIgnoreCase). [Fact] public void Identity_with_WriteOperate_and_gateway_passes() { var identity = IdentityWith("readonly", "writeoperate"); // lower-cased: case-insensitive match var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate( identity, gatewayWired: true); result.ShouldBeNull(); } /// (d) An identity WITH the WriteOperate role but no gateway wired maps to /// BadNotWritable ("writes unavailable") — the gate passes but there is nowhere to route. [Fact] public void Identity_with_WriteOperate_and_no_gateway_maps_to_bad_not_writable() { var identity = IdentityWith(OpcUaDataPlaneRoles.WriteOperate); var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate( identity, gatewayWired: false); result.ShouldNotBeNull(); result!.StatusCode.Code.ShouldBe(StatusCodes.BadNotWritable); result.LocalizedText.Text.ShouldContain("writes unavailable"); } /// /// decision table. Revert ONLY on a failed outcome AND /// only while the node still holds the optimistic value (a fresh poll that moved the node on must not /// be clobbered). The first bool is the outcome's Success flag. /// [Theory] [InlineData(true, "v", "v", false)] // success → never revert (even though still-optimistic) [InlineData(false, "v", "v", true)] // fail + node still holds optimistic → revert [InlineData(false, "w", "v", false)] // fail + a poll moved the node on → no revert [InlineData(true, "w", "v", false)] // success + moved on → no revert public void ShouldRevert_decision_table(bool success, object currentNodeValue, object optimisticValue, bool expected) { var outcome = new NodeWriteOutcome(success, success ? null : "device rejected"); OtOpcUaNodeManager.ShouldRevert(outcome, currentNodeValue, optimisticValue).ShouldBe(expected); } /// Null-value edges: a failed write whose node still holds the optimistic null reverts; /// a failed write whose node moved off null does not. [Fact] public void ShouldRevert_null_value_edges() { var fail = new NodeWriteOutcome(false, "device rejected"); OtOpcUaNodeManager.ShouldRevert(fail, currentNodeValue: null, optimisticValue: null).ShouldBeTrue(); OtOpcUaNodeManager.ShouldRevert(fail, currentNodeValue: null, optimisticValue: "v").ShouldBeFalse(); } private static RoleCarryingUserIdentity IdentityWith(params string[] roles) => new(new UserNameIdentityToken { UserName = "op" }, roles); }