@@ -43,3 +43,41 @@ services:
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
# opc-plc-rc — reverse-connect (server-initiated) variant. The simulator
|
||||
# acts as the OPC UA server but, unlike the regular service above, it dials
|
||||
# OUT to the client's listener URL instead of accepting an inbound dial.
|
||||
# Mirrors the OT-DMZ topology where the plant firewall only permits
|
||||
# outbound traffic from the upstream server. The driver-side test fixture
|
||||
# binds opc.tcp://0.0.0.0:4844 and waits for opc-plc-rc to ReverseHello.
|
||||
#
|
||||
# `--rc` is opc-plc's reverse-connect knob — value is the client URL the
|
||||
# simulator should dial when it has no inbound connection. host.docker.internal
|
||||
# is the docker-for-windows / docker-for-mac shorthand for the host's IP;
|
||||
# on Linux hosts use --add-host=host.docker.internal:host-gateway.
|
||||
opc-plc-rc:
|
||||
image: mcr.microsoft.com/iotedge/opc-plc:2.14.10
|
||||
container_name: otopcua-opc-plc-rc
|
||||
restart: "no"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command:
|
||||
# --pn=50001: bind on a different port so this container can run alongside
|
||||
# the dial-mode simulator above. Reverse-connect doesn't require
|
||||
# the client to know this port (the simulator is the dialer)
|
||||
# but it still has to bind one for any incoming admin queries.
|
||||
# --rc: reverse-connect target — the simulator dials this URL and
|
||||
# presents its OPC UA endpoint over the inbound socket. Must
|
||||
# point at the test runner's listener.
|
||||
# --ut/--aa/--alm: same flags as the regular profile.
|
||||
- "--pn=50001"
|
||||
- "--rc=opc.tcp://host.docker.internal:4844"
|
||||
- "--ut"
|
||||
- "--aa"
|
||||
- "--alm"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "netstat -an | grep -q ':50001.*LISTEN' || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Fixture for the reverse-connect variant of opc-plc (PR-11). Unlike the
|
||||
/// dial-mode <see cref="OpcPlcFixture"/>, the simulator here is the dialer:
|
||||
/// it reaches OUT to the test runner's listener URL on
|
||||
/// <c>opc.tcp://host.docker.internal:4844</c>. The fixture's job is to
|
||||
/// advertise the listener URL the test should bind and provide a clear
|
||||
/// skip reason when the docker-compose service isn't running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why no port-probe</b>: the conventional fixture probes the simulator's
|
||||
/// server port to detect docker-up. In reverse-connect the simulator opens
|
||||
/// no inbound port — it's a pure dialer — so a probe would always fail.
|
||||
/// Tests that want a hard skip should look at <see cref="SkipReason"/>
|
||||
/// which is set from the <c>OPCUA_RC_SIM</c> env var (any value =
|
||||
/// "simulator running"; absent = skip).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The "shared listener URL" model is enforced by the driver's
|
||||
/// <c>ReverseConnectListener</c> singleton — multiple smoke tests in the
|
||||
/// same xunit assembly share one listener instance even if they run in
|
||||
/// parallel. Tests should pick distinct <c>ExpectedServerUri</c> values to
|
||||
/// demultiplex inbound connections.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcPlcReverseConnectFixture : IAsyncDisposable
|
||||
{
|
||||
private const string DefaultListenerUrl = "opc.tcp://0.0.0.0:4844";
|
||||
private const string EnvVar = "OPCUA_RC_SIM";
|
||||
|
||||
/// <summary>The listener URL the driver should bind for incoming reverse-connect dials.</summary>
|
||||
public string ListenerUrl { get; } = DefaultListenerUrl;
|
||||
|
||||
/// <summary>Skip reason when the reverse-connect simulator isn't available; null when ready.</summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public OpcPlcReverseConnectFixture()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(EnvVar)))
|
||||
{
|
||||
SkipReason =
|
||||
$"Reverse-connect smoke skipped — set {EnvVar}=1 once `docker compose -f Docker/docker-compose.yml up opc-plc-rc` is healthy. " +
|
||||
"The dialer needs host.docker.internal to reach this machine — verify Docker Desktop's network mode supports it.";
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class OpcPlcReverseConnectCollection : Xunit.ICollectionFixture<OpcPlcReverseConnectFixture>
|
||||
{
|
||||
public const string Name = "OpcPlcReverseConnect";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-connect smoke (PR-11). Asserts the driver binds a listener at the
|
||||
/// configured URL and accepts an inbound dial from <c>opc-plc-rc</c> (the
|
||||
/// reverse-connect variant of Microsoft Industrial IoT's OPC UA simulator).
|
||||
/// The session that comes up should be functionally identical to a dialled
|
||||
/// session — same Read / Subscribe surface — but the transport direction is
|
||||
/// server → client instead of client → server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Build-only by default</b>: the test is gated on <c>OPCUA_RC_SIM</c>
|
||||
/// + the docker-compose <c>opc-plc-rc</c> service. CI runs that don't
|
||||
/// spin up the dialer skip with a clear message; the build still has to
|
||||
/// compile so wire-level regressions in the reverse-connect code path are
|
||||
/// caught even when the dialer isn't around.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(OpcPlcReverseConnectCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "opc-plc-rc")]
|
||||
public sealed class OpcUaClientReverseConnectSmokeTests(OpcPlcReverseConnectFixture rc)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Driver_accepts_reverse_connect_from_opc_plc_rc_simulator()
|
||||
{
|
||||
if (rc.SkipReason is not null) Assert.Skip(rc.SkipReason);
|
||||
|
||||
var options = new OpcUaClientDriverOptions
|
||||
{
|
||||
// Conventional EndpointUrl still required — the driver derives the
|
||||
// EndpointDescription from it for the session-create call. The actual
|
||||
// dial direction is flipped by ReverseConnect.Enabled below.
|
||||
EndpointUrl = "opc.tcp://opc-plc-rc:50001",
|
||||
SecurityPolicy = OpcUaSecurityPolicy.None,
|
||||
SecurityMode = OpcUaSecurityMode.None,
|
||||
AuthType = OpcUaAuthType.Anonymous,
|
||||
AutoAcceptCertificates = true,
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
SessionTimeout = TimeSpan.FromSeconds(60),
|
||||
ReverseConnect = new ReverseConnectOptions(
|
||||
Enabled: true,
|
||||
ListenerUrl: rc.ListenerUrl,
|
||||
// null = accept any upstream — only one is dialling this listener in
|
||||
// the smoke test, so there's no demux to worry about.
|
||||
ExpectedServerUri: null),
|
||||
};
|
||||
|
||||
await using var drv = new OpcUaClientDriver(options, "opcua-rc-smoke");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// The session is up via reverse path — assert a steady-state read works.
|
||||
var snapshots = await drv.ReadAsync(
|
||||
[OpcPlcProfile.StepUp], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(1);
|
||||
snapshots[0].StatusCode.ShouldBe(0u,
|
||||
"reverse-connect session must round-trip a Read identically to a dialled session");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Text.Json;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the reverse-connect (server-initiated) path (PR-11). The driver
|
||||
/// exposes two test seams — <c>ReverseConnectWaitHookForTest</c> and
|
||||
/// <c>ReverseConnectSessionFactoryForTest</c> — that bypass the SDK's port-bind +
|
||||
/// real-transport machinery so we can assert the wiring without standing up an
|
||||
/// actual reverse-connect TCP listener.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReverseConnectTests : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
// Each test acquires/releases its own listener entries — but if a test fails
|
||||
// mid-way the static dictionary in ReverseConnectListener could be left dirty.
|
||||
// Nothing else here to do; the dictionary is internal and tests target unique
|
||||
// listener URLs to avoid cross-test contamination.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReverseConnect_options_default_to_disabled()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.ReverseConnect.ShouldNotBeNull();
|
||||
opts.ReverseConnect.Enabled.ShouldBeFalse(
|
||||
"default deployments dial outbound — reverse-connect is opt-in for OT-DMZ networks");
|
||||
opts.ReverseConnect.ListenerUrl.ShouldBeNull();
|
||||
opts.ReverseConnect.ExpectedServerUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_reverse_connect_uses_existing_dial_path()
|
||||
{
|
||||
// Initialize against an unreachable endpoint with reverse-connect off; we expect
|
||||
// the failover sweep to fail (since 192.0.2.x is reserved-for-documentation and
|
||||
// routes nowhere). The point is to assert the dial path runs — not the listener
|
||||
// path — by checking the wait hook was never invoked.
|
||||
var waitInvoked = false;
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://192.0.2.1:4840",
|
||||
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(200),
|
||||
Timeout = TimeSpan.FromMilliseconds(200),
|
||||
ReverseConnect = new ReverseConnectOptions(Enabled: false),
|
||||
};
|
||||
await using var drv = new OpcUaClientDriver(opts, "opcua-rc-disabled");
|
||||
drv.ReverseConnectWaitHookForTest = (_, _, _) =>
|
||||
{
|
||||
waitInvoked = true;
|
||||
return Task.FromResult<ITransportWaitingConnection>(null!);
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
waitInvoked.ShouldBeFalse(
|
||||
"reverse-connect wait hook must not run when ReverseConnect.Enabled=false");
|
||||
drv.ReverseListenerForTest.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_reverse_connect_invokes_wait_with_expected_server_uri()
|
||||
{
|
||||
Uri? capturedListener = null;
|
||||
string? capturedServerUri = null;
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://upstream:4840",
|
||||
ReverseConnect = new ReverseConnectOptions(
|
||||
Enabled: true,
|
||||
ListenerUrl: "opc.tcp://0.0.0.0:14844",
|
||||
ExpectedServerUri: "urn:upstream-plc:server"),
|
||||
Timeout = TimeSpan.FromMilliseconds(500),
|
||||
};
|
||||
await using var drv = new OpcUaClientDriver(opts, "opcua-rc-wait");
|
||||
|
||||
drv.ReverseConnectWaitHookForTest = (uri, serverUri, _) =>
|
||||
{
|
||||
capturedListener = uri;
|
||||
capturedServerUri = serverUri;
|
||||
// Return null — the session-factory hook below short-circuits before it
|
||||
// gets used so a null connection can't blow up downstream.
|
||||
return Task.FromResult<ITransportWaitingConnection>(new FakeTransportWaitingConnection());
|
||||
};
|
||||
|
||||
// Halt the path immediately after WaitForServerAsync — the only thing we're
|
||||
// asserting in this test is that wait runs with the right args. Throwing here
|
||||
// means InitializeAsync transitions to Faulted but the assertions on the captured
|
||||
// state still hold.
|
||||
drv.ReverseConnectSessionFactoryForTest = (_, _, _, _, _) =>
|
||||
throw new InvalidOperationException("test stops before session create");
|
||||
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
capturedListener.ShouldNotBeNull();
|
||||
capturedListener!.ToString().ShouldContain("14844");
|
||||
capturedServerUri.ShouldBe("urn:upstream-plc:server");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_reverse_connect_passes_connection_into_session_factory()
|
||||
{
|
||||
// Assert that the ITransportWaitingConnection returned from WaitForServerAsync
|
||||
// flows verbatim into the session-create hook. This is the load-bearing wiring
|
||||
// — a swap of variables at this seam would silently route the wrong connection
|
||||
// into the SDK and the failure would surface only at session activation time.
|
||||
var stubConnection = new FakeTransportWaitingConnection();
|
||||
ITransportWaitingConnection? observedConnection = null;
|
||||
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://upstream:4840",
|
||||
ReverseConnect = new ReverseConnectOptions(
|
||||
Enabled: true,
|
||||
ListenerUrl: "opc.tcp://0.0.0.0:14845"),
|
||||
};
|
||||
await using var drv = new OpcUaClientDriver(opts, "opcua-rc-conn");
|
||||
drv.ReverseConnectWaitHookForTest = (_, _, _) =>
|
||||
Task.FromResult<ITransportWaitingConnection>(stubConnection);
|
||||
drv.ReverseConnectSessionFactoryForTest = (_, conn, _, _, _) =>
|
||||
{
|
||||
observedConnection = conn;
|
||||
// Throw to short-circuit the post-create wiring (KeepAlive, diagnostics)
|
||||
// — this test only cares about the connection plumbing.
|
||||
throw new InvalidOperationException("test stops at factory");
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
observedConnection.ShouldBeSameAs(stubConnection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_reverse_connect_without_listener_url_fails_fast()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://upstream:4840",
|
||||
ReverseConnect = new ReverseConnectOptions(Enabled: true, ListenerUrl: null),
|
||||
};
|
||||
await using var drv = new OpcUaClientDriver(opts, "opcua-rc-missing-listener");
|
||||
|
||||
var ex = await Should.ThrowAsync<Exception>(() =>
|
||||
drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
// The actual InvalidOperationException is wrapped in nothing — InitializeAsync
|
||||
// re-throws after marking Faulted. Inspect the inner-most message either way.
|
||||
var msg = ex is AggregateException agg ? string.Join("|", agg.InnerExceptions.Select(e => e.Message)) : ex.Message;
|
||||
msg.ShouldContain("ListenerUrl");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Listener_acquire_release_is_refcounted_per_url()
|
||||
{
|
||||
var url = "opc.tcp://0.0.0.0:14001";
|
||||
|
||||
var a = ReverseConnectListener.AcquireForTest(url);
|
||||
var b = ReverseConnectListener.AcquireForTest(url);
|
||||
|
||||
// Same URL = same underlying entry. Two drivers sharing a listener must
|
||||
// see the same SDK manager, otherwise we'd be double-binding the port.
|
||||
a.ShouldBeSameAs(b);
|
||||
a.RefCountForTest.ShouldBe(2);
|
||||
|
||||
a.Release();
|
||||
b.RefCountForTest.ShouldBe(1, "first release drops one reference, listener stays alive");
|
||||
|
||||
b.Release();
|
||||
ReverseConnectListener.InstanceCountForTest(url).ShouldBe(0,
|
||||
"last release tears down the listener so the port can be re-bound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Listener_distinct_urls_get_distinct_entries()
|
||||
{
|
||||
var a = ReverseConnectListener.AcquireForTest("opc.tcp://0.0.0.0:14010");
|
||||
var b = ReverseConnectListener.AcquireForTest("opc.tcp://0.0.0.0:14011");
|
||||
|
||||
a.ShouldNotBeSameAs(b,
|
||||
"different listener URLs must own independent SDK managers — sharing would conflate inbound connections from unrelated upstreams");
|
||||
|
||||
a.Release();
|
||||
b.Release();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposal_releases_reverse_listener_reference()
|
||||
{
|
||||
var listenerUrl = "opc.tcp://0.0.0.0:14020";
|
||||
|
||||
// Pre-acquire a reference so we can observe the refcount drop when the driver
|
||||
// shuts down. The driver uses a real Acquire (not the test seam) only when the
|
||||
// wait hook is null — but we don't run init here, we just assert the listener
|
||||
// hand-off model.
|
||||
var pre = ReverseConnectListener.AcquireForTest(listenerUrl);
|
||||
pre.RefCountForTest.ShouldBe(1);
|
||||
|
||||
// Simulate: driver came up, acquired listener, shut down → refcount returns
|
||||
// to 1 (just our pre).
|
||||
var drvHeld = ReverseConnectListener.AcquireForTest(listenerUrl);
|
||||
drvHeld.RefCountForTest.ShouldBe(2);
|
||||
drvHeld.Release();
|
||||
pre.RefCountForTest.ShouldBe(1);
|
||||
|
||||
pre.Release();
|
||||
ReverseConnectListener.InstanceCountForTest(listenerUrl).ShouldBe(0);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DTO_json_round_trip_preserves_reverse_connect_settings()
|
||||
{
|
||||
// The driver host deserializes OpcUaClientDriverOptions from JSON, so a missing
|
||||
// System.Text.Json contract on a new section would silently lose the operator's
|
||||
// config. Round-trip a populated ReverseConnectOptions and check it survives
|
||||
// the JSON boundary.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = "opc.tcp://upstream:4840",
|
||||
ReverseConnect = new ReverseConnectOptions(
|
||||
Enabled: true,
|
||||
ListenerUrl: "opc.tcp://0.0.0.0:4844",
|
||||
ExpectedServerUri: "urn:plant:upstream-server"),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(opts);
|
||||
var roundTripped = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json);
|
||||
|
||||
roundTripped.ShouldNotBeNull();
|
||||
roundTripped!.ReverseConnect.ShouldNotBeNull();
|
||||
roundTripped.ReverseConnect.Enabled.ShouldBeTrue();
|
||||
roundTripped.ReverseConnect.ListenerUrl.ShouldBe("opc.tcp://0.0.0.0:4844");
|
||||
roundTripped.ReverseConnect.ExpectedServerUri.ShouldBe("urn:plant:upstream-server");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bare-bones <see cref="ITransportWaitingConnection"/> test double. The interface
|
||||
/// surface is small (EndpointUrl + ServerUri + Handle) so a hand-rolled fake
|
||||
/// keeps unit tests independent of any specific mocking library.
|
||||
/// </summary>
|
||||
private sealed class FakeTransportWaitingConnection : ITransportWaitingConnection
|
||||
{
|
||||
public Uri EndpointUrl { get; } = new("opc.tcp://fake-upstream:4840");
|
||||
public string ServerUri => "urn:fake-upstream";
|
||||
public object Handle => new object();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user