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) SCALAR numeric-to-numeric is treated as compatible — a Double payload into an Int32 node /// proceeds rather than being rejected here. Deliberately looser than the SDK (which never sees these writes: /// it skips its type check when OnWriteValue is set): consumers write CLR-typed values, and every driver's /// scalar encoder converts with a range check. Arrays are matched exactly instead (#610 review). [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(); } // ───────────────────────────── #610 — array shape + IndexRange ───────────────────────────── /// A whole array of the declared length proceeds; a wrong length or a scalar is refused inline. [Fact] public void Structure_array_node_takes_only_a_whole_array_of_its_declared_length() { var node = ArrayNode(DataTypeIds.Int16, length: 4); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3, 4 }, node).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node)!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure((short)1, node)!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); } /// An unbounded array (ArrayDimensions [0]) accepts any length. [Fact] public void Structure_unbounded_array_node_accepts_any_length() { var node = ArrayNode(DataTypeIds.Int16, length: 0); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node).ShouldBeNull(); } /// #610 review MAJOR-1 — the SDK skips its own type check when OnWriteValue is set, so an array /// write's ELEMENT type is checked here: a non-numeric mismatch, an unclassifiable element type, or a numeric /// element the node's type cannot represent exactly (a uint[] element above Int32.MaxValue would reach /// MxValueEncoder's int[] arm by covariance and reinterpret its bits) is refused. The matching element /// type is the positive control. [Theory] [MemberData(nameof(MistypedArrays))] public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped) { var node = ArrayNode(dataType, length: 2); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(matching, node).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(mistyped, node)!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, mistyped.GetType().Name); } public static TheoryData MistypedArrays() => new() { { DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 70_000 } }, // Int32[] → Int16, out of range { DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy) { DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { ulong.MaxValue, 2 } }, // UInt64[] → Int64 (Galaxy) { DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 1e300 } }, // Double[] → Float, overflows { DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1.5d, 2d } }, // fractional → integer { DataTypeIds.Int32, new[] { 1, 2 }, new[] { double.NaN, 2d } }, // NaN → integer { DataTypeIds.Int64, new long[] { 1, 2 }, new[] { double.PositiveInfinity, 2d } },// ∞ → integer { DataTypeIds.UInt16, new ushort[] { 1, 2 }, new[] { -1, 2 } }, // negative → unsigned { DataTypeIds.Int32, new[] { 1, 2 }, new[] { true, false } }, // Boolean[] → Int32 { DataTypeIds.String, new[] { "a", "b" }, new[] { 1, 2 } }, // Int32[] → String { DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean { DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused }; /// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list it decoded from a List attribute, whose /// element type can only be Boolean/Int32/Float/Double/String/DateTime. A numeric array of another element type /// is CONVERTED (checked) to the node's element type, and the converted array — not the client's — is what the /// production overload hands back to be stored and dispatched. [Theory] [MemberData(nameof(ConvertibleArrays))] public void Structure_numeric_array_of_another_element_type_is_converted_to_the_node_type(NodeId dataType, Array written, Array expected) { var node = ArrayNode(dataType, length: (uint)written.Length); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out var coerced) .ShouldBeNull(); coerced.ShouldNotBeNull().GetType().ShouldBe(expected.GetType()); coerced.ShouldBe(expected); } public static TheoryData ConvertibleArrays() => new() { { DataTypeIds.Int16, new[] { 1, -2, 32_767 }, new short[] { 1, -2, 32_767 } }, // Int32 list → Modbus default { DataTypeIds.Float, new[] { 1.5d, -2.25d }, new[] { 1.5f, -2.25f } }, // Double list → Float array { DataTypeIds.Int64, new[] { 1, -2 }, new long[] { 1, -2 } }, // Int32 list → Galaxy Int64 { DataTypeIds.UInt16, new[] { 0, 65_535 }, new ushort[] { 0, 65_535 } }, { DataTypeIds.UInt32, new[] { 0, int.MaxValue }, new uint[] { 0, int.MaxValue } }, { DataTypeIds.Byte, new[] { 0, 255 }, new byte[] { 0, 255 } }, { DataTypeIds.Int16, new[] { 2.0d, -3.0d }, new short[] { 2, -3 } }, // integral doubles convert { DataTypeIds.Int32, new uint[] { 5, 2_147_483_647 }, new[] { 5, int.MaxValue } }, // in-range UInt32 → Int32 { DataTypeIds.Double, new[] { 1f, 2.5f }, new[] { 1d, 2.5d } }, }; /// An exact element-type match is passed through unconverted (the same instance). [Fact] public void Structure_matching_array_is_passed_through_unchanged() { var written = new short[] { 1, 2 }; OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, ArrayNode(DataTypeIds.Int16, 2), refuseArrayOnScalar: true, out var coerced) .ShouldBeNull(); coerced.ShouldBeSameAs(written); } /// A refused write hands back no value to store. [Fact] public void Structure_refused_array_returns_no_coerced_value() { OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { 70_000 }, ArrayNode(DataTypeIds.Int16, 1), refuseArrayOnScalar: true, out var coerced) .ShouldNotBeNull(); coerced.ShouldBeNull(); } /// A Byte array decodes as byte[], which classifies as ByteString — the one equivalence the SDK /// itself honours for array nodes. An SByte array is numeric, so it converts when every element fits (R6) and is /// refused when one does not. [Fact] public void Structure_byte_array_node_takes_a_byte_array() { var node = ArrayNode(DataTypeIds.Byte, length: 2); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node, refuseArrayOnScalar: true, out var coerced) .ShouldBeNull(); coerced.ShouldBe(new byte[] { 1, 2 }); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { -1, 2 }, node)!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); } /// #610 review R6 MINOR-1 — a scalar node whose driver passes the value through (OpcUaClient, Galaxy: /// a browse-committed array attribute committed as a scalar tag) keeps the pre-#610 behaviour: an array value is /// dispatched and the driver decides. The Modbus/S7 refusal is the control. [Fact] public void Structure_scalar_node_of_a_pass_through_driver_accepts_an_array_value() { var node = ValueNode(DataTypeIds.Int32); var written = new[] { 1, 2, 3 }; OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: false, out var coerced) .ShouldBeNull(); coerced.ShouldBeSameAs(written); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out _)!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); // The element-type check still applies on the pass-through path: a String[] to an Int32 node is refused. OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { "a" }, node, refuseArrayOnScalar: false, out _)!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); } /// #610 review MINOR-5 — the mirror case: an array value to a scalar node of a driver that cannot take /// one (Modbus / S7 — the two-argument overload's assumption) is refused up front. A byte[] (a ByteString on the /// wire) is a scalar and still reaches the type check; an unresolved-DataType node defers as before. [Fact] public void Structure_scalar_node_refuses_an_array_value() { OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1 }, ValueNode(DataTypeIds.Int16))!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure((short)1, ValueNode(DataTypeIds.Int16)).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1 }, ValueNode(DataTypeIds.ByteString)).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1 }, ValueNode(DataTypeIds.BaseDataType)).ShouldBeNull(); } /// A partial (IndexRange) write is refused; an absent range proceeds. [Fact] public void Range_only_whole_value_writes_proceed() { OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Empty).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Parse("1"))!.StatusCode .ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported); } private static BaseDataVariableState ArrayNode(NodeId dataType, uint length) { var node = ValueNode(dataType); node.ValueRank = ValueRanks.OneDimension; node.ArrayDimensions = new ReadOnlyList(new UInt32Collection(new[] { length })); return node; } 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); }