260 lines
11 KiB
C#
260 lines
11 KiB
C#
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();
|
|
}
|
|
}
|