/* ======================================================================== * 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/ * ======================================================================*/ // CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived, // making CA2000 noisy without a real leak risk. Disabled file-level for the suite. #pragma warning disable CA2000 using System; using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua.Configuration; using Opc.Ua.Server.TestFramework; namespace Opc.Ua.Client.TestFramework { /// /// Client fixture for tests. /// public class ClientFixture : IDisposable, IAsyncDisposable { private const uint kDefaultOperationLimits = 5000; private readonly ITelemetryContext m_telemetry; private readonly ILogger m_logger; private ApplicationInstance m_application = null!; private int m_disposed; public ApplicationConfiguration Config { get; private set; } = null!; public ConfiguredEndpoint Endpoint { get; private set; } = null!; public string EndpointUrl { get; private set; } = null!; public string ReverseConnectUri { get; private set; } = null!; public ReverseConnectManager ReverseConnectManager { get; private set; } = null!; public uint SessionTimeout { get; set; } = 10000; public int OperationTimeout { get; set; } = 10000; public int TraceMasks { get; set; } = Utils.TraceMasks.Error | Utils.TraceMasks.StackTrace | Utils.TraceMasks.Security | Utils.TraceMasks.Information; public ISessionFactory SessionFactory { get; set; } public ActivityListener ActivityListener { get; private set; } = null!; /// /// Optional /// assigned to immediately /// after construction so the listeners /// creates pick up /// the right factory for the URI scheme (e.g. Kestrel-TCP /// instead of the raw-socket TCP listener). /// public Bindings.ITransportBindingRegistry TransportBindingRegistry { get; set; } = null!; /// /// Subscription engine factory to inject into every session /// created via . null means /// the session uses the /// default (which is the V2 /// after the /// flip in Session.cs). Test projects that rely on the /// classic subscription engine (e.g. classic /// Session.AddSubscription with classic /// Subscription) should set this to /// ClassicSubscriptionEngineFactory.Instance in their /// fixture setup. /// /// /// Setting this property after is /// constructed has no effect on already-created sessions. To /// switch engines, set this property and then call /// to rebuild the /// factory. /// public ISubscriptionEngineFactory SubscriptionEngineFactory { get; private set; } = null!; /// /// Configure the engine factory used by this fixture's /// . Replaces the current /// with a fresh /// that uses the supplied /// engine. /// /// public void UseSubscriptionEngineFactory(ISubscriptionEngineFactory engineFactory) { SubscriptionEngineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory)); SessionFactory = new DefaultSessionFactory(m_telemetry) { ReturnDiagnostics = DiagnosticsMasks.SymbolicIdAndText, SubscriptionEngineFactory = engineFactory }; } public ClientFixture(bool useTracing, bool disableActivityLogging, ITelemetryContext telemetry) : this(telemetry) { if (useTracing) { SessionFactory = new TraceableRequestHeaderClientSessionFactory(telemetry) { SubscriptionEngineFactory = SubscriptionEngineFactory }; StartActivityListenerInternal(disableActivityLogging); } else { SessionFactory = new DefaultSessionFactory(telemetry) { ReturnDiagnostics = DiagnosticsMasks.SymbolicIdAndText, SubscriptionEngineFactory = SubscriptionEngineFactory }; } } public ClientFixture(ITelemetryContext telemetry) { m_telemetry = telemetry; m_logger = telemetry.CreateLogger(); SessionFactory = new DefaultSessionFactory(telemetry) { ReturnDiagnostics = DiagnosticsMasks.SymbolicIdAndText, SubscriptionEngineFactory = SubscriptionEngineFactory }; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// public async ValueTask DisposeAsync() { await DisposeAsyncCore().ConfigureAwait(false); GC.SuppressFinalize(this); } /// /// An overrideable version of the Dispose. /// protected virtual void Dispose(bool disposing) { if (disposing) { DisposeAsyncCore().AsTask().GetAwaiter().GetResult(); } } /// /// Asynchronously releases fixture resources. /// protected virtual async ValueTask DisposeAsyncCore() { if (Interlocked.Exchange(ref m_disposed, 1) != 0) { return; } StopActivityListener(); if (ReverseConnectManager != null) { await ReverseConnectManager.DisposeAsync().ConfigureAwait(false); ReverseConnectManager = null!; } if (m_application != null) { await m_application.DisposeAsync().ConfigureAwait(false); m_application = null!; } if (Config?.CertificateManager is IAsyncDisposable asyncCertificateManager) { await asyncCertificateManager.DisposeAsync().ConfigureAwait(false); } else if (Config?.CertificateManager is IDisposable certificateManager) { certificateManager.Dispose(); } Config = null!; } /// /// Load the default client configuration. /// /// public async Task LoadClientConfigurationAsync( string? pkiRoot = null, string clientName = "TestClient") { if (m_application != null) { await m_application.DisposeAsync().ConfigureAwait(false); } m_application = new ApplicationInstance(m_telemetry) { ApplicationName = clientName }; ApplicationInstance application = m_application; pkiRoot ??= Path.Combine("%LocalApplicationData%", "OPC", "pki"); ArrayOf applicationCerts = ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates( "CN=" + clientName + ", O=OPC Foundation, DC=localhost", CertificateStoreType.Directory, pkiRoot); // build the application configuration. Config = await application .Build( "urn:localhost:opcfoundation.org:" + clientName, "http://opcfoundation.org/UA/" + clientName) .SetMaxByteStringLength(4 * 1024 * 1024) .SetMaxArrayLength(1024 * 1024) .AsClient() .SetClientOperationLimits( new OperationLimits { MaxNodesPerBrowse = kDefaultOperationLimits, MaxNodesPerRead = kDefaultOperationLimits, MaxMonitoredItemsPerCall = kDefaultOperationLimits, MaxNodesPerWrite = kDefaultOperationLimits }) .AddSecurityConfiguration(applicationCerts, pkiRoot) // .SetApplicationCertificates(applicationCerts) .SetAutoAcceptUntrustedCertificates(true) .SetRejectSHA1SignedCertificates(false) .CreateAsync() .ConfigureAwait(false); // check the application certificate. bool haveAppCertificate = await application .CheckApplicationInstanceCertificatesAsync(true) .ConfigureAwait(false); if (!haveAppCertificate) { throw new InvalidOperationException("Application instance certificate invalid!"); } ReverseConnectManager = CreateReverseConnectManager(); } /// /// Start a host for reverse connections on random port using the /// default opc.tcp:// scheme. /// public Task StartReverseConnectHostAsync() { return StartReverseConnectHostAsync(Utils.UriSchemeOpcTcp); } /// /// Start a host for reverse connections on random port using /// the specified URL scheme. Used by the WSS reverse-connect /// integration tests; the existing parameterless overload /// preserves the opc.tcp default. /// /// The URL scheme for the reverse-connect listener. public async Task StartReverseConnectHostAsync(string uriScheme) { int testPort = ServerFixtureUtils.GetNextFreeIPPort(); int serverStartRetries = 25; bool retryStartServer; do { retryStartServer = false; try { var reverseConnectUri = new Uri($"{uriScheme}://localhost:{testPort}"); if (Utils.IsUriWssScheme(reverseConnectUri.AbsoluteUri)) { // The client's certificate carries the machine name as its DNS identity. reverseConnectUri = new Uri(Utils.ReplaceLocalhost(reverseConnectUri.AbsoluteUri)); } ReverseConnectManager.AddEndpoint(reverseConnectUri, Config); await ReverseConnectManager.StartServiceAsync(Config).ConfigureAwait(false); ReverseConnectUri = reverseConnectUri.ToString(); } catch (ServiceResultException sre) { serverStartRetries--; if (serverStartRetries == 0 || sre.StatusCode != StatusCodes.BadNoCommunication) { throw; } await ReverseConnectManager.DisposeAsync().ConfigureAwait(false); ReverseConnectManager = CreateReverseConnectManager(); testPort = UnsecureRandom.Shared.Next( ServerFixtureUtils.MinTestPort, ServerFixtureUtils.MaxTestPort); retryStartServer = true; } await Task.Delay(UnsecureRandom.Shared.Next(100, 1000)).ConfigureAwait(false); } while (retryStartServer); } /// /// Connects the specified endpoint URL. /// /// The endpoint URL. /// Cancellation token to cancel operation with /// /// /// public async Task ConnectAsync(string endpointUrl, CancellationToken ct = default) { if (string.IsNullOrEmpty(endpointUrl)) { throw new ArgumentNullException(nameof(endpointUrl)); } if (!Uri.IsWellFormedUriString(endpointUrl, UriKind.Absolute)) { throw new ArgumentException( endpointUrl + " is not a valid URL.", nameof(endpointUrl)); } const int maxAttempts = 25; for (int attempt = 0; ; attempt++) { try { EndpointDescription endpointDescription = (await CoreClientUtils.SelectEndpointAsync( Config, endpointUrl, true, m_telemetry, ct).ConfigureAwait(false))!; var endpointConfiguration = EndpointConfiguration.Create(Config); var endpoint = new ConfiguredEndpoint( null, endpointDescription!, endpointConfiguration); return await ConnectAsync(endpoint).ConfigureAwait(false); } catch (ServiceResultException e) when ( ( e.StatusCode == StatusCodes.BadServerHalted || e.StatusCode == StatusCodes.BadSecureChannelClosed || e.StatusCode == StatusCodes.BadNoCommunication ) && attempt < maxAttempts) { attempt++; m_logger.LogError(e, "Failed to connect {Attempt}. Retrying in 1 second...", attempt + 1); await Task.Delay(1000, ct).ConfigureAwait(false); } } throw new ServiceResultException(StatusCodes.BadNoCommunication); } /// /// Connects the url endpoint with specified security profile. /// /// public async Task ConnectAsync( Uri url, string securityProfile, ArrayOf endpoints = default, IUserIdentity? userIdentity = null) { string uri = url.AbsoluteUri; Uri getEndpointsUrl = url; if (uri.StartsWith(Utils.UriSchemeHttp, StringComparison.Ordinal) || Utils.IsUriHttpsScheme(uri)) { getEndpointsUrl = CoreClientUtils.GetDiscoveryUrl(uri); } const int maxAttempts = 25; for (int attempt = 0; ; attempt++) { try { ConfiguredEndpoint endpoint = await GetEndpointAsync( getEndpointsUrl, securityProfile, endpoints ).ConfigureAwait(false); return await ConnectAsync(endpoint, userIdentity).ConfigureAwait(false); } catch (ServiceResultException e) when (IsPermanentConnectFailure(e.StatusCode.Code)) { // Permanent failure (bad credentials, bad cert, rejected // security policy etc.). Retrying just floods the server // and can lock out the account. throw; } catch (ServiceResultException e) when ( ( e.StatusCode == StatusCodes.BadServerHalted || e.StatusCode == StatusCodes.BadSecureChannelClosed || e.StatusCode == StatusCodes.BadNoCommunication ) && attempt < maxAttempts) { m_logger.LogError(e, "Failed to connect {Attempt}. Retrying in 1 second...", attempt + 1); await Task.Delay(1000).ConfigureAwait(false); } catch (Exception e) when ( e is not IgnoreException && attempt < maxAttempts) { // Retry on transient errors (e.g. HttpRequestException when HTTPS server is not yet ready) m_logger.LogError(e, "Failed to connect {Attempt}. Retrying in 1 second...", attempt + 1); await Task.Delay(1000).ConfigureAwait(false); } } throw new ServiceResultException(StatusCodes.BadNoCommunication); } private ReverseConnectManager CreateReverseConnectManager() { return new ReverseConnectManager(m_telemetry) { TransportBindings = TransportBindingRegistry ?? TestTransportBindings.WithAllSchemes() }; } /// /// True for service-result status codes that indicate a permanent /// connect failure that won't be resolved by retrying — namely /// authentication and certificate-validation errors. Retrying these /// would cause the test to spend 25 s flooding the server with bad /// auth attempts, which can trip the failed-auth lockout and /// poison later tests. /// private static bool IsPermanentConnectFailure(uint statusCode) { return statusCode == StatusCodes.BadIdentityTokenInvalid || statusCode == StatusCodes.BadIdentityTokenRejected || statusCode == StatusCodes.BadUserAccessDenied || statusCode == StatusCodes.BadCertificateInvalid || statusCode == StatusCodes.BadCertificateUntrusted || statusCode == StatusCodes.BadCertificateTimeInvalid || statusCode == StatusCodes.BadCertificateIssuerTimeInvalid || statusCode == StatusCodes.BadCertificateHostNameInvalid || statusCode == StatusCodes.BadCertificateUriInvalid || statusCode == StatusCodes.BadCertificateUseNotAllowed || statusCode == StatusCodes.BadCertificateIssuerUseNotAllowed || statusCode == StatusCodes.BadCertificateRevoked || statusCode == StatusCodes.BadCertificateIssuerRevoked || statusCode == StatusCodes.BadCertificateRevocationUnknown || statusCode == StatusCodes.BadCertificateIssuerRevocationUnknown || statusCode == StatusCodes.BadCertificatePolicyCheckFailed || statusCode == StatusCodes.BadSecurityChecksFailed || statusCode == StatusCodes.BadSecurityPolicyRejected || statusCode == StatusCodes.BadSecurityModeRejected; } /// /// Connects the specified endpoint. /// /// The configured endpoint. /// is null. public async Task ConnectAsync( ConfiguredEndpoint endpoint, IUserIdentity? userIdentity = null) { endpoint ??= Endpoint ?? throw new ArgumentNullException(nameof(endpoint)); ISession session = await SessionFactory .CreateAsync( Config, endpoint, false, false, Config.ApplicationName!, SessionTimeout, userIdentity, default) .ConfigureAwait(false); Endpoint = session.ConfiguredEndpoint; session.KeepAlive += Session_KeepAlive; EndpointUrl = session.ConfiguredEndpoint.EndpointUrl!.ToString(); return session; } /// /// Create a channel using the specified endpoint. /// /// The configured endpoint public Task CreateChannelAsync( ConfiguredEndpoint endpoint, bool updateBeforeConnect = true) { return SessionFactory.CreateChannelAsync( Config, null!, endpoint, updateBeforeConnect, checkDomain: false); } /// /// Create a session using the specified channel. /// /// The channel to use /// The configured endpoint public ISession CreateSession(ITransportChannel channel, ConfiguredEndpoint endpoint) { return SessionFactory.Create(channel, Config, endpoint, null); } /// /// Get configured endpoint from url with security profile. /// public async Task GetEndpointAsync( Uri url, string securityPolicy, ArrayOf endpoints = default) { if (endpoints.IsNull) { endpoints = await GetEndpointsAsync(url).ConfigureAwait(false); } EndpointDescription endpointDescription = SelectEndpoint( Config, endpoints, url, securityPolicy); if (endpointDescription == null) { Assert.Ignore("The endpoint is not supported by the server."); } var endpointConfiguration = EndpointConfiguration.Create(Config); endpointConfiguration.OperationTimeout = OperationTimeout; return new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); } /// /// Select a security endpoint from description. /// public static EndpointDescription SelectEndpoint( ApplicationConfiguration configuration, ArrayOf endpoints, Uri url, string securityPolicy) { EndpointDescription? selectedEndpoint = null; // select the best endpoint to use based on the selected URL and the UseSecurity checkbox. foreach (EndpointDescription endpoint in endpoints) { // check for a match on the URL scheme. if (endpoint.EndpointUrl!.StartsWith(url.Scheme, StringComparison.Ordinal)) { // skip unsupported security policies if (!configuration.SecurityConfiguration.SupportedSecurityPolicies.Contains( endpoint.SecurityPolicyUri!)) { continue; } // pick the first available endpoint by default. if (selectedEndpoint == null && securityPolicy.Equals(endpoint.SecurityPolicyUri, StringComparison.Ordinal)) { selectedEndpoint = endpoint; continue; } if (selectedEndpoint?.SecurityMode < endpoint.SecurityMode && securityPolicy.Equals(endpoint.SecurityPolicyUri, StringComparison.Ordinal)) { selectedEndpoint = endpoint; } } } // return the selected endpoint. return selectedEndpoint!; } /// /// Get endpoints from discovery endpoint. /// public async Task> GetEndpointsAsync( Uri url, CancellationToken ct = default) { var endpointConfiguration = EndpointConfiguration.Create(Config); endpointConfiguration.OperationTimeout = OperationTimeout; // Use the ApplicationConfiguration overload so the // CertificateManager flows into the discovery channel; the // HTTPS transport needs it to validate the test server's // self-signed certificate (otherwise the channel falls back // to the OS TLS chain check, which rejects untrusted certs). using DiscoveryClient client = await DiscoveryClient.CreateAsync( Config, url, endpointConfiguration, ct: ct).ConfigureAwait(false); client.ReturnDiagnostics = DiagnosticsMasks.SymbolicIdAndText; ArrayOf result = await client.GetEndpointsAsync(default, ct) .ConfigureAwait(false); await client.CloseAsync(ct).ConfigureAwait(false); return result; } /// /// Configures Activity Listener and registers with Activity Source. /// public void StartActivityListenerInternal(bool disableActivityLogging) { ActivitySource activitySource = m_telemetry.GetActivitySource(); string expectedName = activitySource.Name; if (disableActivityLogging) { // Create an instance of ActivityListener without logging ActivityListener = new ActivityListener { ShouldListenTo = (source) => source.Name == expectedName, // Sample all data and recorded activities Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, // Do not log during benchmarks ActivityStarted = _ => { }, ActivityStopped = _ => { } }; } else { // Create an instance of ActivityListener and configure its properties with logging ActivityListener = new ActivityListener { ShouldListenTo = (source) => source.Name == expectedName, // Sample all data and recorded activities Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, ActivityStarted = activity => { if (m_logger.IsEnabled(LogLevel.Information)) { m_logger.LogInformation( "Client Started: {OperationName,-15} - TraceId: {TraceId,-32} SpanId: {SpanId,-16}", activity.OperationName, activity.TraceId, activity.SpanId); } }, ActivityStopped = activity => { if (m_logger.IsEnabled(LogLevel.Information)) { m_logger.LogInformation( "Client Stopped: {OperationName,-15} - TraceId: {TraceId,-32} SpanId: {SpanId,-16} Duration: {Duration}", activity.OperationName, activity.TraceId, activity.SpanId, activity.Duration); } } }; } ActivitySource.AddActivityListener(ActivityListener); } /// /// Disposes Activity Listener and unregisters from Activity Source. /// public void StopActivityListener() { ActivityListener?.Dispose(); ActivityListener = null!; } private void Session_KeepAlive(ISession session, KeepAliveEventArgs e) { if (ServiceResult.IsBad(e.Status)) { // Ignore expected errors during test shutdown to reduce noise in CI logs if (e.Status?.StatusCode == StatusCodes.BadServerHalted || e.Status?.StatusCode == StatusCodes.BadConnectionClosed || e.Status?.StatusCode == StatusCodes.BadNoCommunication || e.Status?.StatusCode == StatusCodes.BadSecureChannelClosed || e.Status?.StatusCode == StatusCodes.BadRequestInterrupted) { return; } m_logger.LogError( "Session '{SessionName}' keep alive error: {StatusCode}", session.SessionName, e.Status?.ToLongString()); } } } }