Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs
T
Joseph Doherty 52708bff52 fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list
writes (its element types are only Boolean/Int32/Float/Double/String/
DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a
Double list to a Float array. EvaluateEquipmentWriteStructure now converts
a numeric array of another numeric element type element-wise, CHECKED, into
the node's element type and hands the converted array back through
OnEquipmentTagWrite's ref value, so the node stores and the driver receives
the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to-
integer or Float-overflowing element refuses the whole write BadTypeMismatch
(nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays
closed. Non-numeric mismatches stay refused. The wrong 'no client relies on
a looser rule' comment is corrected.

MINOR-1: the array-value-to-scalar-node refusal broke writes back to
browse-committed array tags (committed scalar, isArray never set) on
pass-through drivers. It now applies only when the owning driver's scalar
encoder cannot take an array (DriverArrayWriteSupport.
RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan.
ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue
-> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node
manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the
classifier whitelist (driver-type edit rebuilds); a mirror whose flag
changes is recreated, not updated in place.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:31:46 -04:00

369 lines
20 KiB
C#

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;
/// <summary>
/// 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 <see cref="RoleCarryingUserIdentity"/>, gates on the
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role (deny otherwise) AND on the gateway being wired
/// (BadNotWritable otherwise), then returns <c>Good</c> (optimistic write) and dispatches the write
/// fire-and-forget through the <see cref="OtOpcUaNodeManager.NodeWriteGateway"/>. 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:
/// <see cref="OtOpcUaNodeManager.EvaluateEquipmentWriteGate"/> (role + availability) and
/// <see cref="OtOpcUaNodeManager.ShouldRevert"/> (compare-and-revert).
/// </summary>
public sealed class EquipmentWriteGateTests
{
/// <summary>(a) A null identity (anonymous / no role-carrying identity on the context) is denied with
/// <c>BadUserAccessDenied</c> — the gate fails closed.</summary>
[Fact]
public void Null_identity_is_denied()
{
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteGate(
identity: null,
gatewayWired: true);
result.ShouldNotBeNull();
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
/// <summary>(b) An identity WITHOUT the <c>WriteOperate</c> role is denied with
/// <c>BadUserAccessDenied</c>.</summary>
[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);
}
/// <summary>(c) An identity WITH the <c>WriteOperate</c> role and a wired gateway passes the gate
/// (returns <c>null</c> — proceed). The role match is case-insensitive (the role set + gate both use
/// <c>OrdinalIgnoreCase</c>).</summary>
[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();
}
/// <summary>(d) An identity WITH the <c>WriteOperate</c> role but no gateway wired maps to
/// <c>BadNotWritable</c> ("writes unavailable") — the gate passes but there is nowhere to route.</summary>
[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");
}
/// <summary>
/// <see cref="OtOpcUaNodeManager.ShouldRevert"/> 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 <c>bool</c> is the outcome's Success flag.
/// </summary>
[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);
}
/// <summary>Null-value edges: a failed write whose node still holds the optimistic <c>null</c> reverts;
/// a failed write whose node moved off <c>null</c> does not.</summary>
[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 ─────────────────────────────
/// <summary>(A1) A <c>null</c> value write to a value variable is rejected synchronously with
/// <c>BadTypeMismatch</c> (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).</summary>
[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);
}
/// <summary>(A2) A type-matching value (Int32 payload into an Int32 node) proceeds (returns <c>null</c>).</summary>
[Fact]
public void Structure_matching_type_proceeds()
{
var node = ValueNode(DataTypeIds.Int32);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 42, node).ShouldBeNull();
}
/// <summary>(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).</summary>
[Fact]
public void Structure_numeric_to_numeric_proceeds()
{
var node = ValueNode(DataTypeIds.Int32);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 3.5d, node).ShouldBeNull();
}
/// <summary>(A4) A confident cross-family mismatch (a String payload into a Boolean node) is rejected
/// with <c>BadTypeMismatch</c> via the cheap built-in-type compatibility check.</summary>
[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);
}
/// <summary>(A5) Confidence-gated defer: a node whose DataType is the abstract <c>BaseDataType</c>
/// 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.</summary>
[Fact]
public void Structure_unresolved_datatype_defers_to_sdk()
{
var node = ValueNode(DataTypeIds.BaseDataType);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "anything", node).ShouldBeNull();
}
/// <summary>(A6) A non-variable node (defensive) is never rejected on type — only the null-payload check
/// applies. A non-null write to a plain <see cref="NodeState"/> proceeds.</summary>
[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();
}
/// <summary>(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 <c>BadTypeMismatch</c> — a false rejection of a valid write.</summary>
[Fact]
public void Structure_enumeration_datatype_defers_to_sdk()
{
var node = ValueNode(DataTypeIds.Enumeration);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 2, node).ShouldBeNull();
}
/// <summary>(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.</summary>
[Fact]
public void Structure_noncheckable_expected_type_defers()
{
var node = ValueNode(DataTypeIds.Guid);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "any", node).ShouldBeNull();
}
// ───────────────────────────── #610 — array shape + IndexRange ─────────────────────────────
/// <summary>A whole array of the declared length proceeds; a wrong length or a scalar is refused inline.</summary>
[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);
}
/// <summary>An unbounded array (ArrayDimensions [0]) accepts any length.</summary>
[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();
}
/// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> 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 <c>uint[]</c> element above Int32.MaxValue would reach
/// MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret its bits) is refused. The matching element
/// type is the positive control.</summary>
[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<NodeId, Array, Array> 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
};
/// <summary>#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.</summary>
[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<NodeId, Array, Array> 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 } },
};
/// <summary>An exact element-type match is passed through unconverted (the same instance).</summary>
[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);
}
/// <summary>A refused write hands back no value to store.</summary>
[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();
}
/// <summary>A Byte array decodes as <c>byte[]</c>, 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.</summary>
[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);
}
/// <summary>#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.</summary>
[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);
}
/// <summary>#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.</summary>
[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();
}
/// <summary>A partial (IndexRange) write is refused; an absent range proceeds.</summary>
[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<uint>(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);
}