using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #610 — server-side array writes, over the wire. The address space is built by the PRODUCTION /// composer + applier (not hand-made EnsureVariable calls), so these tests prove the whole chain: /// DriverArrayWriteSupport → RawTagPlan.ArrayWritable / UnsReferenceVariable.BackingArrayWritable /// → the node's AccessLevel → a real client write through the realm-qualified WriteOperate gate → /// the node write gateway (the stand-in for DriverHostActor's routing). /// /// An S7 Int16 array (S7 encodes non-Bool arrays as one block) is writable on BOTH its raw node and /// its UNS mirror, and a whole-array write round-trips. /// An AbCip DInt array (AbCip has a scalar-only write encoder) stays read-only even though it is /// authored ReadWrite: the write is BadNotWritable at the node and never reaches the /// gateway — the "never advertise a write the driver will refuse" rule. /// A device-refused array write is reverted on whichever NodeId it was written through. /// A wrong-length array and an IndexRange (partial) write are refused inline, before dispatch, and /// leave the node holding its prior array. /// /// [Trait("Category", "Integration")] public sealed class ArrayWriteWireTests { private const string S7ArrayRaw = "Plant/S7a/plc1/Levels"; private const string AbArrayRaw = "Plant/Ab1/plc2/Counts"; private const string S7ArrayUns = "filling/line-1/station-1/Levels"; private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint"; private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe"; private const string UaScalarRaw = "Plant/Ua1/up/Vector"; private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway { public List<(string NodeId, object? Value, AddressSpaceRealm Realm)> Writes { get; } = []; public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) { lock (Writes) Writes.Add((nodeId, value, realm)); return Task.FromResult(new NodeWriteOutcome(succeed, succeed ? null : "device refused")); } public int Count { get { lock (Writes) return Writes.Count; } } } /// Composes + materialises the two array tags and the UNS mirror of the S7 one, through the /// production composer and applier, then publishes an initial value to every node. private static async Task StartAsync(string name, CancellationToken ct) { var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct); var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" }; var s7 = new DriverInstance { DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "raw-plant", Name = "S7a", DriverType = "S7", DriverConfig = "{}", }; var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "raw-plant", Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}", }; var plc1 = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" }; var plc2 = new Device { DeviceId = "dev-2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" }; var gx = new DriverInstance { DriverInstanceId = "drv-gx", ClusterId = "c1", RawFolderId = "raw-plant", Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}", }; var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" }; // An upstream array variable committed from the /raw browser: the commit sets the DataType but never // isArray, so it materialises SCALAR while the driver publishes an array into it (R6 MINOR-1). var ua = new DriverInstance { DriverInstanceId = "drv-ua", ClusterId = "c1", RawFolderId = "raw-plant", Name = "Ua1", DriverType = "OpcUaClient", DriverConfig = "{}", }; var uaDevice = new Device { DeviceId = "dev-4", DriverInstanceId = "drv-ua", Name = "up", DeviceConfig = "{}" }; var vector = new Tag { TagId = "t-vector", DeviceId = "dev-4", Name = "Vector", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"nodeId\":\"ns=2;s=Vector\"}", }; var recipe = new Tag { TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"FullName\":\"Mixer_001.Recipe\",\"isArray\":true,\"arrayLength\":2}", }; var setpoint = new Tag { TagId = "t-setpoint", DeviceId = "dev-1", Name = "Setpoint", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW20\",\"dataType\":\"Int16\"}", }; var levels = new Tag { TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}", }; var counts = new Tag { TagId = "t-counts", DeviceId = "dev-2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}", }; var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "ST1" }; var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" }; var composition = AddressSpaceComposer.Compose( [area], [line], [equip], [s7, ab, gx, ua], Array.Empty(), unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector], rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]); var applier = new AddressSpaceApplier(rig.Sink, NullLogger.Instance); applier.MaterialiseHierarchy(composition).ShouldBe(0); applier.MaterialiseRawSubtree(composition).ShouldBe(0); applier.MaterialiseUnsReferences(composition).ShouldBe(0); var ts = DateTime.UtcNow; rig.Sink.WriteValue(S7ArrayRaw, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(S7ArrayUns, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns); rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(UaScalarRaw, new[] { 1, 2 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); return rig; } private static NodeId Raw(ISession s, string id) => new(id, (ushort)s.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); private static async Task WriteAsync(ISession session, NodeId node, object value, CancellationToken ct, string? indexRange = null) { var response = await session.WriteAsync(null, new WriteValueCollection { new WriteValue { NodeId = node, AttributeId = Attributes.Value, IndexRange = indexRange, Value = new DataValue(new Variant(value)) }, }, ct); return response.Results.ShouldHaveSingleItem(); } private static async Task ReadShortsAsync(ISession session, NodeId node, CancellationToken ct) => (short[])(await session.ReadValueAsync(node, ct)).Value; private static async Task ReadAccessLevelAsync(ISession session, NodeId node, CancellationToken ct) { var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection { new ReadValueId { NodeId = node, AttributeId = Attributes.UserAccessLevel }, }, ct); return (byte)response.Results.ShouldHaveSingleItem().Value; } private static async Task WaitForAsync(Func> condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < deadline) { if (await condition()) return; await Task.Delay(50); } (await condition()).ShouldBeTrue("condition not met within timeout"); } /// A writable-capable array round-trips a whole-array write through BOTH NodeIds. [Fact] public async Task A_writable_array_roundtrips_a_whole_array_write_through_both_nodeids() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-write", ct); var gateway = new RecordingWriteGateway(succeed: true); rig.Nm.NodeWriteGateway = gateway; using var session = await rig.ConnectAsync(anonymous: false, ct); var raw = Raw(session, S7ArrayRaw); var uns = ServerEventRig.UnsNodeId(session, S7ArrayUns); (await ReadAccessLevelAsync(session, raw, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite); (await ReadAccessLevelAsync(session, uns, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite); (await WriteAsync(session, raw, new short[] { 10, 20, 30, 40 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); (await ReadShortsAsync(session, raw, ct)).ShouldBe(new short[] { 10, 20, 30, 40 }); (await WriteAsync(session, uns, new short[] { -1, -2, -3, -4 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); (await ReadShortsAsync(session, uns, ct)).ShouldBe(new short[] { -1, -2, -3, -4 }); lock (gateway.Writes) { gateway.Writes.Count.ShouldBe(2); gateway.Writes[0].Realm.ShouldBe(AddressSpaceRealm.Raw); gateway.Writes[0].Value.ShouldBe(new short[] { 10, 20, 30, 40 }); gateway.Writes[1].Realm.ShouldBe(AddressSpaceRealm.Uns); gateway.Writes[1].Value.ShouldBe(new short[] { -1, -2, -3, -4 }); } } /// An array on a driver with no array encoder stays read-only though authored ReadWrite. [Fact] public async Task An_array_the_driver_cannot_write_is_read_only_and_a_write_is_BadNotWritable() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-ro", ct); var gateway = new RecordingWriteGateway(succeed: true); rig.Nm.NodeWriteGateway = gateway; using var session = await rig.ConnectAsync(anonymous: false, ct); var ab = Raw(session, AbArrayRaw); (await ReadAccessLevelAsync(session, ab, ct) & AccessLevels.CurrentWrite).ShouldBe((byte)0); (await WriteAsync(session, ab, new[] { 50, 60, 70, 80 }, ct)).ShouldBe((StatusCode)StatusCodes.BadNotWritable); ((int[])(await session.ReadValueAsync(ab, ct)).Value).ShouldBe(new[] { 5, 6, 7, 8 }); gateway.Count.ShouldBe(0); // Positive control: the same session's write to the S7 array is dispatched, so the refusal is the node's. (await WriteAsync(session, Raw(session, S7ArrayRaw), new short[] { 9, 9, 9, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); gateway.Count.ShouldBe(1); } /// A device-refused array write reverts the node it was written through, on either NodeId. [Fact] public async Task A_device_refused_array_write_is_reverted_through_either_nodeid() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-revert", ct); rig.Nm.NodeWriteGateway = new RecordingWriteGateway(succeed: false); using var session = await rig.ConnectAsync(anonymous: false, ct); foreach (var node in new[] { Raw(session, S7ArrayRaw), ServerEventRig.UnsNodeId(session, S7ArrayUns) }) { (await WriteAsync(session, node, new short[] { 99, 99, 99, 99 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); await WaitForAsync(async () => (await ReadShortsAsync(session, node, ct)).SequenceEqual(new short[] { 1, 2, 3, 4 })); } } /// A wrong-length array and a partial (IndexRange) write are refused before dispatch. [Fact] public async Task A_wrong_length_or_index_range_array_write_is_refused_inline_and_leaves_the_node_intact() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-shape", ct); var gateway = new RecordingWriteGateway(succeed: true); rig.Nm.NodeWriteGateway = gateway; using var session = await rig.ConnectAsync(anonymous: false, ct); var raw = Raw(session, S7ArrayRaw); StatusCode.IsBad(await WriteAsync(session, raw, new short[] { 7, 7, 7 }, ct)).ShouldBeTrue("a 3-element write to a 4-element array"); (await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct, indexRange: "0:3")).ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported, "an IndexRange write, even one spanning the whole array"); (await ReadShortsAsync(session, raw, ct)).ShouldBe(new short[] { 1, 2, 3, 4 }); gateway.Count.ShouldBe(0); // Positive control: a correctly-shaped whole-array write on the same node is dispatched. (await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); gateway.Count.ShouldBe(1); } /// /// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an OnWriteValue handler, /// so the node manager must refuse an array its node type cannot represent before dispatch: an Int32[] /// element outside Int16 to an S7 Int16 array, and a UInt32[] element above Int32.MaxValue to a Galaxy /// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as /// -294,967,296, Good). /// [Fact] public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-type", ct); var gateway = new RecordingWriteGateway(succeed: true); rig.Nm.NodeWriteGateway = gateway; using var session = await rig.ConnectAsync(anonymous: false, ct); var s7 = Raw(session, S7ArrayRaw); var galaxy = Raw(session, GalaxyArrayRaw); (await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite); (await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16"); (await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array"); (await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 }); ((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 }); gateway.Count.ShouldBe(0); // Positive control: the correctly-typed array on the same Galaxy node is dispatched as-is. (await WriteAsync(session, galaxy, new[] { 21, 22 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 }); } /// /// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list a List attribute decodes to (here Int32, the only /// integer element type it has) to an Int16 array node. The write is converted, checked, to the node's /// element type: the client sees Good, the node serves short[], and the driver receives short[]. /// [Fact] public async Task A_numeric_array_of_another_element_type_is_converted_to_the_node_type() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-convert", ct); var gateway = new RecordingWriteGateway(succeed: true); rig.Nm.NodeWriteGateway = gateway; using var session = await rig.ConnectAsync(anonymous: false, ct); var s7 = Raw(session, S7ArrayRaw); (await WriteAsync(session, s7, new[] { 10, -20, 30, 32_767 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); (await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 10, -20, 30, 32_767 }); lock (gateway.Writes) { var dispatched = gateway.Writes.ShouldHaveSingleItem().Value.ShouldBeOfType(); dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 }); } } /// /// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed /// from /raw without isArray) still dispatches an array write, as it did before #610: the /// upstream server arbitrates the shape. The S7 scalar's refusal is the other side of the same rule. /// [Fact] public async Task An_array_value_to_a_pass_through_scalar_node_is_still_dispatched() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-to-ua-scalar", ct); var gateway = new RecordingWriteGateway(succeed: true); rig.Nm.NodeWriteGateway = gateway; using var session = await rig.ConnectAsync(anonymous: false, ct); (await WriteAsync(session, Raw(session, UaScalarRaw), new[] { 7, 8, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good); lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 7, 8, 9 }); (await WriteAsync(session, Raw(session, S7ScalarRaw), new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); gateway.Count.ShouldBe(1); } /// #610 review MINOR-5 — an array value to a SCALAR node of a driver whose scalar encoder cannot take /// one (S7) is refused before dispatch. [Fact] public async Task An_array_value_to_a_scalar_node_is_refused_inline() { var ct = TestContext.Current.CancellationToken; await using var rig = await StartAsync("array-to-scalar", ct); var gateway = new RecordingWriteGateway(succeed: true); rig.Nm.NodeWriteGateway = gateway; using var session = await rig.ConnectAsync(anonymous: false, ct); var scalar = Raw(session, S7ScalarRaw); (await WriteAsync(session, scalar, new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); ((short)(await session.ReadValueAsync(scalar, ct)).Value).ShouldBe((short)5); gateway.Count.ShouldBe(0); // Positive control: a scalar write to the same node is dispatched. (await WriteAsync(session, scalar, (short)9, ct)).ShouldBe((StatusCode)StatusCodes.Good); gateway.Count.ShouldBe(1); } }