Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs
T
Joseph Doherty 7892eb13a8 fix(abcip): harden Ab.Transport CIP codec and EncapsulationClient (#564, #565)
CIP codec (#564): PatchU16 bounds guard; CipReply content equality; CipPath
port/length/ASCII/odd/oversize validation with ArgumentException for malformed
tag names and routes; CipPath.MessageRouter/ConnectionManager are now
ReadOnlySpan<byte>; refused Forward Open surfaces ForwardOpenRefusedException
with structured general/extended status; test CaptureFile uses BinaryPrimitives.

EncapsulationClient (#565): frame write bounded by the exchange timeout, which
faults (never cancels) the connection; finite positive timeouts enforced at
every entry point; gate wait bounded by the timeout; deterministic reorder of
the 300 ms test; new dispose-during-drain, gate-wait and RegisterSession
zero-context tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:54:32 -04:00

146 lines
7.2 KiB
C#

using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip;
// CAPTURE DECISION: the brief gave timeout ticks 0x0E. Every libplctag capture (logix-forward-open,
// logix-forward-close) carries priority/tick 0x0A, timeout ticks 0x05, so ConnectionManagerServices
// uses 0x05 (brief: 0x0E -> capture: 0x05). The golden-byte tests below use 0x05 accordingly.
public sealed class ConnectionManagerServicesTests
{
private static byte[] CapturedRequest(string file) =>
CaptureFile.CipPayload(CaptureFile.Load(file).First(f => f.FromClient));
private static CipReply CapturedReply(string file) =>
CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).First(f => !f.FromClient)));
[Fact]
public void Forward_close_frame()
{
var bytes = ConnectionManagerServices.ForwardClose(0x1234, 0x1337, 0xCAFEBABE, [0x01, 0x00]);
Convert.ToHexString(bytes).ShouldBe("4E02200624010A05" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401");
}
[Fact]
public void Forward_close_matches_capture()
{
var cap = CapturedRequest("logix-forward-close.hex");
var serial = BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(8));
var vendor = BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(10));
var orig = BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(12));
vendor.ShouldBe((ushort)0xF33D);
ConnectionManagerServices.ForwardClose(serial, vendor, orig, [0x01, 0x00]).ShouldBe(cap);
}
[Fact]
public void Large_forward_open_matches_capture()
{
var cap = CapturedRequest("logix-forward-open.hex");
// Values read out of the capture: T->O id @12, serial @16, vendor @18, originator serial @20,
// timeout multiplier @24, RPI @28, network params @32 (0x42000FA2 -> size 4002).
var p = new ForwardOpenParams(
Large: true, TOConnectionId: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(12)), ConnectionSerial: BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(16)),
VendorId: BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(18)), OriginatorSerial: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(20)),
TimeoutMultiplier: cap[24], RpiMicros: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(28)), ConnectionSize: 4002, Route: [0x01, 0x00]);
p.RpiMicros.ShouldBe(1_000_000u);
BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(32)).ShouldBe(0x42000FA2u);
ConnectionManagerServices.ForwardOpen(p).ShouldBe(cap);
}
[Fact]
public void Large_forward_open_reply_parses_from_capture()
{
var reply = ConnectionManagerServices.ParseForwardOpenReply(CapturedReply("logix-forward-open.hex"));
reply.OTConnectionId.ShouldBe(0x2CDF633Fu);
reply.TOConnectionId.ShouldBe(0x073F9E60u);
reply.OTApiMicros.ShouldBe(1_000_000u);
reply.TOApiMicros.ShouldBe(1_000_000u);
}
// No small Forward Open 0x54 was captured (the Large FO succeeded), so this layout is checked against
// the CIP spec / the brief rather than a capture.
[Fact]
public void Regular_forward_open_layout_per_spec()
{
var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(
false, 0x11223344, 0x5566, 0x7788, 0x99AABBCC, 0x01, 1_000_000, 504, [0x01, 0x00]));
Convert.ToHexString(bytes).ShouldBe(
"540220062401" + "0A05" + "00000000" + "44332211" + "6655" + "8877" + "CCBBAA99" + "01000000"
+ "40420F00" + "F843" + "40420F00" + "F843" + "A3" + "03" + "0100" + "20022401");
}
[Fact]
public void Regular_forward_open_network_params_encoding()
{
var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(false, 1, 2, 3, 4, 1, 1_000_000, 504, []));
// 504 = 0x1F8; 0x4200 | 0x1F8 = 0x43F8 -> LE F8 43
Convert.ToHexString(bytes).ShouldContain("40420F00" + "F843");
bytes[0].ShouldBe((byte)0x54);
}
[Fact]
public void Unconnected_send_even_embedded()
{
byte[] embedded = [0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00]; // 8 bytes
Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
"520220062401" + "0A05" + "0800" + Convert.ToHexString(embedded) + "01" + "00" + "0100");
}
[Fact]
public void Unconnected_send_pads_odd_embedded()
{
byte[] embedded = [0x01, 0x02, 0x03];
Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe(
"520220062401" + "0A05" + "0300" + "010203" + "00" + "01" + "00" + "0100");
}
[Fact]
public void Refused_forward_open_surfaces_statuses()
{
var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901"));
var ex = Should.Throw<AbTransportException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
ex.Message.ShouldContain("0x01");
ex.Message.ShouldContain("0x0109");
}
[Fact]
public void Refused_forward_open_carries_the_statuses_as_structured_data()
{
var reply = CipReply.Parse(Convert.FromHexString("DB000102" + "09010203"));
var ex = Should.Throw<ForwardOpenRefusedException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
ex.GeneralStatus.ShouldBe((byte)0x01);
ex.ExtendedStatus.ShouldBe(new ushort[] { 0x0109, 0x0302 });
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
}
[Fact]
public void Refused_forward_open_exception_is_not_aliased_to_the_reply_array()
{
var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901"));
var ex = Should.Throw<ForwardOpenRefusedException>(() => ConnectionManagerServices.ParseForwardOpenReply(reply));
reply.ExtendedStatus[0] = 0xFFFF;
ex.ExtendedStatus[0].ShouldBe((ushort)0x0109);
}
[Theory]
[InlineData("DB000800", true)] // Service Not Supported
[InlineData("DB0001010901", true)] // general 1 / extended 0x0109
[InlineData("DB0001010001", false)] // general 1 / extended 0x0100 (connection in use): no fallback
[InlineData("DB000000", false)]
public void Fallback_rules(string hex, bool expected) =>
ConnectionManagerServices.ShouldFallBackToRegularOpen(CipReply.Parse(Convert.FromHexString(hex))).ShouldBe(expected);
[Theory]
[InlineData(true, 0)] [InlineData(true, 0x10000)] [InlineData(false, 0)] [InlineData(false, 0x200)]
public void Connection_size_out_of_range_throws(bool large, int size) =>
Should.Throw<ArgumentOutOfRangeException>(() => ConnectionManagerServices.ForwardOpen(
new ForwardOpenParams(large, 1, 2, 3, 4, 1, 1_000_000, size, [])));
[Theory]
[InlineData(true, 1)] [InlineData(true, 0xFFFF)] [InlineData(false, 1)] [InlineData(false, 0x1FF)]
public void Connection_size_bounds_accepted(bool large, int size) =>
ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(large, 1, 2, 3, 4, 1, 1_000_000, size, [])).ShouldNotBeEmpty();
}