Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/Session/SessionTests.cs
MrAlaskan 8f4087605a Fix unordered server endpoint validation (#4029)
### Summary

This PR makes `Session.ValidateServerEndpoints()` compare
`ServerEndpoints` and `UserIdentityTokens` as unordered sets and adds
regression tests that verify `OpenAsync()` no longer rejects legal
servers that return the same endpoint data in a different order.

### Problem

According to OPC UA Part 4, the client validates the endpoint set
returned by `CreateSessionResponse.ServerEndpoints` against the endpoint
set observed during discovery. The specification describes these values
as a set of endpoint descriptions filtered by the relevant transport
profile, not as an ordered array that must preserve the exact
discovery-time enumeration order.

Previously, `Session.ValidateServerEndpoints()` first checked the
endpoint count and then compared `m_discoveryServerEndpoints[ii]`
against `serverEndpoints[ii]` by index. It also compared
`UserIdentityTokens[jj]` by index within each endpoint. As a result, a
server that returned the same legal endpoints in a different order, or
returned the same token policies in a different order within an
endpoint, could be rejected with `BadSecurityChecksFailed` even though
the endpoint data itself had not changed.

### Changes

- Replace index-based `ServerEndpoints` validation with unordered
matching based on the same endpoint fields already validated by the
client.
- Compare `UserIdentityTokens` as an unordered multiset instead of
requiring the server to preserve the original token enumeration order.
- Extend the client session test scaffolding so discovery endpoints can
be supplied explicitly.
- Add regression tests that verify `OpenAsync()` accepts reordered
`ServerEndpoints` and reordered `UserIdentityTokens`.
2026-07-20 08:17:34 +02:00

2314 lines
97 KiB
C#

/* ========================================================================
* 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.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests
{
[TestFixture]
[Category("Client")]
[Category("Session")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public sealed class SessionTests
{
[Test]
public void BuildTransportChainReturnsLeafFollowedByIssuers()
{
using Certificate leaf = CertificateBuilder.Create("CN=TransportLeaf").CreateForRSA();
using Certificate issuer = CertificateBuilder
.Create("CN=TransportIssuer")
.SetCAConstraint()
.CreateForRSA();
using var issuerChain = new CertificateCollection { issuer };
using var entry = new CertificateEntry(
leaf, issuerChain, ObjectTypeIds.RsaSha256ApplicationCertificateType);
using CertificateCollection? chain = Session.BuildTransportChain(entry);
// The transport chain is [leaf, ...issuers] with the leaf at index 0.
Assert.That(chain, Is.Not.Null);
Assert.That(chain!, Has.Count.EqualTo(2));
Assert.That(chain[0].Thumbprint, Is.EqualTo(leaf.Thumbprint));
Assert.That(chain[1].Thumbprint, Is.EqualTo(issuer.Thumbprint));
}
[Test]
public void BuildTransportChainReturnsNullWhenNoIssuers()
{
using Certificate leaf = CertificateBuilder.Create("CN=TransportLeafOnly").CreateForRSA();
using var issuerChain = new CertificateCollection();
using var entry = new CertificateEntry(
leaf, issuerChain, ObjectTypeIds.RsaSha256ApplicationCertificateType);
CertificateCollection? chain = Session.BuildTransportChain(entry);
// With no issuers the transport sends the leaf only, so the chain is null.
Assert.That(chain, Is.Null);
}
[Test]
public void BuildInstanceCertificateEntryReturnsNullWhenNoCertificate()
{
Assert.That(Session.BuildInstanceCertificateEntry(null, null), Is.Null);
}
[Test]
public void BuildInstanceCertificateEntryStoresIssuersWithoutLeaf()
{
using Certificate leaf = CertificateBuilder.Create("CN=EntryLeaf").CreateForRSA();
using Certificate issuer = CertificateBuilder
.Create("CN=EntryIssuer")
.SetCAConstraint()
.CreateForRSA();
// Transport-style chain: [leaf, ...issuers].
using var transportChain = new CertificateCollection { leaf, issuer };
using CertificateEntry? entry = Session.BuildInstanceCertificateEntry(leaf, transportChain);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Certificate.Thumbprint, Is.EqualTo(leaf.Thumbprint));
// The leaf is dropped from the stored issuer chain (issuers only).
Assert.That(entry.IssuerChain, Has.Count.EqualTo(1));
Assert.That(entry.IssuerChain[0].Thumbprint, Is.EqualTo(issuer.Thumbprint));
}
[Test]
public void BuildInstanceCertificateEntryHandlesNullChain()
{
using Certificate leaf = CertificateBuilder.Create("CN=EntryLeafOnly").CreateForRSA();
using CertificateEntry? entry = Session.BuildInstanceCertificateEntry(leaf, null);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Certificate.Thumbprint, Is.EqualTo(leaf.Thumbprint));
Assert.That(entry.IssuerChain, Has.Count.EqualTo(0));
}
[Test]
public void BuildTransportChainAndInstanceEntryRoundTrip()
{
using Certificate leaf = CertificateBuilder.Create("CN=RoundTripLeaf").CreateForRSA();
using Certificate issuer = CertificateBuilder
.Create("CN=RoundTripIssuer")
.SetCAConstraint()
.CreateForRSA();
using var issuerChain = new CertificateCollection { issuer };
using var original = new CertificateEntry(
leaf, issuerChain, ObjectTypeIds.RsaSha256ApplicationCertificateType);
using CertificateCollection? transportChain = Session.BuildTransportChain(original);
using CertificateEntry? rebuilt = Session.BuildInstanceCertificateEntry(leaf, transportChain);
Assert.That(rebuilt, Is.Not.Null);
Assert.That(rebuilt!.Certificate.Thumbprint, Is.EqualTo(leaf.Thumbprint));
Assert.That(rebuilt.IssuerChain, Has.Count.EqualTo(1));
Assert.That(rebuilt.IssuerChain[0].Thumbprint, Is.EqualTo(issuer.Thumbprint));
}
[Test]
public async Task FetchOperationLimitsAsyncShouldFetchAllOperationLimitsAsync()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues =
[
new DataValue(new Variant(2000u)),
new DataValue(new Variant(3000u)),
new DataValue(new Variant(4000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(5000u)),
new DataValue(new Variant(6000u)),
new DataValue(new Variant(7000u)),
new DataValue(new Variant(8000u)),
new DataValue(new Variant(9000u)),
new DataValue(new Variant(10000u)),
new DataValue(new Variant(11000u)),
new DataValue(new Variant(12000u)),
new DataValue(new Variant((ushort)13000)),
new DataValue(new Variant((ushort)14000u)),
new DataValue(new Variant((ushort)15000u)),
new DataValue(new Variant(16000u)),
new DataValue(new Variant(17000u)),
new DataValue(new Variant(18000u)),
new DataValue(new Variant((double)19000.0)),
new DataValue(new Variant(20000u)),
new DataValue(new Variant(21000u)),
new DataValue(new Variant(22000u)),
new DataValue(new Variant(23000u)),
new DataValue(new Variant(24000u)),
new DataValue(new Variant(25000u)),
new DataValue(new Variant(26000u)),
new DataValue(new Variant(27000u))
];
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(1000u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
// Act
await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(1000));
Assert.That(sut.OperationLimits.MaxNodesPerHistoryReadData, Is.EqualTo(2000));
Assert.That(sut.OperationLimits.MaxNodesPerHistoryReadEvents, Is.EqualTo(3000));
Assert.That(sut.OperationLimits.MaxNodesPerWrite, Is.EqualTo(4000));
Assert.That(sut.OperationLimits.MaxNodesPerHistoryUpdateData, Is.EqualTo(5000));
Assert.That(sut.OperationLimits.MaxNodesPerHistoryUpdateEvents, Is.EqualTo(6000));
Assert.That(sut.OperationLimits.MaxNodesPerMethodCall, Is.EqualTo(7000));
Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(8000));
Assert.That(sut.OperationLimits.MaxNodesPerRegisterNodes, Is.EqualTo(9000));
Assert.That(sut.OperationLimits.MaxNodesPerNodeManagement, Is.EqualTo(10000));
Assert.That(sut.OperationLimits.MaxMonitoredItemsPerCall, Is.EqualTo(11000));
Assert.That(sut.OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds, Is.EqualTo(12000));
Assert.That(sut.ServerCapabilities.MaxBrowseContinuationPoints, Is.EqualTo(13000));
Assert.That(sut.ServerCapabilities.MaxHistoryContinuationPoints, Is.EqualTo(14000));
Assert.That(sut.ServerCapabilities.MaxQueryContinuationPoints, Is.EqualTo(15000));
Assert.That(sut.ServerCapabilities.MaxStringLength, Is.EqualTo(16000));
Assert.That(sut.ServerCapabilities.MaxArrayLength, Is.EqualTo(17000));
Assert.That(sut.ServerCapabilities.MaxByteStringLength, Is.EqualTo(18000));
Assert.That(sut.ServerCapabilities.MinSupportedSampleRate, Is.EqualTo(19000.0));
Assert.That(sut.ServerCapabilities.MaxSessions, Is.EqualTo(20000));
Assert.That(sut.ServerCapabilities.MaxSubscriptions, Is.EqualTo(21000));
Assert.That(sut.ServerCapabilities.MaxMonitoredItems, Is.EqualTo(22000));
Assert.That(sut.ServerCapabilities.MaxMonitoredItemsPerSubscription, Is.EqualTo(23000));
Assert.That(sut.ServerCapabilities.MaxMonitoredItemsQueueSize, Is.EqualTo(24000));
Assert.That(sut.ServerCapabilities.MaxSubscriptionsPerSession, Is.EqualTo(25000));
Assert.That(sut.ServerCapabilities.MaxWhereClauseParameters, Is.EqualTo(26000));
Assert.That(sut.ServerCapabilities.MaxSelectClauseParameters, Is.EqualTo(27000));
sut.Channel.Verify();
}
[Test]
public void FetchOperationLimitsAsyncShouldHandleEmptyResponse()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues = [];
ArrayOf<DiagnosticInfo> diagnosticInfos = [];
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = diagnosticInfos
}))
.Verifiable(Times.Once);
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void FetchOperationLimitsAsyncShouldHandlePartialData()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues =
[
new DataValue(new Variant(1000u)),
new DataValue(new Variant(2000u)),
new DataValue(new Variant(3000u))
];
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(1000u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void FetchOperationLimitsAsyncShouldHandleErrors()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues = [DataValue.FromStatusCode(StatusCodes.BadUnexpectedError)];
ArrayOf<DiagnosticInfo> diagnosticInfos = [];
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = diagnosticInfos
}))
.Verifiable(Times.Exactly(2));
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void FetchOperationLimitsAsyncShouldThrowWhenInvalidDataTypes()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues = [new DataValue("InvalidDataType")];
ArrayOf<DiagnosticInfo> diagnosticInfos = [];
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = diagnosticInfos
}))
.Verifiable(Times.Exactly(2));
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void FetchOperationLimitsAsyncShouldHandleTimeout()
{
using var sut = SessionMock.Create();
using var cts = new CancellationTokenSource(100);
CancellationToken ct = cts.Token; // Set a short timeout
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException())
.Verifiable(Times.Once);
Assert.ThrowsAsync<TaskCanceledException>(
async () => await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public async Task FetchOperationLimitsAsyncShouldApplyClientLimitsWhenSmallerAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var channel = new Mock<ITransportChannel>();
channel
.SetupGet(s => s.MessageContext)
.Returns(ServiceMessageContext.Create(telemetry));
channel
.SetupGet(s => s.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
// Configure client with smaller limits than server
var configuration = new ApplicationConfiguration(telemetry)
{
ClientConfiguration = new ClientConfiguration
{
OperationLimits = new OperationLimits
{
MaxNodesPerRead = 500,
MaxNodesPerWrite = 2000,
MaxNodesPerBrowse = 4000
}
}
};
var endpoint = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840"
};
using var sut = new SessionMock(
channel,
configuration,
new ConfiguredEndpoint(null, endpoint));
CancellationToken ct = CancellationToken.None;
// Server returns larger values
ArrayOf<DataValue> dataValues =
[
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadData - no client limit
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadEvents - no client limit
new DataValue(new Variant(5000u)), // MaxNodesPerWrite - client has 2000
new DataValue(new Variant(1000u)), // MaxNodesPerRead - client has 500
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateData
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateEvents
new DataValue(new Variant(1000u)), // MaxNodesPerMethodCall
new DataValue(new Variant(10000u)), // MaxNodesPerBrowse - client has 4000
new DataValue(new Variant(1000u)), // MaxNodesPerRegisterNodes
new DataValue(new Variant(1000u)), // MaxNodesPerNodeManagement
new DataValue(new Variant(1000u)), // MaxMonitoredItemsPerCall
new DataValue(new Variant(1000u)), // MaxNodesPerTranslateBrowsePathsToNodeIds
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(100000u)), // MaxArrayLength - large so it does not cap the limits under test
new DataValue(new Variant(1000u)),
new DataValue(new Variant(100.0)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u))
];
channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(1000u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
// Act
await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
// Assert - should use smaller of client and server values
Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(500)); // min(500, 1000)
Assert.That(sut.OperationLimits.MaxNodesPerWrite, Is.EqualTo(2000)); // min(2000, 5000)
Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(4000)); // min(4000, 10000)
Assert.That(sut.OperationLimits.MaxNodesPerHistoryReadData, Is.EqualTo(1000)); // 0 -> server
Assert.That(sut.OperationLimits.MaxNodesPerHistoryReadEvents, Is.EqualTo(1000)); // 0 -> server
channel.Verify();
}
[Test]
public async Task FetchOperationLimitsAsyncShouldApplyServerLimitsWhenSmallerAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var channel = new Mock<ITransportChannel>();
channel
.SetupGet(s => s.MessageContext)
.Returns(ServiceMessageContext.Create(telemetry));
channel
.SetupGet(s => s.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
// Configure client with larger limits than server
var configuration = new ApplicationConfiguration(telemetry)
{
ClientConfiguration = new ClientConfiguration
{
OperationLimits = new OperationLimits
{
MaxNodesPerRead = 2000,
MaxNodesPerWrite = 10000,
MaxNodesPerBrowse = 8000
}
}
};
var endpoint = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840"
};
using var sut = new SessionMock(
channel,
configuration,
new ConfiguredEndpoint(null, endpoint));
CancellationToken ct = CancellationToken.None;
// Server returns smaller values
ArrayOf<DataValue> dataValues =
[
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadData
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadEvents
new DataValue(new Variant(5000u)), // MaxNodesPerWrite - server has 5000, client has 10000
new DataValue(new Variant(1000u)), // MaxNodesPerRead - server has 1000, client has 2000
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateData
new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateEvents
new DataValue(new Variant(1000u)), // MaxNodesPerMethodCall
new DataValue(new Variant(5000u)), // MaxNodesPerBrowse - server has 5000, client has 8000
new DataValue(new Variant(1000u)), // MaxNodesPerRegisterNodes
new DataValue(new Variant(1000u)), // MaxNodesPerNodeManagement
new DataValue(new Variant(1000u)), // MaxMonitoredItemsPerCall
new DataValue(new Variant(1000u)), // MaxNodesPerTranslateBrowsePathsToNodeIds
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(100000u)), // MaxArrayLength - large so it does not cap the limits under test
new DataValue(new Variant(1000u)),
new DataValue(new Variant(100.0)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u))
];
channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(1000u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
// Act
await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
// Assert - should use smaller of client and server values
Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(1000)); // min(2000, 1000)
Assert.That(sut.OperationLimits.MaxNodesPerWrite, Is.EqualTo(5000)); // min(10000, 5000)
Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(5000)); // min(8000, 5000)
channel.Verify();
}
[Test]
public async Task FetchOperationLimitsAsyncShouldCapLimitsToServerMaxArrayLengthAsync()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
// The server advertises a small MaxArrayLength (index 16) that is lower than
// most operation limits. Limits above it are reduced to it; a limit already at
// or below it is left unchanged.
ArrayOf<DataValue> dataValues =
[
new DataValue(new Variant(2000u)), // MaxNodesPerHistoryReadData -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerHistoryReadEvents -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerWrite -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerRead -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerHistoryUpdateData -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerHistoryUpdateEvents -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerMethodCall -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerBrowse -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerRegisterNodes -> 500
new DataValue(new Variant(2000u)), // MaxNodesPerNodeManagement -> 500
new DataValue(new Variant(2000u)), // MaxMonitoredItemsPerCall -> 500
new DataValue(new Variant(200u)), // MaxNodesPerTranslateBrowsePathsToNodeIds -> 200 (unchanged)
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant((ushort)100)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(500u)), // MaxArrayLength
new DataValue(new Variant(1000u)),
new DataValue(new Variant(100.0)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u)),
new DataValue(new Variant(1000u))
];
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(2000u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
// Act
await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
// Assert - operation limits above MaxArrayLength are capped to it.
Assert.That(sut.ServerCapabilities.MaxArrayLength, Is.EqualTo(500));
Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(500));
Assert.That(sut.OperationLimits.MaxNodesPerWrite, Is.EqualTo(500));
Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(500));
Assert.That(sut.OperationLimits.MaxNodesPerHistoryReadData, Is.EqualTo(500));
Assert.That(sut.OperationLimits.MaxNodesPerNodeManagement, Is.EqualTo(500));
Assert.That(sut.OperationLimits.MaxMonitoredItemsPerCall, Is.EqualTo(500));
Assert.That(sut.OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds, Is.EqualTo(200));
}
[Test]
public async Task FetchOperationLimitsAsyncShouldCapUnlimitedLimitsToMaxArrayLengthAsync()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
// All operation limits are 0 (unlimited); the non-zero MaxArrayLength bounds them.
ArrayOf<DataValue> dataValues = CreateOperationLimitsRead(operationLimit: 0u, maxArrayLength: 750u);
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(0u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
// Act
await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
// Assert - unlimited (0) limits are bound to MaxArrayLength.
Assert.That(sut.ServerCapabilities.MaxArrayLength, Is.EqualTo(750));
Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(750));
Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(750));
Assert.That(sut.OperationLimits.MaxMonitoredItemsPerCall, Is.EqualTo(750));
Assert.That(sut.OperationLimits.MaxNodesPerNodeManagement, Is.EqualTo(750));
Assert.That(sut.OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds, Is.EqualTo(750));
}
[Test]
public async Task FetchOperationLimitsAsyncShouldCapToClientArrayQuotaWhenServerUnlimitedAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var channel = new Mock<ITransportChannel>();
channel
.SetupGet(s => s.MessageContext)
.Returns(ServiceMessageContext.Create(telemetry));
channel
.SetupGet(s => s.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
// The client constrains the array length via its transport quotas while the
// server reports MaxArrayLength 0 (unlimited).
var configuration = new ApplicationConfiguration(telemetry)
{
ClientConfiguration = new ClientConfiguration(),
TransportQuotas = new TransportQuotas { MaxArrayLength = 600 }
};
var endpoint = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840"
};
using var sut = new SessionMock(
channel,
configuration,
new ConfiguredEndpoint(null, endpoint));
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues = CreateOperationLimitsRead(operationLimit: 1000u, maxArrayLength: 0u);
channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(1000u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
// Act
await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
// Assert - the client array quota becomes the effective cap.
Assert.That(sut.ServerCapabilities.MaxArrayLength, Is.EqualTo(600));
Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(600));
Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(600));
Assert.That(sut.OperationLimits.MaxMonitoredItemsPerCall, Is.EqualTo(600));
}
[Test]
public async Task FetchOperationLimitsAsyncShouldNotCapWhenArrayLengthUnlimitedAsync()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
// Both the server MaxArrayLength and the client array quota are 0 (unlimited):
// operation limits are left untouched.
ArrayOf<DataValue> dataValues = CreateOperationLimitsRead(operationLimit: 1000u, maxArrayLength: 0u);
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [new DataValue(new Variant(1000u))],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = []
}));
// Act
await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false);
// Assert - no array length constraint, so limits are unchanged.
Assert.That(sut.ServerCapabilities.MaxArrayLength, Is.Zero);
Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(1000));
Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(1000));
Assert.That(sut.OperationLimits.MaxMonitoredItemsPerCall, Is.EqualTo(1000));
Assert.That(sut.OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds, Is.EqualTo(1000));
}
[Test]
public async Task FetchNamespaceTablesAsyncShouldFetchAndUpdateTablesAsync()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = new DataValue(new Variant(["http://server1", "http://server2"]));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2"]));
sut.Channel.Verify();
}
[Test]
public async Task FetchNamespaceTablesAsyncShouldFetchAndUpdateTablesAndLogDifferences1Async()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray1 = new DataValue(Variant.From([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
var serverArray1 = new DataValue(Variant.From(["http://server1", "http://server2"]));
var namespaceArray2 = new DataValue(Variant.From([Namespaces.OpcUa, "http://namespace3", "http://namespace2"]));
var serverArray2 = new DataValue(Variant.From(["http://server1", "http://server2", "http://server3"]));
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray1, serverArray1],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray2, serverArray2],
DiagnosticInfos = []
}));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2"]));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace3", "http://namespace2"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2", "http://server3"]));
sut.Channel.Verify();
}
[Test]
public async Task FetchNamespaceTablesAsyncShouldFetchAndUpdateTablesAndLogDifferences2Async()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray1 = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
var serverArray1 = new DataValue(new Variant(["http://server1", "http://server2"]));
var namespaceArray2 = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace3"]));
var serverArray2 = new DataValue(new Variant(["http://server1"]));
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray1, serverArray1],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray2, serverArray2],
DiagnosticInfos = []
}));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2"]));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace3"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1"]));
sut.Channel.Verify();
}
[Test]
public async Task FetchNamespaceTablesAsyncShouldHandlePartialSuccessAsync()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = DataValue.FromStatusCode(StatusCodes.BadUnexpectedError);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2"]));
Assert.That(sut.ServerUris.ToArray(), Is.Empty);
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowInCaseOfBadResponse()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = new DataValue(new Variant(["http://server1", "http://server2"]));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadUnexpectedError },
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowWhenNamespaceArrayCouldNotBeRetrieved()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = DataValue.FromStatusCode(StatusCodes.BadUnexpectedError);
var serverArray = DataValue.FromStatusCode(StatusCodes.BadUnexpectedError);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShoulThrowWhenEmptyResponse()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues = [];
ArrayOf<DiagnosticInfo> diagnosticInfos = [];
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = diagnosticInfos
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowWhenInvalidDataTypesForNamespaceTable()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant(12345));
var serverArray = new DataValue(new Variant(67890));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowWhenInvalidDataTypeForServerUrls()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = new DataValue(new Variant(67890));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldCloseSessionAndChannelSuccessfullyAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldHandleAlreadyClosedSessionAsync()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
sut.Dispose();
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
}
[Test]
public async Task CloseAsyncShouldHandleErrorsDuringCloseAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldCloseSessionWithoutDeletingSubscriptionsAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
sut.DeleteSubscriptionsOnClose = false;
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldHandleChannelErrorsAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(StatusCodes.Good)); // Eats the error from channel close
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldCloseSessionSuccessfullyAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldHandleErrorsDuringCloseSessionAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public async Task ReconnectAsyncShouldReconnectSuccessfullyAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
await sut.ReconnectAsync(ct).ConfigureAwait(false);
Assert.That(sut.ServerNonce, Is.EqualTo(serverNonce));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldHandleReconnectFailure()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
[Explicit("Requires proper channel creation mocking")]
public void ReconnectAsyncShouldHandleNoSupportedFeatures()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.None);
// TODO: Should properly mock channel creation
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldHandleCancellation()
{
using var sut = SessionMock.Create();
sut.SetConnected();
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
Assert.ThrowsAsync<TaskCanceledException>(
async () => await sut.ReconnectAsync(default).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public async Task ReconnectAsyncShouldHandleServerResponseWithNullNonceAsync()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = default,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
await sut.ReconnectAsync(ct).ConfigureAwait(false);
Assert.That(sut.ServerNonce.IsEmpty, Is.True);
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldThrowWithIncompatibleIdentity()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "T",
TokenType = UserTokenType.Certificate
}
]
};
using var sut = SessionMock.Create(ep);
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenInvalid));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldThrowWithBadActivationResponse()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionNotActivated },
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldThrowWhenTimingOut()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException())
.Verifiable(Times.Once);
Assert.ThrowsAsync<OperationCanceledException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public async Task OpenAsyncShouldOpenSessionSuccessfullyAsync()
{
// Arrange
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
var authToken = NodeId.Parse("s=cookie");
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = serverNonce,
SessionId = NodeId.Parse("s=connected"),
AuthenticationToken = authToken,
ServerEndpoints = [ep]
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r => r.RequestHeader.AuthenticationToken == authToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Read limit and also first keep alive timers
// Server_ServerStatus_State is a ServerState enum which is wire-encoded as Int32.
// Using UInt32 here causes ClientBase.ValidateDataValue to log spurious
// BadTypeMismatch warnings on every keep-alive while the test runs.
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 1),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results =
[
new (new Variant((int)ServerState.Running))
],
DiagnosticInfos = []
}))
.Verifiable(Times.AtLeastOnce);
// Operation limits
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 27),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [.. Enumerable
.Range(0, 27)
.Select(_ => new DataValue(Variant.Null))],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Namespaces
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 2),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results =
[
new(ArrayOf.Create([Namespaces.OpcUa])),
new(ArrayOf.Empty<string>())
],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
var identity = new UserIdentity();
await sut.OpenAsync("test", identity, ct).ConfigureAwait(false);
Assert.That(sut.ServerNonce, Is.EqualTo(ByteString.From([1, 2, 3, 4])));
sut.Channel.Verify();
}
[Test]
public async Task OpenAsyncAcceptsServerEndpointsWithDifferentOrderingAsync()
{
EndpointDescription primaryEndpoint = CreateSessionEndpointDescription(
"opc.tcp://localhost:4840");
primaryEndpoint.SecurityLevel = 1;
primaryEndpoint.TransportProfileUri = Profiles.UaTcpTransport;
EndpointDescription secondaryEndpoint = CreateSessionEndpointDescription(
"opc.tcp://localhost:4841");
secondaryEndpoint.SecurityMode = MessageSecurityMode.Sign;
secondaryEndpoint.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
secondaryEndpoint.SecurityLevel = 2;
secondaryEndpoint.TransportProfileUri = Profiles.UaTcpTransport;
using var sut = SessionMock.Create(
primaryEndpoint,
[primaryEndpoint, secondaryEndpoint],
[Profiles.UaTcpTransport]);
ConfigureSuccessfulOpenResponses(
sut.Channel,
[secondaryEndpoint, primaryEndpoint],
ByteString.From([1, 2, 3, 4]),
NodeId.Parse("s=cookie"));
await sut.OpenAsync("test", new UserIdentity(), CancellationToken.None)
.ConfigureAwait(false);
Assert.That(sut.ServerNonce, Is.EqualTo(ByteString.From([1, 2, 3, 4])));
sut.Channel.Verify();
}
[Test]
public async Task OpenAsyncAcceptsUserTokenPoliciesWithDifferentOrderingAsync()
{
EndpointDescription discoveryEndpoint = CreateSessionEndpointDescription(
"opc.tcp://localhost:4840");
discoveryEndpoint.TransportProfileUri = Profiles.UaTcpTransport;
discoveryEndpoint.UserIdentityTokens =
[
CreateUserTokenPolicy("anonymous", UserTokenType.Anonymous),
CreateUserTokenPolicy("username", UserTokenType.UserName)
];
EndpointDescription responseEndpoint = CreateSessionEndpointDescription(
"opc.tcp://localhost:4840");
responseEndpoint.TransportProfileUri = Profiles.UaTcpTransport;
responseEndpoint.UserIdentityTokens =
[
CreateUserTokenPolicy("username", UserTokenType.UserName),
CreateUserTokenPolicy("anonymous", UserTokenType.Anonymous)
];
using var sut = SessionMock.Create(
discoveryEndpoint,
[discoveryEndpoint],
[Profiles.UaTcpTransport]);
ConfigureSuccessfulOpenResponses(
sut.Channel,
[responseEndpoint],
ByteString.From([1, 2, 3, 4]),
NodeId.Parse("s=cookie"));
await sut.OpenAsync("test", new UserIdentity(), CancellationToken.None)
.ConfigureAwait(false);
Assert.That(sut.ServerNonce, Is.EqualTo(ByteString.From([1, 2, 3, 4])));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleCreateSessionSuccessButActivationError()
{
// Arrange
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
var authToken = NodeId.Parse("s=cookie");
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = serverNonce,
SessionId = NodeId.Parse("s=connected"),
AuthenticationToken = authToken,
ServerEndpoints = [ep]
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r => r.RequestHeader.AuthenticationToken == authToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionNotActivated },
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleCreateSessionSuccessButActivationErrorAndThenCloseAlsoFails()
{
// Arrange
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
var authToken = NodeId.Parse("s=cookie");
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = serverNonce,
SessionId = NodeId.Parse("s=connected"),
AuthenticationToken = authToken,
ServerEndpoints = [ep]
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r => r.RequestHeader.AuthenticationToken == authToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionNotActivated },
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadNotConnected }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Throws(new ServiceResultException(StatusCodes.BadNotConnected))
.Verifiable(Times.Once);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync(
"test",
60000,
identity,
default,
true,
closeChannel: true,
default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleSessionOpeningFailure()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Exactly(2));
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
#if FALSE // TODO: Enable when moving certificate loading into OpenAsync
[Test]
public void OpenAsyncShouldHandleBadSecurityPolicy()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = "Bad",
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSecurityPolicyRejected));
sut.Channel.Verify();
}
#endif
[Test]
public void OpenAsyncShouldHandleBadIdentityTokenPolicy()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "PolicyId",
TokenType = UserTokenType.IssuedToken
}
]
};
using var sut = SessionMock.Create(ep);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenInvalid));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleCancellation()
{
using var sut = SessionMock.Create();
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException())
.Verifiable(Times.Once);
var identity = new UserIdentity();
Assert.ThrowsAsync<TaskCanceledException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleInvalidServerResponse()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = default,
SessionId = NodeId.Parse("s=connected")
}))
.Verifiable(Times.Once);
var identity = new UserIdentity();
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public async Task RecreateInPlaceAsyncReusesTokenWhenEnabledAsync()
{
EndpointDescription failoverDescription = CreateSessionEndpointDescription("opc.tcp://failover:4840");
using var sut = SessionMock.Create();
sut.SetConnected();
sut.EnableTokenReuseFailover = true;
SetServerNonce(sut, [1, 2, 3, 4]);
Mock<ITransportChannel> failoverChannel = CreateReconnectChannelMock(sut, failoverDescription);
failoverChannel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r =>
r.RequestHeader.AuthenticationToken == NodeId.Parse("s=auth")),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = [5, 6, 7, 8],
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
await InvokeRecreateInPlaceCoreAsync(
sut,
new ConfiguredEndpoint(null, failoverDescription),
failoverChannel.Object,
requireTokenReuse: false).ConfigureAwait(false);
failoverChannel.Verify();
failoverChannel.Verify(
c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Test]
public async Task RecreateInPlaceAsyncFallsBackToFullReauthenticationWhenTokenReuseFailsAsync()
{
EndpointDescription failoverDescription = CreateSessionEndpointDescription("opc.tcp://failover:4840");
using var sut = SessionMock.Create();
sut.SetConnected();
sut.EnableTokenReuseFailover = true;
SetServerNonce(sut, [1, 2, 3, 4]);
var newSessionId = NodeId.Parse("s=recreated");
var newAuthenticationToken = NodeId.Parse("s=new-auth");
Mock<ITransportChannel> failoverChannel = CreateReconnectChannelMock(sut, failoverDescription);
ConfigureOpenAsyncReadResponses(failoverChannel);
failoverChannel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r =>
r.RequestHeader.AuthenticationToken == NodeId.Parse("s=auth")),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadSessionNotActivated))
.Verifiable(Times.Once);
failoverChannel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = [9, 10, 11, 12],
SessionId = newSessionId,
AuthenticationToken = newAuthenticationToken,
ServerEndpoints = [failoverDescription]
}))
.Verifiable(Times.Once);
failoverChannel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r =>
r.RequestHeader.AuthenticationToken == newAuthenticationToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = [13, 14, 15, 16],
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
await InvokeRecreateInPlaceCoreAsync(
sut,
new ConfiguredEndpoint(null, failoverDescription),
failoverChannel.Object,
requireTokenReuse: false).ConfigureAwait(false);
Assert.That(sut.SessionId, Is.EqualTo(newSessionId));
failoverChannel.Verify();
}
[Test]
public void ReactivateMirroredSessionAsyncThrowsWhenTokenReuseIsRequiredButDisabled()
{
EndpointDescription failoverDescription = CreateSessionEndpointDescription("opc.tcp://failover:4840");
using var sut = SessionMock.Create();
sut.SetConnected();
sut.EnableTokenReuseFailover = false;
SetServerNonce(sut, [1, 2, 3, 4]);
Mock<ITransportChannel> failoverChannel = CreateReconnectChannelMock(sut, failoverDescription);
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
await InvokeRecreateInPlaceCoreAsync(
sut,
new ConfiguredEndpoint(null, failoverDescription),
failoverChannel.Object,
requireTokenReuse: true).ConfigureAwait(false))!;
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
failoverChannel.Verify(
c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Test]
public void SaveShouldOnlySaveSpecifiedSubscriptions()
{
using var sut = SessionMock.Create();
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
using var subscription1 = new Subscription(telemetry, new SubscriptionOptions { DisplayName = "Subscription1" });
using var subscription2 = new Subscription(telemetry, new SubscriptionOptions { DisplayName = "Subscription2" });
using var subscription3 = new Subscription(telemetry, new SubscriptionOptions { DisplayName = "Subscription3" });
sut.AddSubscription(subscription1);
sut.AddSubscription(subscription2);
sut.AddSubscription(subscription3);
Assert.That(sut.SubscriptionCount, Is.EqualTo(3));
// Only save a subset of subscriptions (subscription1 and subscription3)
Subscription[] subscriptionsToSave = [subscription1, subscription3];
using var stream = new MemoryStream();
sut.Save(stream, subscriptionsToSave);
stream.Position = 0;
using var loadSession = SessionMock.Create();
var loadedSubscriptions = loadSession.Load(stream).ToList();
Assert.That(loadedSubscriptions, Has.Count.EqualTo(2), "Only the specified subscriptions should be saved");
Assert.That(loadedSubscriptions.Select(s => s.DisplayName), Is.EquivalentTo(["Subscription1", "Subscription3"]));
}
[Test]
public void SaveAndApplySessionConfigurationShouldPersistClientNonce()
{
var source = SessionMock.Create();
source.SetConnected();
byte[] clientNonce = [1, 3, 5, 7, 9];
byte[] serverNonce = [2, 4, 6, 8];
SetClientNonce(source, clientNonce);
SetServerNonce(source, serverNonce);
SessionConfiguration configuration = source.SaveSessionConfiguration();
Assert.That(configuration.ClientNonce.ToArray(), Is.EquivalentTo(clientNonce));
Assert.That(configuration.ServerNonce.ToArray(), Is.EquivalentTo(serverNonce));
var target = SessionMock.Create();
bool success = target.ApplySessionConfiguration(configuration);
Assert.That(success, Is.True);
Assert.That(GetClientNonce(target), Is.EquivalentTo(clientNonce));
Assert.That(GetServerNonce(target), Is.EquivalentTo(serverNonce));
}
private static EndpointDescription CreateSessionEndpointDescription(string endpointUrl)
{
return new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = endpointUrl,
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
}
private static UserTokenPolicy CreateUserTokenPolicy(
string policyId,
UserTokenType tokenType)
{
return new UserTokenPolicy
{
PolicyId = policyId,
TokenType = tokenType,
SecurityPolicyUri = SecurityPolicies.None
};
}
private static Mock<ITransportChannel> CreateReconnectChannelMock(
SessionMock session,
EndpointDescription endpointDescription)
{
var channel = new Mock<ITransportChannel>();
channel.SetupGet(c => c.MessageContext).Returns(session.MessageContext);
channel.SetupGet(c => c.SupportedFeatures).Returns(TransportChannelFeatures.Reconnect);
channel.SetupGet(c => c.EndpointDescription).Returns(endpointDescription);
channel.SetupGet(c => c.EndpointConfiguration).Returns(new EndpointConfiguration());
channel.SetupGet(c => c.ChannelThumbprint).Returns([]);
channel.SetupGet(c => c.ClientChannelCertificate).Returns([]);
channel.SetupGet(c => c.ServerChannelCertificate).Returns([]);
return channel;
}
private static void ConfigureSuccessfulOpenResponses(
Mock<ITransportChannel> channel,
ArrayOf<EndpointDescription> serverEndpoints,
ByteString serverNonce,
NodeId authToken)
{
channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = serverNonce,
SessionId = NodeId.Parse("s=connected"),
AuthenticationToken = authToken,
ServerEndpoints = serverEndpoints
}));
channel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r => r.RequestHeader.AuthenticationToken == authToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}));
ConfigureOpenAsyncReadResponses(channel);
}
private static void ConfigureOpenAsyncReadResponses(Mock<ITransportChannel> channel)
{
channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 1),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results =
[
new(new Variant((int)ServerState.Running))
],
DiagnosticInfos = []
}));
channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 27),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [.. Enumerable
.Range(0, 27)
.Select(_ => new DataValue(Variant.Null))],
DiagnosticInfos = []
}));
channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 2),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results =
[
new(ArrayOf.Create([Namespaces.OpcUa])),
new(ArrayOf.Empty<string>())
],
DiagnosticInfos = []
}));
}
private static Task InvokeRecreateInPlaceCoreAsync(
Session session,
ConfiguredEndpoint endpoint,
ITransportChannel channel,
bool requireTokenReuse)
{
MethodInfo? method = typeof(Session).GetMethod(
"RecreateInPlaceCoreAsync",
BindingFlags.NonPublic | BindingFlags.Instance,
null,
[
typeof(ConfiguredEndpoint),
typeof(ITransportWaitingConnection),
typeof(ITransportChannel),
typeof(IRetryBudget),
typeof(CancellationToken),
typeof(bool),
typeof(bool)
],
null);
Assert.That(method, Is.Not.Null);
var task = (Task?)method!.Invoke(
session,
[endpoint, null, channel, null, CancellationToken.None, false, requireTokenReuse]);
Assert.That(task, Is.Not.Null);
return task!;
}
private const BindingFlags PrivateInstance =
BindingFlags.NonPublic | BindingFlags.Instance;
private static void SetClientNonce(Session session, byte[] value)
{
typeof(Session)
.GetField("m_clientNonce", PrivateInstance)
.SetValue(session, value.ToArray());
}
private static void SetServerNonce(Session session, byte[] value)
{
typeof(Session)
.GetField("m_serverNonce", PrivateInstance)
.SetValue(session, ByteString.From(value));
}
private static byte[] GetClientNonce(Session session)
{
return typeof(Session)
.GetField("m_clientNonce", PrivateInstance)
.GetValue(session) is byte[] bytes
? [.. bytes]
: [];
}
private static byte[] GetServerNonce(Session session)
{
return ((ByteString)typeof(Session)
.GetField("m_serverNonce", PrivateInstance)
.GetValue(session)).ToArray();
}
private static ArrayOf<DataValue> CreateOperationLimitsRead(uint operationLimit, uint maxArrayLength)
{
// Mirrors the node order read by FetchOperationLimitsAsync: the 12 operation
// limits (all set to operationLimit) followed by the server capabilities, of
// which only MaxArrayLength (index 16) varies.
return
[
new DataValue(new Variant(operationLimit)), // MaxNodesPerHistoryReadData
new DataValue(new Variant(operationLimit)), // MaxNodesPerHistoryReadEvents
new DataValue(new Variant(operationLimit)), // MaxNodesPerWrite
new DataValue(new Variant(operationLimit)), // MaxNodesPerRead
new DataValue(new Variant(operationLimit)), // MaxNodesPerHistoryUpdateData
new DataValue(new Variant(operationLimit)), // MaxNodesPerHistoryUpdateEvents
new DataValue(new Variant(operationLimit)), // MaxNodesPerMethodCall
new DataValue(new Variant(operationLimit)), // MaxNodesPerBrowse
new DataValue(new Variant(operationLimit)), // MaxNodesPerRegisterNodes
new DataValue(new Variant(operationLimit)), // MaxNodesPerNodeManagement
new DataValue(new Variant(operationLimit)), // MaxMonitoredItemsPerCall
new DataValue(new Variant(operationLimit)), // MaxNodesPerTranslateBrowsePathsToNodeIds
new DataValue(new Variant((ushort)100)), // MaxBrowseContinuationPoints
new DataValue(new Variant((ushort)100)), // MaxHistoryContinuationPoints
new DataValue(new Variant((ushort)100)), // MaxQueryContinuationPoints
new DataValue(new Variant(1000u)), // MaxStringLength
new DataValue(new Variant(maxArrayLength)), // MaxArrayLength
new DataValue(new Variant(1000u)), // MaxByteStringLength
new DataValue(new Variant(100.0)), // MinSupportedSampleRate
new DataValue(new Variant(1000u)), // MaxSessions
new DataValue(new Variant(1000u)), // MaxSubscriptions
new DataValue(new Variant(1000u)), // MaxMonitoredItems
new DataValue(new Variant(1000u)), // MaxMonitoredItemsPerSubscription
new DataValue(new Variant(1000u)), // MaxMonitoredItemsQueueSize
new DataValue(new Variant(1000u)), // MaxSubscriptionsPerSession
new DataValue(new Variant(1000u)), // MaxWhereClauseParameters
new DataValue(new Variant(1000u)) // MaxSelectClauseParameters
];
}
}
}