/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using NUnit.Framework;
using Opc.Ua.Bindings;
namespace Opc.Ua.DataChannels.ClientServer.Tests
{
///
/// Tests the explicitly enabled native lane composition boundary.
///
[TestFixture]
public sealed class DataChannelClientServerCompositionTests
{
///
/// A lane cannot request a UACP buffer below the protocol minimum.
///
[Test]
public void LaneOptionsRejectInsufficientChunkSize()
{
var options = new DataChannelClientServerOptions { MaxChunkSize = 8191 };
Assert.Throws(() =>
new DataChannelClientServerClient(new TestTelemetry(), options));
}
///
/// Enforces independent chunk, encoded-window, lane and operation boundaries.
///
[TestCase(8192, 8205U, 1, 1, true)]
[TestCase(8192, 8204U, 1, 1, false)]
[TestCase(8191, 8205U, 1, 1, false)]
[TestCase(16777216, 16777229U, 128, 1, true)]
[TestCase(16777217, 16777230U, 128, 1, false)]
[TestCase(8192, 8205U, 0, 1, false)]
[TestCase(8192, 8205U, -1, 1, false)]
[TestCase(8192, 8205U, 129, 1, false)]
[TestCase(8192, 8205U, 1, 0, false)]
[TestCase(8192, 8205U, 1, -1, false)]
public void LaneOptionsPreserveExactBounds(int chunk, uint receive, int lanes, int operations, bool accepted)
{
var options = new DataChannelClientServerOptions
{
MaxChunkSize = chunk,
MaxReceiveBytes = receive,
MaxLanes = lanes,
MaxPendingRequests = operations
};
void create() => _ = new DataChannelClientServerClient(new TestTelemetry(), options);
if (accepted)
{
Assert.That(create, Throws.Nothing);
}
else
{
Assert.That(create, Throws.TypeOf());
}
}
///
/// Registration never creates a flow, replaces resource authorization or modifies another application's bindings.
///
[Test]
public async Task DirectAndDiCompositionPreserveExplicitOptIn()
{
var telemetry = new TestTelemetry();
var source = new Mock(MockBehavior.Strict);
var services = new ServiceCollection();
services.AddSingleton(telemetry);
services.AddSingleton(source.Object);
IOpcUaBuilder builder = services.AddOpcUa();
await using var quic = new DataChannelClientServerListener(DataChannelCarrierKind.Quic, telemetry);
await using var web = new DataChannelClientServerListener(DataChannelCarrierKind.WebTransport, telemetry);
builder.AddDataChannelClientServer()
.AddDataChannelClientServerListener(quic)
.AddDataChannelClientServerListener(web);
using ServiceProvider provider = services.BuildServiceProvider();
ITransportBindingRegistry registry = provider.GetRequiredService();
Assert.Multiple(() =>
{
Assert.That(provider.GetRequiredService(),
Is.TypeOf());
Assert.That(provider.GetRequiredService(), Is.SameAs(source.Object));
Assert.That(registry.GetListenerFactory("opc.quic"),
Is.TypeOf());
Assert.That(registry.GetListenerFactory("opc.wt"),
Is.TypeOf());
Assert.That(registry.GetListenerFactory("opc.tcp"), Is.TypeOf());
Assert.That(new DefaultTransportBindingRegistry().HasListenerFactory("opc.wt"), Is.False);
});
source.VerifyNoOtherCalls();
}
///
/// A UASC carrier cannot be used to recursively compose a native-only lane.
///
[TestCase(DataChannelCarrierKind.DedicatedUasc)]
[TestCase(DataChannelCarrierKind.SharedUasc)]
[TestCase((DataChannelCarrierKind)1000)]
public void ListenerRejectsNonNativeCarriers(DataChannelCarrierKind kind)
{
Assert.Throws(() =>
new DataChannelClientServerListener(kind, new TestTelemetry()));
}
///
/// Ordinary host registration cannot accidentally open the same virtual listener twice.
///
[Test]
public async Task ListenerFactoryTransfersExactlyOneListener()
{
var telemetry = new TestTelemetry();
await using var listener = new DataChannelClientServerListener(DataChannelCarrierKind.Quic, telemetry);
var factory = new DataChannelClientServerListenerFactory(listener);
Assert.That(factory.Create(telemetry), Is.SameAs(listener));
Assert.Throws(() => factory.Create(telemetry));
}
///
/// Invalid composition fails before constructing listeners, resolving policies or acquiring native resources.
///
[Test]
public async Task PublicCompositionRejectsMissingDependencies()
{
var telemetry = new TestTelemetry();
await using var listener = new DataChannelClientServerListener(DataChannelCarrierKind.Quic, telemetry);
Assert.Throws(() => new DataChannelClientServerClient(null!));
Assert.Throws(() =>
new DataChannelClientServerListener(DataChannelCarrierKind.Quic, null!));
Assert.Throws(() => new DataChannelClientServerListenerFactory(null!));
Assert.Throws(() =>
DataChannelClientServerBuilderExtensions.AddDataChannelClientServer(null!));
Assert.Throws(() =>
DataChannelClientServerBuilderExtensions.AddDataChannelClientServerListener(null!, listener));
var services = new ServiceCollection();
IOpcUaBuilder builder = services.AddOpcUa();
Assert.Throws(() => builder.AddDataChannelClientServerListener(null!));
var factory = new DataChannelClientServerListenerFactory(listener);
Assert.Throws(() => factory.Create(null!));
Assert.That(factory.Create(telemetry), Is.SameAs(listener), "Rejected creation must not consume ownership.");
}
///
/// Virtual listener startup succeeds while another owner already reserves the corresponding TCP port.
/// No request callback or native context is started by registering the ordinary Service dispatcher.
///
[TestCase(DataChannelCarrierKind.Quic)]
[TestCase(DataChannelCarrierKind.WebTransport)]
public async Task VirtualListenerDoesNotBindTcpPort(DataChannelCarrierKind kind)
{
var telemetry = new TestTelemetry();
using var reservedTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
reservedTcp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
var address = (IPEndPoint)reservedTcp.LocalEndPoint!;
await using var listener = new DataChannelClientServerListener(kind, telemetry);
var endpoint = new Uri($"{listener.UriScheme}://127.0.0.1:{address.Port}/ua");
var callback = new Mock(MockBehavior.Strict);
var settings = new TransportListenerSettings
{
Descriptions =
[
new EndpointDescription
{
EndpointUrl = endpoint.AbsoluteUri,
TransportProfileUri = Profiles.UaTcpTransport,
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
}
],
Configuration = EndpointConfiguration.Create(),
Factory = ServiceMessageContext.Create(telemetry).Factory,
NamespaceUris = new NamespaceTable(),
CertificateValidator = Mock.Of(),
ServerCertificates = Mock.Of()
};
await listener.OpenAsync(endpoint, settings, callback.Object).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(listener.ListenerId, Is.Not.Empty);
Assert.That(listener.EndpointUrl, Is.EqualTo(endpoint));
Assert.That(reservedTcp.LocalEndPoint, Is.EqualTo(address));
});
callback.VerifyNoOtherCalls();
}
///
/// A lane cannot select another flow profile, resource or context factory during inner HEL/OPN setup.
///
[TestCase(DataChannelCarrierKind.Quic, "profile")]
[TestCase(DataChannelCarrierKind.Quic, "resource")]
[TestCase(DataChannelCarrierKind.Quic, "factory")]
[TestCase(DataChannelCarrierKind.WebTransport, "profile")]
[TestCase(DataChannelCarrierKind.WebTransport, "resource")]
[TestCase(DataChannelCarrierKind.WebTransport, "factory")]
public async Task InnerListenerRejectsDataChannelComposition(DataChannelCarrierKind kind, string nested)
{
var telemetry = new TestTelemetry();
await using var listener = new DataChannelClientServerListener(kind, telemetry);
string resource = nested == "resource" ? "/flow/v1/shared" : "/ua";
var endpoint = new Uri($"{listener.UriScheme}://localhost:4840{resource}");
var factory = new Mock(MockBehavior.Strict);
var callback = new Mock(MockBehavior.Strict);
var settings = new TransportListenerSettings
{
Descriptions =
[
new EndpointDescription
{
EndpointUrl = endpoint.AbsoluteUri,
TransportProfileUri = nested == "profile"
? DataChannelProfiles.SharedUasc : Profiles.UaTcpTransport,
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
}
],
Configuration = EndpointConfiguration.Create(),
Factory = ServiceMessageContext.Create(telemetry).Factory,
NamespaceUris = new NamespaceTable(),
CertificateValidator = Mock.Of(),
ServerCertificates = Mock.Of(),
DataChannels = nested == "factory" ? factory.Object : null
};
if (nested == "factory")
{
await Assert.ThrowsAsync(async () =>
await listener.OpenAsync(endpoint, settings, callback.Object).ConfigureAwait(false))
.ConfigureAwait(false);
}
else
{
ServiceResultException? error = await Assert.ThrowsAsync(async () =>
await listener.OpenAsync(endpoint, settings, callback.Object).ConfigureAwait(false))
.ConfigureAwait(false);
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
}
factory.VerifyNoOtherCalls();
callback.VerifyNoOtherCalls();
}
private sealed class TestTelemetry() : TelemetryContextBase(NullLoggerFactory.Instance);
}
}