Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
T
Joseph Doherty df340b8bba fix(tests): three real failures the integration suite had stopped catching
None of these were fixture absence. Each is a guard that went quietly wrong and
then reported the wrong thing.

PrimaryGateFailoverTests — per-cluster mesh Phase 6 (a2c5d57f) moved
RedundancyStateActor out of WithOtOpcUaControlPlaneSingletons into its own
cluster-role-scoped registration, made from Program.cs's DRIVER branch. The
harness models a fused admin,driver node but was never updated, so from that
commit onward NO node it built spawned the singleton: nothing published
redundancy-state, both nodes sat at "role unknown" forever, and the test polled
45s and timed out. The test's own summary calls this its negative control for a
broken redundancy-topic subscribe — it fired correctly, at the harness rather
than at production. Second time this shape has bitten the harness; the
AddOtOpcUaRuntime note a few lines up records the first.

DriverProbeRegistrationTests — the expected driver set was a hand-written array
kept in sync by comment, and it drifted when Sql (9b5a9687) and Mqtt (f79d13e2)
shipped. It failed as "12 where 10 expected", which names neither driver and
reads as if the probes were wrong. It also could not catch the direction that
matters: a driver type registered with no probe passed silently, because a
hand-written list only grows when someone remembers, and this test exists for
the case where nobody did. Now derived from DriverTypeNames.All, both
directions, plus an explicit case-drift test for the two types whose AdminUI key
differs in case from the constant (TwinCat/TwinCAT, Focas/FOCAS) — which the
derived tests cannot cover, since they compare the constants to themselves.

DriverProbeHandshakeE2eTests.AbCip_Green_AgainstSim — the probe config still used
the retired v2 `Tags: [{DeviceHostAddress, TagPath}]` shape, so
AbCipDriverOptions.RawTags bound empty, the probe fell through to its
@raw_cpu_type system-tag fallback, and ab_server answered ErrorBadParam. The test
has therefore spent the whole v3 migration exercising the one path its own
comment says it does not exercise. Rewritten to the v3 RawTags shape, and the
assertion tightened from ShouldContain("CIP session OK") to an exact match:
the probe's two green messages both contain that substring, so the loose form
would go green again the moment the config merely reached the controller,
whether or not the authored tag resolved.

Falsified each: drop the MTConnect probe (2 red, by name), remove the harness
singleton registration (back to the 48s "role unknown" timeout), break the
RawTags key (back to ErrorBadParam).

Host.IntegrationTests 211 passed / 4 skipped / 0 failed, against live fixtures on
10.100.0.35.

Still red and NOT addressed here: the AbLegacy integration suite (4 tests). Its
fixture is genuinely down, but it hard-fails instead of skipping because the
AbCip ControlLogix container holds :44818 — the reachability guard cannot tell
"my ab_server" from "someone else's", so SkipReason stays null and SLC500-mode
PCCC runs against a ControlLogix. Needs either distinct fixture ports or a
family-aware guard; both are shared-infrastructure changes.

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

204 lines
10 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 (`@raw_cpu_type`) is NOT exercised here:
// ab_server answers an unknown/unsupported tag with libplctag ErrorBadParam (a REAL
// ControlLogix instead returns ErrorNotFound, which the probe classifies as
// reachable). Whether the `@raw_cpu_type` system-tag fallback is valid on a real
// ControlLogix is a hardware-gated follow-up (AbCipDriverOptions.cs flags it deferred).
// ⚠️ 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");
}
}