/* ======================================================================== * 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.Globalization; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Moq; using Opc.Ua.Bindings; namespace Opc.Ua.Client.Tests.Stack.Client.Fakes { /// /// Provides a network-free transport mock with scriptable open, reconnect, and request operations /// and queued observations of service response tasks. /// internal sealed class ScriptedChannel { /// /// Configures the transport mock to expose the supplied message context and invoke the scripted handlers. /// /// The message context exposed to the real client session. /// The clock used to timestamp canned responses. public ScriptedChannel(IServiceMessageContext messageContext, TimeProvider timeProvider) { m_timeProvider = timeProvider; Mock = new Mock(); Mock.As().Setup(c => c.CurrentToken).Returns((ChannelToken?)null); Mock.Setup(c => c.SupportedFeatures).Returns(() => SupportedFeatures); Mock.Setup(c => c.EndpointDescription).Returns(() => m_description); Mock.Setup(c => c.EndpointConfiguration).Returns(() => m_endpointConfiguration); Mock.Setup(c => c.MessageContext).Returns(messageContext); Mock.Setup(c => c.ChannelThumbprint).Returns([]); Mock.Setup(c => c.ClientChannelCertificate).Returns([]); Mock.Setup(c => c.ServerChannelCertificate).Returns([]); Mock.Setup(c => c.OperationTimeout).Returns(() => m_operationTimeout); Mock.SetupSet(c => c.OperationTimeout = It.IsAny()) .Callback(value => m_operationTimeout = value); Mock.As().Setup(c => c.OpenAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(OpenAsync); Mock.As().Setup(c => c.OpenAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(OpenReverseAsync); Mock.Setup(c => c.ReconnectAsync( It.IsAny(), It.IsAny())) .Returns(ReconnectAsync); Mock.Setup(c => c.SendRequestAsync( It.IsAny(), It.IsAny())) .Returns(SendRequestAsync); Mock.Setup(c => c.CloseAsync(It.IsAny())) .Returns(CloseAsync); Mock.Setup(c => c.Dispose()).Callback(() => Interlocked.Increment(ref m_disposeCount)); } /// /// Gets the underlying mock for additional transport setups and invocation verification. /// public Mock Mock { get; } /// /// Gets the mock transport instance supplied to the real session or channel manager. /// public ITransportChannel Channel => Mock.Object; /// /// Gets or sets the transport capabilities advertised by the mock, including optional reconnect support. /// public TransportChannelFeatures SupportedFeatures { get; set; } /// /// Gets or sets the optional open script, awaited before cancellation is checked and endpoint settings /// are recorded. /// public Func? OpenHandler { get; set; } /// /// Gets or sets the optional reconnect script, invoked after counting the call and checking cancellation. /// public Func? ReconnectHandler { get; set; } /// /// Gets or sets the request script whose response task is recorded for observation. /// When unset, requests use the canned responses from . /// public Func>? RequestHandler { get; set; } /// /// Gets the number of close invocations, including calls made with canceled tokens. /// public int CloseCount => Volatile.Read(ref m_closeCount); /// /// Gets the number of disposal invocations on the mock transport. /// public int DisposeCount => Volatile.Read(ref m_disposeCount); /// /// Gets the number of reconnect invocations, including calls rejected by an already-canceled token. /// public int ReconnectCount => Volatile.Read(ref m_reconnectCount); /// /// Gets the number of request invocations, including calls rejected by an already-canceled token. /// public int SendRequestCount => Volatile.Read(ref m_sendRequestCount); /// /// Gets the number of canned create-session responses generated by this channel. /// public int CreateSessionCount => Volatile.Read(ref m_sessionCounter); /// /// Gets the endpoint URL last recorded by a successful scripted open or , /// or null before either has assigned an endpoint. /// public string? OpenedEndpointUrl { get; private set; } /// /// Consumes recorded operations until a request of the specified type is found, /// discarding intervening operations of other request types. /// /// The service request type to observe. /// The token used to cancel waiting for the next recorded operation. public async ValueTask NextOperationAsync(CancellationToken ct) where TRequest : class, IServiceRequest { while (true) { RequestOperation operation = await m_operations.Reader.ReadAsync(ct).ConfigureAwait(false); if (operation.Request is TRequest) { return operation; } } } /// /// Assigns the mock endpoint settings without opening a connection or invoking . /// /// The endpoint description and configuration to expose through the mock. public void OpenForEndpoint(ConfiguredEndpoint endpoint) { m_description = endpoint.Description; m_endpointConfiguration = endpoint.Configuration!; OpenedEndpointUrl = endpoint.Description.EndpointUrl; } /// /// Builds a canned session-lifecycle or read response and rejects service types outside the default script. /// /// The request whose type selects the scripted response. /// The default script does not support the request type. public IServiceResponse CreateResponse(IServiceRequest request) { return request switch { CreateSessionRequest => CreateSessionResponse(), ActivateSessionRequest => new ActivateSessionResponse { ResponseHeader = CreateGoodHeader(), ServerNonce = ByteString.Empty, Results = [], DiagnosticInfos = [] }, ReadRequest readRequest => new ReadResponse { ResponseHeader = CreateGoodHeader(), Results = CreateReadResults(readRequest), DiagnosticInfos = [] }, CloseSessionRequest => new CloseSessionResponse { ResponseHeader = CreateGoodHeader() }, _ => throw ServiceResultException.Create( StatusCodes.BadServiceUnsupported, "Unexpected request type {0}.", request.GetType().Name) }; } /// /// Creates a successful response header timestamped with the clock supplied to this channel. /// public ResponseHeader CreateGoodHeader() { return new ResponseHeader { ServiceResult = StatusCodes.Good, Timestamp = m_timeProvider.GetUtcNow().UtcDateTime }; } private async ValueTask OpenAsync(Uri uri, TransportChannelSettings settings, CancellationToken ct) { _ = uri; if (OpenHandler != null) { await OpenHandler(ct).ConfigureAwait(false); } ct.ThrowIfCancellationRequested(); OpenWithSettings(settings); } private ValueTask OpenReverseAsync( ITransportWaitingConnection connection, TransportChannelSettings settings, CancellationToken ct) { _ = connection; return OpenAsync(new Uri(settings.Description!.EndpointUrl!), settings, ct); } private ValueTask ReconnectAsync(ITransportWaitingConnection? connection, CancellationToken ct) { _ = connection; Interlocked.Increment(ref m_reconnectCount); ct.ThrowIfCancellationRequested(); return ReconnectHandler?.Invoke(ct) ?? default; } private ValueTask SendRequestAsync(IServiceRequest request, CancellationToken ct) { Interlocked.Increment(ref m_sendRequestCount); ct.ThrowIfCancellationRequested(); Task operation = RequestHandler?.Invoke(request, ct).AsTask() ?? Task.FromResult(CreateResponse(request)); m_operations.Writer.TryWrite(new RequestOperation(request, operation, ct)); return new ValueTask(operation); } private ValueTask CloseAsync(CancellationToken ct) { _ = ct; Interlocked.Increment(ref m_closeCount); return default; } private void OpenWithSettings(TransportChannelSettings settings) { m_description = settings.Description ?? throw new InvalidOperationException("Transport settings do not include an endpoint."); m_endpointConfiguration = settings.Configuration ?? throw new InvalidOperationException("Transport settings do not include endpoint configuration."); OpenedEndpointUrl = m_description.EndpointUrl; } private CreateSessionResponse CreateSessionResponse() { string suffix = Interlocked.Increment(ref m_sessionCounter).ToString(CultureInfo.InvariantCulture); return new CreateSessionResponse { ResponseHeader = CreateGoodHeader(), SessionId = new NodeId($"session-{suffix}", 1), AuthenticationToken = new NodeId($"token-{suffix}", 1), RevisedSessionTimeout = 60000, ServerNonce = ByteString.Empty, ServerCertificate = ByteString.Empty, ServerSignature = new SignatureData(), ServerEndpoints = [m_description], MaxRequestMessageSize = 1_048_576 }; } private static ArrayOf CreateReadResults(ReadRequest request) { int count = request.NodesToRead.Count; if (count == 1 && Equals(request.NodesToRead[0].NodeId, VariableIds.Server_ServerStatus_State)) { return [new DataValue(new Variant((int)ServerState.Running), StatusCodes.Good)]; } if (count == 2) { return [ new DataValue(new Variant(ArrayOf.Wrapped(Namespaces.OpcUa)), StatusCodes.Good), new DataValue(new Variant(ArrayOf.Wrapped("urn:localhost:server")), StatusCodes.Good) ]; } var values = new DataValue[count]; for (int index = 0; index < count; index++) { Variant value = count > 1 && index is 12 or 13 or 14 ? new Variant((ushort)0) : count > 1 && index == 18 ? new Variant(0d) : new Variant(0u); values[index] = new DataValue(value, StatusCodes.Good); } return new ArrayOf(values); } /// /// Associates a service request with its scripted response task and the token supplied to the transport. /// /// The service request received by the mock transport. /// The response task returned by the script or canned response handler. /// The cancellation token supplied with the transport request. internal sealed record RequestOperation( IServiceRequest Request, Task Operation, CancellationToken CancellationToken); private readonly TimeProvider m_timeProvider; private readonly Channel m_operations = System.Threading.Channels.Channel .CreateUnbounded(); private EndpointDescription m_description = new(); private EndpointConfiguration m_endpointConfiguration = new(); private int m_operationTimeout; private int m_sessionCounter; private int m_closeCount; private int m_disposeCount; private int m_reconnectCount; private int m_sendRequestCount; } }