Merge pull request 'OpcUaClient integration fixture � opc-plc in Docker (#215)' (#161) from opcuaclient-opc-plc-fixture into v2

This commit was merged in pull request #161.
This commit is contained in:
2026-04-20 11:45:24 -04:00
8 changed files with 454 additions and 12 deletions

View File

@@ -41,6 +41,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>

View File

@@ -3,24 +3,55 @@
Coverage map + gap inventory for the OPC UA Client (gateway / aggregation)
driver.
**TL;DR: there is no integration fixture.** Tests mock the OPC UA SDK's
`Session` + `Subscription` types directly; there is no upstream OPC UA
server standup in CI. The irony is not lost — this repo *is* an OPC UA
server, and the integration fixtures for `OpcUaApplicationHost`
(`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` +
`OpcUaEquipmentWalkerIntegrationTests.cs`) stand up the server-side stack
end-to-end. The client-side driver could in principle wire against one of
those, but doesn't today.
**TL;DR:** Wire-level coverage now exists via
[opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc) — Microsoft
Industrial IoT's OPC UA PLC simulator running in Docker (task #215). Real
Secure Channel, real Session, real MonitoredItem exchange against an
independent server implementation. Unit tests still carry the exhaustive
capability matrix (cert auth / security policies / reconnect / failover /
attribute mapping). Gaps remaining: upstream-server-specific quirks
(historian aggregates, typed ConditionType events, SDK-publish-queue edge
behavior under load) — opc-plc uses the same OPCFoundation stack internally
so fully-independent-stack coverage needs `open62541/open62541` as a second
image (follow-up).
## What the fixture is
Nothing at the integration layer.
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is unit-only. Tests
inject fakes through the driver's construction path; the
**Integration layer** (task #215):
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` stands up
`mcr.microsoft.com/iotedge/opc-plc:2.14.10` via `Docker/docker-compose.yml`
on `opc.tcp://localhost:50000`. `OpcPlcFixture` probes the port at
collection init + skips tests with a clear message when the container's
not running (matches the Modbus/pymodbus + S7/python-snap7 skip pattern).
Docker is the launcher — no PowerShell wrapper needed because opc-plc
ships pre-containerized. Compose-file flags: `--ut` (unsecured transport
advertised), `--aa` (auto-accept client certs — opc-plc's cert trust store
resets on each spin-up), `--alm` (alarm simulation for IAlarmSource
follow-up coverage), `--pn=50000` (port).
**Unit layer**:
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is still the primary
coverage. Tests inject fakes through the driver's construction path; the
OPCFoundation.NetStandard `Session` surface is wrapped behind an interface
the tests mock.
## What it actually covers (unit only)
## What it actually covers
### Integration (opc-plc Docker, task #215)
- `OpcUaClientSmokeTests.Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack`
full Secure Channel + Session + `ns=3;s=StepUp` Read round-trip
- `OpcUaClientSmokeTests.Client_reads_batch_of_varied_types_from_live_simulator`
batch Read of UInt32 / Int32 / Boolean; asserts `bool`-specific Variant
decoding to catch a common attribute-mapping regression
- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server`
real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every
100 ms); asserts `OnDataChange` fires within 3 s of subscribe
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
`IHostConnectivityProbe` (via the Secure Channel exchange).
### Unit
The surface is broad because `OpcUaClientDriver` is the richest-capability
driver in the fleet (it's a gateway for another OPC UA server, so it

View File

@@ -0,0 +1,103 @@
# opc-plc Docker fixture
[Microsoft Industrial IoT's opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc)
— pinned Docker image that stands up an OPC UA server at
`opc.tcp://localhost:50000` with step-up counters, random nodes, alarm
simulation, and other canonical simulated shapes. Replaces the PowerShell
launcher pattern used by the Modbus / S7 fixtures — Docker is the launcher
here since opc-plc ships pre-containerized.
| File | Purpose |
|---|---|
| [`docker-compose.yml`](docker-compose.yml) | Service definition for `otopcua-opc-plc` — image pin, port map, command flags. |
| (this file) | How to run it. |
## Install
Docker Desktop (Windows) or the docker CLI + daemon (Linux/macOS). Per
`CLAUDE.md` Phase 1 decision #134 the dev box already has Docker Desktop
configured with the WSL 2 backend — nothing new to install.
## Run
From the repo root:
```powershell
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests\Docker\docker-compose.yml up
```
Or from this folder:
```powershell
docker compose up
```
First run pulls the image (~250 MB). Startup takes ~5-10 seconds; the
healthcheck in the compose file surfaces ready state in `docker ps`.
To run detached (CI pattern):
```powershell
docker compose up -d
```
Stop with `docker compose down` (removes the container) or `docker compose stop`
(keeps it for fast restart).
## Endpoint
- Default: `opc.tcp://localhost:50000`
- Override by setting `OPCUA_SIM_ENDPOINT` before `dotnet test` — e.g. point
at a real OPC UA server in the lab, or at a different Docker host.
## What opc-plc advertises
Command flags in `docker-compose.yml` enable:
- `--pn=50000` — OPC UA endpoint on port 50000
- `--ut` — unsecured transport endpoint advertised (SecurityPolicy=None).
Secured policies are still on the endpoint list; `--ut` just adds an
unsecured option.
- `--aa` — auto-accept client certs (opc-plc's cert trust store lives
inside the container + resets each spin-up, so without this the driver's
first contact would be rejected).
- `--alm` — alarm simulation enabled; opc-plc publishes
`TripAlarmType`, `ExclusiveDeviationAlarmType`,
`NonExclusiveLevelAlarmType`, and `DialogConditionType` events.
Not turned on (but available via compose-file tweaks):
- `--daa` — disable anonymous auth; forces username or cert tokens. Flip
on when username-auth / cert-auth smoke tests land.
- `--fn` / `--fr` / `--ft` — fast-node variants (100 / 1 000 / 10 000 Hz
update rates) for subscription-stress coverage. Not needed for smoke.
- `--sn` / `--sr` — slow-node / special-shape coverage.
## Run the integration tests
In a separate shell, with the simulator running:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests
```
Tests auto-skip with a clear `SkipReason` when `localhost:50000` isn't
reachable within 2 seconds (`OpcPlcFixture`).
## Known limitations
opc-plc uses the OPCFoundation.NetStandard stack internally — same as
our driver. That means bugs common to the stack itself are **not** caught
by this fixture; the follow-up to add `open62541/open62541` as a second
independent-stack image (task tracked in #215's follow-ups) would close
that.
See [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)
for the full coverage map + what's still trusted from field deployments.
## References
- [opc-plc GitHub](https://github.com/Azure-Samples/iot-edge-opc-plc)
- [mcr.microsoft.com/iotedge/opc-plc tags](https://mcr.microsoft.com/v2/iotedge/opc-plc/tags/list)
- [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)

View File

@@ -0,0 +1,45 @@
# opc-plc — OPC UA PLC simulator from Microsoft Industrial IoT.
# https://github.com/Azure-Samples/iot-edge-opc-plc
#
# Why pinned: MCR tags only go forward; keeping the suite reproducible means
# we test against a known feature surface. Bump deliberately alongside a
# driver-side change that needs the newer image.
services:
opc-plc:
image: mcr.microsoft.com/iotedge/opc-plc:2.14.10
container_name: otopcua-opc-plc
restart: "no"
ports:
- "50000:50000"
command:
# --pn: Bind port 50000 (opc-plc default; matches fixture default)
# --ut: Advertise an Unsecured transport endpoint (SecurityPolicy=None).
# Tests that need signed/encrypted endpoints pick those off the
# negotiated endpoint list separately — opc-plc always advertises
# the secure policies even with --ut on.
# --aa: Auto-accept client certs. Tests wouldn't otherwise survive the
# first contact because opc-plc's cert trust store lives inside
# the container + resets each spin-up.
# --daa: Disable anonymous auth — forces the driver to go through the
# Anonymous user-token policy negotiation rather than opc-plc's
# "no auth required" short-circuit. Would flip to username/cert
# if we needed that coverage.
# Commented out for first-pass smoke; flip on when the cert-auth
# and username-auth smoke tests land.
# --alm: Turn on alarm simulation (TripAlarm / ExclusiveDeviation /
# NonExclusiveLevel / DialogCondition). Closes the IAlarmSource
# gap the OpcUaClient-Test-Fixture doc calls out.
- "--pn=50000"
- "--ut"
- "--aa"
- "--alm"
# - "--daa"
healthcheck:
# opc-plc doesn't expose an HTTP health endpoint by default; use a TCP
# probe via a shell the base image ships with. The fixture does its own
# TCP probe but healthcheck surfaces status in `docker ps` for humans.
test: ["CMD-SHELL", "netstat -an | grep -q ':50000.*LISTEN' || exit 1"]
interval: 5s
timeout: 2s
retries: 10
start_period: 10s

View File

@@ -0,0 +1,97 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Reachability probe for an <c>opc-plc</c> simulator (Microsoft Industrial IoT's
/// OPC UA PLC from <c>mcr.microsoft.com/iotedge/opc-plc</c>) or any real OPC UA
/// server the <c>OPCUA_SIM_ENDPOINT</c> env var points at. Parses
/// <c>OPCUA_SIM_ENDPOINT</c> (default <c>opc.tcp://localhost:50000</c>),
/// TCP-connects to the resolved host:port at collection init, and records a
/// <see cref="SkipReason"/> on failure. Tests call <c>Assert.Skip</c> on that, so
/// `dotnet test` stays green when Docker isn't running the simulator — mirrors the
/// <see cref="ModbusSimulatorFixture"/> / <c>Snap7ServerFixture</c> pattern.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why opc-plc over loopback against our own server</b> — (1) independent
/// cert chain + user-token handling catches interop bugs loopback can't;
/// (2) built-in alarm ConditionType + history simulation gives
/// <see cref="Core.Abstractions.IAlarmSource"/> +
/// <see cref="Core.Abstractions.IHistoryProvider"/> coverage without a custom
/// driver fake; (3) pinned image tag fixes the test surface in a way our own
/// evolving server wouldn't. Follow-up: add <c>open62541/open62541</c> as a
/// second image once this lands, for fully-independent-stack interop.
/// </para>
/// <para>
/// Endpoint URL contract: parser strips the <c>opc.tcp://</c> scheme + resolves
/// host + port for the liveness probe only. The real test session always
/// dials the full endpoint URL via <see cref="OpcUaClientDriverOptions.EndpointUrl"/>
/// so cert negotiation + security-policy selection run end-to-end.
/// </para>
/// </remarks>
public sealed class OpcPlcFixture : IAsyncDisposable
{
private const string DefaultEndpoint = "opc.tcp://localhost:50000";
private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT";
/// <summary>Full <c>opc.tcp://host:port</c> URL the driver session should connect to.</summary>
public string EndpointUrl { get; }
public string Host { get; }
public int Port { get; }
public string? SkipReason { get; }
public OpcPlcFixture()
{
EndpointUrl = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
(Host, Port) = ParseHostPort(EndpointUrl);
try
{
using var client = new TcpClient(AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(Host)
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"opc-plc simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}.";
}
}
catch (Exception ex)
{
SkipReason = $"opc-plc simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}.";
}
}
/// <summary>
/// Parse "opc.tcp://host:port[/path]" → (host, port). Defaults to port 4840
/// (OPC UA standard) when the URL omits the port, but opc-plc's default is
/// 50000 so DefaultEndpoint carries it explicitly.
/// </summary>
private static (string Host, int Port) ParseHostPort(string endpointUrl)
{
const string scheme = "opc.tcp://";
var body = endpointUrl.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)
? endpointUrl[scheme.Length..]
: endpointUrl;
var slash = body.IndexOf('/');
if (slash >= 0) body = body[..slash];
var colon = body.IndexOf(':');
if (colon < 0) return (body, 4840);
var host = body[..colon];
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, 4840);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class OpcPlcCollection : Xunit.ICollectionFixture<OpcPlcFixture>
{
public const string Name = "OpcPlc";
}

View File

@@ -0,0 +1,38 @@
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Driver-side configuration + well-known opc-plc node identifiers that the smoke
/// tests address. Node IDs are stable across opc-plc releases — the simulator
/// guarantees the same <c>ns=3;s=...</c> names shipped since v1.0. If a release
/// bump breaks these, the fixture's pinned image tag needs a coordinated bump.
/// </summary>
public static class OpcPlcProfile
{
/// <summary>opc-plc monotonically-increasing UInt32; ticks once per second under default opts.</summary>
public const string StepUp = "ns=3;s=StepUp";
/// <summary>opc-plc random Int32 node; new value ~every 100ms.</summary>
public const string RandomSignedInt32 = "ns=3;s=RandomSignedInt32";
/// <summary>opc-plc alternating boolean; flips every second.</summary>
public const string AlternatingBoolean = "ns=3;s=AlternatingBoolean";
/// <summary>opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests.</summary>
public const string FastUInt1 = "ns=3;s=FastUInt1";
public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new()
{
EndpointUrl = endpointUrl,
SecurityPolicy = OpcUaSecurityPolicy.None,
SecurityMode = OpcUaSecurityMode.None,
AuthType = OpcUaAuthType.Anonymous,
// opc-plc auto-accepts client certs (--aa) but we still present one; trust the
// server's cert back since the simulator regenerates it each container spin-up
// and there's no meaningful chain to validate against.
AutoAcceptCertificates = true,
Timeout = TimeSpan.FromSeconds(10),
SessionTimeout = TimeSpan.FromSeconds(30),
};
}

View File

@@ -0,0 +1,92 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// End-to-end smoke against a live <c>opc-plc</c> (task #215). Drives the real
/// OPC UA Secure Channel + Session + MonitoredItem exchange — no mocks. Every
/// test here proves a capability surface that loopback against our own server
/// couldn't exercise cleanly: real cert negotiation, real endpoint descriptions,
/// real simulated nodes that change without a write.
/// </summary>
[Collection(OpcPlcCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "opc-plc")]
public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
{
[Fact]
public async Task Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-read");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
[OpcPlcProfile.StepUp], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(1);
snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end");
snapshots[0].Value.ShouldNotBeNull("StepUp always has a current value");
}
[Fact]
public async Task Client_reads_batch_of_varied_types_from_live_simulator()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-batch");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
[OpcPlcProfile.StepUp, OpcPlcProfile.RandomSignedInt32, OpcPlcProfile.AlternatingBoolean],
TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(3);
foreach (var s in snapshots)
{
s.StatusCode.ShouldBe(0u);
s.Value.ShouldNotBeNull();
}
// AlternatingBoolean should decode as a bool specifically — catches a common
// attribute-mapping regression where the driver stringifies variant values.
snapshots[2].Value.ShouldBeOfType<bool>();
}
[Fact]
public async Task Client_subscribe_receives_StepUp_data_changes_from_live_server()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-sub");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var observed = new List<DataChangeEventArgs>();
var gate = new SemaphoreSlim(0);
drv.OnDataChange += (_, e) =>
{
lock (observed) observed.Add(e);
gate.Release();
};
var handle = await drv.SubscribeAsync(
[OpcPlcProfile.FastUInt1], TimeSpan.FromMilliseconds(250),
TestContext.Current.CancellationToken);
// FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver.
// Wait up to 3 s to tolerate container warm-up + first-publish delay.
var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
got.ShouldBeTrue("opc-plc FastUInt1 must publish at least one data change within 3s");
int observedCount;
lock (observed) observedCount = observed.Count;
observedCount.ShouldBeGreaterThan(0);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>