/* ========================================================================
* Copyright (c) 2005-2025 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.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client.TestFramework;
namespace Opc.Ua.Sessions.Tests
{
///
/// compliance tests for Session Service Set – Sessionless Invocation.
/// Verifies that discovery services (GetEndpoints, FindServers) can be
/// called without an established session.
///
[TestFixture]
[Category("Conformance")]
[Category("Session")]
[Category("SessionlessInvocation")]
public class SessionlessInvocationTests : TestFixture
{
[Test]
public async Task DiscoveryFailureDoesNotInterruptHealthyClientAsync()
{
using var reserved = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
reserved.Bind(new IPEndPoint(IPAddress.Loopback, 0));
int port = ((IPEndPoint)reserved.LocalEndPoint!).Port;
var refusedUrl = new Uri($"opc.tcp://127.0.0.1:{port}");
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
endpointConfiguration.OperationTimeout = 5000;
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using DiscoveryClient healthyClient = await DiscoveryClient.CreateAsync(
ServerUrl, endpointConfiguration, Telemetry, ct: timeout.Token).ConfigureAwait(false);
// Darwin can leave a connection pending while a non-listening socket owns the port.
// Release the reservation immediately before testing the refused connection.
reserved.Dispose();
await Assert.ThatAsync(async () =>
{
using DiscoveryClient unexpectedClient = await DiscoveryClient.CreateAsync(
refusedUrl, endpointConfiguration, Telemetry, ct: timeout.Token).ConfigureAwait(false);
}, Throws.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo((uint)StatusCodes.BadNotConnected)
.And.Property(nameof(Exception.InnerException)).TypeOf()).ConfigureAwait(false);
ArrayOf endpoints = await healthyClient.GetEndpointsAsync(
default, timeout.Token).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0));
Assert.That(timeout.IsCancellationRequested, Is.False);
}
[Test]
public async Task DiscoveryPreservesCallerCancellationAsync()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
await Assert.ThatAsync(async () =>
{
using DiscoveryClient unexpectedClient = await DiscoveryClient.CreateAsync(
ServerUrl, endpointConfiguration, Telemetry, ct: cancellation.Token).ConfigureAwait(false);
}, Throws.InstanceOf()).ConfigureAwait(false);
}
[Description("Call GetEndpoints via DiscoveryClient without an established session. The service should return Good with at least one endpoint.")]
[Test]
public async Task GetEndpointsWithoutSessionAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints, Is.Not.Null,
"GetEndpoints response should not be null.");
Assert.That(endpoints.Count, Is.GreaterThan(0),
"GetEndpoints without a session should return at least one endpoint.");
}
[Description("Call FindServers via DiscoveryClient without an established session. The service should return Good with at least one server.")]
[Test]
public async Task FindServersWithoutSessionAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf servers = await client.FindServersAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(servers, Is.Not.Null,
"FindServers response should not be null.");
Assert.That(servers.Count, Is.GreaterThan(0),
"FindServers without a session should return at least one server.");
}
[Description("Verify each endpoint returned by GetEndpoints has EndpointUrl, SecurityMode, and SecurityPolicyUri populated.")]
[Test]
public async Task GetEndpointsReturnsValidEndpointsAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0));
for (int i = 0; i < endpoints.Count; i++)
{
EndpointDescription ep = endpoints[i];
Assert.That(ep.EndpointUrl, Is.Not.Null.And.Not.Empty,
$"Endpoint[{i}] EndpointUrl should not be empty.");
Assert.That(ep.SecurityMode, Is.Not.EqualTo(MessageSecurityMode.Invalid),
$"Endpoint[{i}] SecurityMode should not be Invalid.");
Assert.That(ep.SecurityPolicyUri, Is.Not.Null.And.Not.Empty,
$"Endpoint[{i}] SecurityPolicyUri should not be empty.");
}
}
[Description("Verify each ApplicationDescription returned by FindServers has ApplicationUri, ApplicationName, and ApplicationType populated.")]
[Test]
public async Task FindServersReturnsValidApplicationDescriptionAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf servers = await client.FindServersAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(servers.Count, Is.GreaterThan(0));
for (int i = 0; i < servers.Count; i++)
{
ApplicationDescription app = servers[i];
Assert.That(app.ApplicationUri, Is.Not.Null.And.Not.Empty,
$"Server[{i}] ApplicationUri should not be empty.");
Assert.That(app.ApplicationName, Is.Not.Null,
$"Server[{i}] ApplicationName should not be null.");
Assert.That(app.ApplicationType, Is.Not.EqualTo((ApplicationType)(-1)),
$"Server[{i}] ApplicationType should be valid.");
}
}
[Description("Call GetEndpoints with a transport profile filter for UA TCP. All returned endpoints should match the requested transport profile.")]
[Test]
public async Task GetEndpointsWithProfileFilterAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0));
// Verify at least one endpoint uses the UA TCP transport profile
bool hasTcpTransport = false;
for (int i = 0; i < endpoints.Count; i++)
{
if (!string.IsNullOrEmpty(endpoints[i].TransportProfileUri) &&
endpoints[i].TransportProfileUri!.Contains("uatcp",
StringComparison.OrdinalIgnoreCase))
{
hasTcpTransport = true;
break;
}
}
Assert.That(hasTcpTransport, Is.True,
"At least one endpoint should use UA TCP transport profile.");
}
[Description("Call GetEndpoints multiple times sequentially without a session. All calls should succeed and return endpoints.")]
[Test]
public async Task GetEndpointsMultipleCallsInSequenceAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
for (int call = 0; call < 3; call++)
{
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0),
$"GetEndpoints call {call + 1} should return at least one endpoint.");
}
}
[Description("Call FindServers multiple times sequentially without a session. All calls should succeed and return servers.")]
[Test]
public async Task FindServersMultipleCallsInSequenceAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
for (int call = 0; call < 3; call++)
{
ArrayOf servers = await client.FindServersAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(servers.Count, Is.GreaterThan(0),
$"FindServers call {call + 1} should return at least one server.");
}
}
[Description("Verify GetEndpoints returns at least one endpoint, confirming the server supports sessionless discovery.")]
[Test]
public async Task GetEndpointsReturnsDifferentSecurityModesAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0),
"Server should return at least one endpoint.");
}
[Description("Verify that endpoints with security (not None) include a ServerCertificate.")]
[Test]
public async Task GetEndpointsReturnsServerCertificateAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0));
for (int i = 0; i < endpoints.Count; i++)
{
EndpointDescription ep = endpoints[i];
if (ep.SecurityMode != MessageSecurityMode.None)
{
Assert.That(ep.ServerCertificate, Is.Not.Null,
$"Endpoint[{i}] with SecurityMode={ep.SecurityMode} " +
"should include a ServerCertificate.");
Assert.That(ep.ServerCertificate.Length, Is.GreaterThan(0),
$"Endpoint[{i}] ServerCertificate should not be empty.");
}
}
}
[Description("Verify each ApplicationDescription returned by FindServers has at least one DiscoveryUrl.")]
[Test]
public async Task FindServersReturnsDiscoveryUrlsAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf servers = await client.FindServersAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(servers.Count, Is.GreaterThan(0));
for (int i = 0; i < servers.Count; i++)
{
ApplicationDescription app = servers[i];
Assert.That(app.DiscoveryUrls, Is.Not.Null,
$"Server[{i}] DiscoveryUrls should not be null.");
Assert.That(app.DiscoveryUrls.Count, Is.GreaterThan(0),
$"Server[{i}] should have at least one DiscoveryUrl.");
}
}
[Description("Pass an empty string array as the profile filter to GetEndpoints. Should return all available endpoints.")]
[Test]
public async Task SessionlessGetEndpointsWithEmptyProfileFilterAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
// Call with default (no filter) to get baseline count
ArrayOf allEndpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(allEndpoints.Count, Is.GreaterThan(0),
"GetEndpoints with empty profile filter should return all endpoints.");
}
[Description("Call GetEndpoints twice and verify the same number of endpoints is returned each time.")]
[Test]
public async Task SessionlessGetEndpointsReturnsSameResultsOnRepeatedCallsAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf firstCall = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
ArrayOf secondCall = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(firstCall.Count, Is.GreaterThan(0));
Assert.That(secondCall.Count, Is.EqualTo(firstCall.Count),
"Repeated GetEndpoints calls should return the same number of endpoints.");
}
[Description("Create and dispose a DiscoveryClient without calling any services. Verifies that client lifecycle management works without errors.")]
[Test]
public async Task DiscoveryClientCreatedAndDisposedAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
Assert.That(client, Is.Not.Null,
"DiscoveryClient should be created successfully.");
}
[Description("Call GetEndpoints with locale IDs specified. The call should succeed regardless of locale support.")]
[Test]
public async Task GetEndpointsWithLocaleIdsAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints, Is.Not.Null);
Assert.That(endpoints.Count, Is.GreaterThan(0),
"GetEndpoints with locale IDs should return endpoints.");
}
[Description("Call FindServers passing the server URL as the endpointUrl parameter. The server should return at least one matching application.")]
[Test]
public async Task FindServersWithEndpointUrlAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf servers = await client.FindServersAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(servers, Is.Not.Null);
Assert.That(servers.Count, Is.GreaterThan(0),
"FindServers with the server URL should return at least one server.");
}
[Description("Verify at least one endpoint uses SecurityMode.None, since the test fixture has SecurityNone and AutoAccept enabled.")]
[Test]
public async Task GetEndpointsContainsNoneSecurityModeAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0));
bool hasNone = false;
for (int i = 0; i < endpoints.Count; i++)
{
if (endpoints[i].SecurityMode == MessageSecurityMode.None)
{
hasNone = true;
break;
}
}
if (!hasNone)
{
Assert.Ignore("Server does not expose a SecurityMode.None endpoint.");
}
}
[Description("Create a DiscoveryClient without providing any user credentials. The client should connect successfully for discovery operations.")]
[Test]
public async Task DiscoveryClientConnectsWithoutCredentialsAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
Assert.That(client, Is.Not.Null,
"DiscoveryClient should connect without credentials.");
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0),
"Discovery should work without any authentication credentials.");
}
[Description("Verify that both GetEndpoints and FindServers work without any authentication tokens, confirming sessionless invocation.")]
[Test]
public async Task SessionlessCallsDoNotRequireAuthenticationAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0),
"GetEndpoints should succeed without authentication.");
ArrayOf servers = await client.FindServersAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(servers.Count, Is.GreaterThan(0),
"FindServers should succeed without authentication.");
}
[Description("Verify that each endpoint returned by GetEndpoints has a TransportProfileUri set.")]
[Test]
public async Task GetEndpointsReturnsTransportProfileUriAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(endpoints.Count, Is.GreaterThan(0));
for (int i = 0; i < endpoints.Count; i++)
{
Assert.That(endpoints[i].TransportProfileUri, Is.Not.Null.And.Not.Empty,
$"Endpoint[{i}] TransportProfileUri should not be empty.");
}
}
[Description("Verify that each server returned by FindServers has ApplicationType of Server or ClientAndServer.")]
[Test]
public async Task FindServersApplicationTypeIsServerOrBothAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf servers = await client.FindServersAsync(
default, CancellationToken.None).ConfigureAwait(false);
Assert.That(servers.Count, Is.GreaterThan(0));
for (int i = 0; i < servers.Count; i++)
{
ApplicationDescription app = servers[i];
bool isValidType =
app.ApplicationType is ApplicationType.Server or
ApplicationType.ClientAndServer;
Assert.That(isValidType, Is.True,
$"Server[{i}] ApplicationType should be Server or ClientAndServer, " +
$"but was {app.ApplicationType}.");
}
}
}
}