Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
T
Joseph Doherty 6b3da93151
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 16m47s
fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.

The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.

Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.

--- and the other half, which the fixture was hiding ---

Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:

  * @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
    @udt/ are), so it goes out as an ordinary symbolic name that no controller
    defines, and the controller's "tag does not exist" IS the successful answer.
  * ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
    controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
    classifies oppositely. Fixed in vendored patch 0002.
  * With the fixture then telling the truth, the real defect surfaced:
    AbCipDriverProbe.IsReachableException matched the native C status spellings
    ("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
    ("ErrorNotFound"). Those substrings can never match, so the reachable
    classification could not fire on the exception path — which is the path every
    no-authored-tags probe takes. This was failing on real hardware too, not just
    against the sim. It now recovers the Status and defers to IsReachableStatus,
    so there is one classification rather than two that can drift.

Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.

Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".

Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 15:20:07 -04:00

230 lines
12 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Phase 5 live verification that the real protocol-handshake Test-Connect probes actually
/// discriminate a speaking device from a merely-TCP-reachable one. Each probe is exercised
/// DIRECTLY (no cluster harness / SQL needed) against the shared docker-host sims, skip-gated
/// on reachability so <c>dotnet test</c> stays clean on a machine without fixture access.
///
/// <para>The decisive assertions are the cross-protocol RED cases: pointing a probe at a
/// DIFFERENT protocol's open port (which accepts TCP but does not speak the probe's protocol)
/// must now read <c>Ok = false</c> — the exact false-green bug Phase 5 fixes. Before Phase 5
/// every one of these read a false-healthy green.</para>
///
/// <para>S7 (<c>:1102</c>) and AbCip (<c>:44818</c>) happy-path verification skips unless those
/// fixtures are up (<c>lmxopcua-fix up s7 s7_1500</c> / <c>up abcip controllogix</c>); they are
/// unit-proven + code-reviewed. AbLegacy / TwinCAT / FOCAS have no rig target and are
/// unit-proven + degrade-guarded only (see <c>docs/drivers/TestConnectProbes.md</c>).</para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Phase", "5-probes")]
public sealed class DriverProbeHandshakeE2eTests
{
private const string DockerHost = "10.100.0.35";
private const int ModbusPort = 5020; // pymodbus sim — speaks Modbus
private const int OpcUaPort = 50000; // opc-plc — speaks OPC UA
private const int S7Port = 1102;
private const int AbCipPort = 44818;
private const string GalaxyHost = "10.100.0.48";
private const int GalaxyPort = 5120; // mxaccessgw — speaks gRPC
// Local docker-dev rig (on the dev host): a REAL OPC UA server + a real non-OPC-UA server.
private const int LocalOpcUaPort = 4840; // central-1 OtOpcUa OPC UA server — speaks OPC UA
private const int LocalSqlPort = 14330; // SQL Server — accepts TCP, speaks neither OPC UA nor gRPC
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static void SkipUnless(string host, int port)
{
// Generous timeout: the first connect from a cold test process (JIT + DNS warmup) can
// exceed the 500 ms default, and these targets may be a VPN hop away.
if (!DockerFixtureAvailability.IsReachable(host, port, 3000))
Assert.Skip($"Fixture {host}:{port} unreachable — skipping live handshake check.");
}
// ---- Modbus : FC03 handshake ----
[Fact]
public async Task Modbus_Green_AgainstModbusSim()
{
SkipUnless(DockerHost, ModbusPort);
var result = await new ModbusDriverProbe().ProbeAsync(
$"{{\"Host\":\"{DockerHost}\",\"Port\":{ModbusPort}}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("Modbus FC03");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task Modbus_Red_AgainstNonModbusPort()
{
// The OPC UA port accepts TCP but does not speak Modbus — must NOT read green.
SkipUnless(DockerHost, OpcUaPort);
var result = await new ModbusDriverProbe().ProbeAsync(
$"{{\"Host\":\"{DockerHost}\",\"Port\":{OpcUaPort}}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A non-Modbus TCP server must not pass the FC03 handshake.");
}
// ---- OpcUaClient : GetEndpoints handshake ----
[Fact]
public async Task OpcUaClient_Green_AgainstOpcPlc()
{
SkipUnless(DockerHost, OpcUaPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://{DockerHost}:{OpcUaPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("OPC UA");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task OpcUaClient_Red_AgainstNonOpcUaPort()
{
// The Modbus port accepts TCP but does not speak OPC UA — must NOT read green.
SkipUnless(DockerHost, ModbusPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://{DockerHost}:{ModbusPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A non-OPC-UA TCP server must not pass the GetEndpoints handshake.");
}
// ---- Galaxy : gRPC ping (auth-rejection = reachable) ----
[Fact]
public async Task Galaxy_Green_AgainstGateway()
{
SkipUnless(GalaxyHost, GalaxyPort);
// No API key supplied — an Unauthenticated reply still proves a live mxaccessgw gRPC server.
// UseTls:false matches the dev gateway's http2-cleartext endpoint (mirrors the dev config).
var result = await new GalaxyDriverProbe().ProbeAsync(
$"{{\"Gateway\":{{\"Endpoint\":\"http://{GalaxyHost}:{GalaxyPort}\",\"UseTls\":false}}}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task Galaxy_Red_AgainstNonGrpcPort()
{
// The Modbus port accepts TCP but does not speak gRPC — must NOT read green.
SkipUnless(DockerHost, ModbusPort);
var result = await new GalaxyDriverProbe().ProbeAsync(
$"{{\"Gateway\":{{\"Endpoint\":\"http://{DockerHost}:{ModbusPort}\",\"UseTls\":false}}}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A non-gRPC TCP server must not pass the gateway gRPC handshake.");
}
// ---- Local docker-dev rig: real OPC UA server (central-1) vs a real non-OPC-UA server ----
[Fact]
public async Task OpcUaClient_Green_AgainstLocalOtOpcUaServer()
{
SkipUnless("127.0.0.1", LocalOpcUaPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://127.0.0.1:{LocalOpcUaPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("OPC UA");
result.Latency.ShouldNotBeNull();
}
[Fact]
public async Task OpcUaClient_Red_AgainstLocalNonOpcUaServer()
{
// SQL Server accepts TCP but does not speak OPC UA — the false-green bug Phase 5 fixes.
SkipUnless("127.0.0.1", LocalSqlPort);
var result = await new OpcUaClientDriverProbe().ProbeAsync(
$"{{\"EndpointUrl\":\"opc.tcp://127.0.0.1:{LocalSqlPort}\"}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A SQL Server (non-OPC-UA) must not pass the GetEndpoints handshake.");
}
[Fact]
public async Task Modbus_Red_AgainstLocalNonModbusServer()
{
// SQL Server accepts TCP but does not speak Modbus.
SkipUnless("127.0.0.1", LocalSqlPort);
var result = await new ModbusDriverProbe().ProbeAsync(
$"{{\"Host\":\"127.0.0.1\",\"Port\":{LocalSqlPort}}}", Timeout, Ct);
result.Ok.ShouldBeFalse("A SQL Server (non-Modbus) must not pass the FC03 handshake.");
}
// ---- S7 : Plc.OpenAsync handshake (skips unless the sim fixture is up) ----
[Fact]
public async Task S7_Green_AgainstSim()
{
SkipUnless(DockerHost, S7Port);
var result = await new S7DriverProbe().ProbeAsync(
$"{{\"Host\":\"{DockerHost}\",\"Port\":{S7Port},\"CpuType\":\"S71500\",\"Rack\":0,\"Slot\":1}}", Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("S7 connected");
}
// ---- AbCip : libplctag CIP session handshake (skips unless the sim fixture is up) ----
[Fact]
public async Task AbCip_Green_AgainstSim()
{
SkipUnless(DockerHost, AbCipPort);
// Probe an explicit tag that the ab_server ControlLogix sim actually defines
// (`TestDINT:DINT[1]`). The no-tags fallback is exercised separately, below.
// ⚠️ The config must use the v3 `RawTags` shape — `[{RawPath, TagConfig, WriteIdempotent,
// DeviceName}]`, where the Logix tag path lives inside the TagConfig blob as `tagPath`. This
// test passed the retired v2 `Tags: [{DeviceHostAddress, TagPath}]` shape until 2026-08-01, so
// AbCipDriverOptions.RawTags bound EMPTY, the probe fell through to its `@raw_cpu_type` system-tag
// fallback, and ab_server answered ErrorBadParam. Which is to say it spent that whole time
// exercising the one path the comment above says it does not exercise — and reporting it as a
// failure of the fixture rather than of the config shape.
var result = await new AbCipDriverProbe().ProbeAsync(
$"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," +
$"\"RawTags\":[{{\"RawPath\":\"Plant/AbCip/dev1/TestDINT\"," +
$"\"TagConfig\":\"{{\\\"tagPath\\\":\\\"TestDINT\\\",\\\"dataType\\\":\\\"DInt\\\"}}\"," +
$"\"WriteIdempotent\":false,\"DeviceName\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}",
Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
// EXACT, not ShouldContain. The probe's two green messages are "CIP session OK" (the tag was
// read) and "CIP session OK (controller reachable; probe tag not found)" (the fallback), and
// `ShouldContain` matches both — so it would have gone green again the moment the config shape
// was merely well-formed enough to reach the controller, whether or not the authored tag was
// ever resolved. Reading the authored tag is the entire point of this test.
result.Message.ShouldBe("CIP session OK");
}
/// <summary>
/// Test Connect on a device with NO authored tags — which is every AB CIP device at the moment
/// an operator first presses the button — must report the controller as reachable.
/// </summary>
/// <remarks>
/// The probe falls back to a name no controller defines, so the green answer arrives as a CIP
/// "tag does not exist" (0x04 → libplctag <c>ErrorNotFound</c>), which
/// <c>AbCipDriverProbe.IsReachableStatus</c> classifies as reachable. Until 2026-08-01 that
/// classification had never been exercised against any backend: <c>ab_server</c> answered an
/// unresolvable tag path with 0x03 (<c>ErrorBadParam</c>) where a Logix controller answers 0x04,
/// so the fixture reported a healthy sim as a failed handshake and the discrepancy was written
/// down as a fixture limitation rather than fixed. It is now fixed in the vendored patch —
/// <c>Docker/patches/0002-ab_server-answer-tag-not-found-with-cip-0x04.patch</c> — so this test
/// needs the fixture rebuilt from that patch, not merely running.
/// </remarks>
[Fact]
public async Task AbCip_WithNoAuthoredTags_ReportsTheControllerReachable()
{
SkipUnless(DockerHost, AbCipPort);
var result = await new AbCipDriverProbe().ProbeAsync(
$"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}],\"RawTags\":[]}}",
Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
// EXACT for the same reason as the test above, and here it carries the extra weight of proving
// WHICH green this is: the fallback's "probe tag not found", not a tag that was actually read.
result.Message.ShouldBe("CIP session OK (controller reachable; probe tag not found)");
}
}