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(); } // ───────────────────────────── Item A — EvaluateEquipmentWriteStructure ───────────────────────────── /// (A1) A null value write to a value variable is rejected synchronously with /// BadTypeMismatch (the minimum sensible structural check — a value node always holds a typed /// payload, so a null write is never valid and fails fast inline rather than optimistic-then-revert). [Fact] public void Structure_null_value_is_rejected_with_type_mismatch() { var node = ValueNode(DataTypeIds.Int32); var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: null, node); result.ShouldNotBeNull(); result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch); } /// (A2) A type-matching value (Int32 payload into an Int32 node) proceeds (returns null). [Fact] public void Structure_matching_type_proceeds() { var node = ValueNode(DataTypeIds.Int32); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 42, node).ShouldBeNull(); } /// (A3) Numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics) — a /// Double payload into an Int32 node proceeds rather than being rejected here. [Fact] public void Structure_numeric_to_numeric_proceeds() { var node = ValueNode(DataTypeIds.Int32); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 3.5d, node).ShouldBeNull(); } /// (A4) A confident cross-family mismatch (a String payload into a Boolean node) is rejected /// with BadTypeMismatch via the cheap built-in-type compatibility check. [Fact] public void Structure_cross_family_mismatch_is_rejected() { var node = ValueNode(DataTypeIds.Boolean); var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "not-a-bool", node); result.ShouldNotBeNull(); result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch); } /// (A5) Confidence-gated defer: a node whose DataType is the abstract BaseDataType /// wildcard (unresolved built-in type) proceeds for ANY non-null payload — the SDK's own coercion stays /// authoritative. A "fallback" equipment tag (unknown DataType string) materialises as exactly this. [Fact] public void Structure_unresolved_datatype_defers_to_sdk() { var node = ValueNode(DataTypeIds.BaseDataType); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "anything", node).ShouldBeNull(); } /// (A6) A non-variable node (defensive) is never rejected on type — only the null-payload check /// applies. A non-null write to a plain proceeds. [Fact] public void Structure_non_variable_node_defers() { var folder = new FolderState(null) { NodeId = new NodeId("f", 2), BrowseName = new QualifiedName("f", 2) }; OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 1, folder).ShouldBeNull(); } /// (A7) Closed-set safety boundary: an Enumeration-typed node with an Int32 payload DEFERS /// (returns null) rather than false-rejecting — an enum write normally carries an Int32 the SDK coerces. /// Before the closed-set gate this returned BadTypeMismatch — a false rejection of a valid write. [Fact] public void Structure_enumeration_datatype_defers_to_sdk() { var node = ValueNode(DataTypeIds.Enumeration); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 2, node).ShouldBeNull(); } /// (A8) Closed-set safety boundary: any expected built-in type outside the materialiser's emitted /// set (here Guid) DEFERS — only the numeric families + Boolean/String/DateTime/ByteString are ever rejected, /// so the fail-fast can never reject a write the SDK would coerce. [Fact] public void Structure_noncheckable_expected_type_defers() { var node = ValueNode(DataTypeIds.Guid); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "any", node).ShouldBeNull(); } private static BaseDataVariableState ValueNode(NodeId dataType) => new(null) { NodeId = new NodeId("eq/x", 2), BrowseName = new QualifiedName("x", 2), DisplayName = "x", DataType = dataType, ValueRank = ValueRanks.Scalar, }; private static RoleCarryingUserIdentity IdentityWith(params string[] roles) => new(new UserNameIdentityToken { UserName = "op" }, roles); }