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; /// /// Unit tests for the reverse-connect (server-initiated) path (PR-11). The driver /// exposes two test seams — ReverseConnectWaitHookForTest and /// ReverseConnectSessionFactoryForTest — 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. /// [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(null!); }; await Should.ThrowAsync(() => 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(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(() => 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(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(() => 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(() => 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(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"); } /// /// Bare-bones test double. The interface /// surface is small (EndpointUrl + ServerUri + Handle) so a hand-rolled fake /// keeps unit tests independent of any specific mocking library. /// 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(); } }