Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/Session/SessionTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

2972 lines
128 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.Collections.Generic;
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
{
[TestCase(3, false, true)]
[TestCase(4, true, true)]
[TestCase(4, false, false)]
public async Task FetchTypeTreeVisitsEveryNodeAndTerminatesAsync(int count, bool singleId, bool cycle)
{
using var session = SessionMock.Create();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
ushort namespaceIndex = session.NamespaceUris.GetIndexOrAppend("urn:test:type-traversal");
var ids = Enumerable.Range(1, count)
.Select(value => new NodeId((uint)value, namespaceIndex)).ToArrayOf();
var browsed = new HashSet<NodeId>();
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<ReadRequest>(), It.IsAny<CancellationToken>()))
.Returns((ReadRequest request, CancellationToken ct) =>
{
ct.ThrowIfCancellationRequested();
return new ValueTask<IServiceResponse>(new ReadResponse
{
Results = request.NodesToRead.ConvertAll(item => item.AttributeId switch
{
Attributes.NodeId => new DataValue(item.NodeId),
Attributes.NodeClass => new DataValue((int)NodeClass.ObjectType),
Attributes.BrowseName => new DataValue(new QualifiedName("Type", namespaceIndex)),
Attributes.DisplayName => new DataValue(LocalizedText.From("Type")),
Attributes.IsAbstract => new DataValue(false),
_ => DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid)
})
});
});
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<BrowseRequest>(), It.IsAny<CancellationToken>()))
.Returns((BrowseRequest request, CancellationToken ct) =>
{
ct.ThrowIfCancellationRequested();
return new ValueTask<IServiceResponse>(new BrowseResponse
{
Results = request.NodesToBrowse.ConvertAll(item =>
{
browsed.Add(item.NodeId);
int index = ids.ToList().IndexOf(item.NodeId);
return new BrowseResult
{
References = index == count - 1 && !cycle ? [] :
[
new ReferenceDescription
{
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = true,
NodeId = new ExpandedNodeId(ids[(index + 1) % count]),
NodeClass = NodeClass.ObjectType,
BrowseName = new QualifiedName("Type", namespaceIndex)
}
]
};
})
});
});
IServiceMessageContext context = session.MessageContext;
int lookups = 0;
session.Channel.SetupGet(channel => channel.MessageContext).Returns(() =>
{
Assert.That(++lookups, Is.LessThan(128),
"The cached graph must terminate instead of recursively revisiting the cycle.");
return context;
});
try
{
if (singleId)
{
await session.FetchTypeTreeAsync(new ExpandedNodeId(ids[0]), timeout.Token).ConfigureAwait(false);
}
else
{
await session.FetchTypeTreeAsync(
[new ExpandedNodeId(ids[0])], timeout.Token).ConfigureAwait(false);
}
}
finally
{
session.Channel.SetupGet(channel => channel.MessageContext).Returns(context);
}
Assert.That(browsed, Is.EquivalentTo(ids.ToList()));
}
[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.SetConnectedAndResponsive();
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.SetConnectedAndResponsive();
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();
}
/// <summary>
/// A request with a null authenticationToken is a session-less
/// invocation (OPC 10000-4 §6.3.1). A closed Session must not send
/// one through the channel it keeps open.
/// </summary>
[Test]
public async Task RequestAfterCloseFailsLocallyWithBadSessionIdInvalidAsync()
{
using var sut = SessionMock.Create();
sut.SetConnectedAndResponsive();
CancellationToken ct = CancellationToken.None;
SetupCloseSession(sut);
List<ReadRequest> sent = SetupRead(sut);
StatusCode closed = await sut.CloseAsync(closeChannel: false, ct).ConfigureAwait(false);
Assert.That(closed, Is.EqualTo(StatusCodes.Good));
ServiceResultException? sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await ReadServerStateAsync(sut, ct).ConfigureAwait(false));
Assert.That(sre!.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(sent, Is.Empty, "The request must not reach the channel.");
}
[Test]
public async Task ActivateSessionIsStillSentAfterCloseAsync()
{
using var sut = SessionMock.Create();
sut.SetConnectedAndResponsive();
CancellationToken ct = CancellationToken.None;
SetupCloseSession(sut);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ActivateSessionResponse())
.Verifiable(Times.Once);
await sut.CloseAsync(closeChannel: false, ct).ConfigureAwait(false);
ActivateSessionResponse response = await sut.ActivateSessionAsync(
null,
new SignatureData(),
default,
default,
new ExtensionObject(),
new SignatureData(),
ct).ConfigureAwait(false);
Assert.That(response, Is.Not.Null);
sut.Channel.Verify();
}
[Test]
public async Task RequestAfterANewSessionIsCreatedCarriesItsTokenAsync()
{
using var sut = SessionMock.Create();
sut.SetConnectedAndResponsive();
CancellationToken ct = CancellationToken.None;
SetupCloseSession(sut);
List<ReadRequest> sent = SetupRead(sut);
await sut.CloseAsync(closeChannel: false, ct).ConfigureAwait(false);
sut.SessionCreated(NodeId.Parse("s=second"), NodeId.Parse("s=auth2"));
await ReadServerStateAsync(sut, ct).ConfigureAwait(false);
Assert.That(sent, Has.Count.EqualTo(1));
Assert.That(sent[0].RequestHeader.AuthenticationToken, Is.EqualTo(NodeId.Parse("s=auth2")));
}
[Test]
public async Task RequestBeforeASessionIsCreatedIsNotRefusedAsync()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
List<ReadRequest> sent = SetupRead(sut);
await ReadServerStateAsync(sut, ct).ConfigureAwait(false);
Assert.That(sent, Has.Count.EqualTo(1));
Assert.That(sent[0].RequestHeader.AuthenticationToken.IsNull, Is.True);
}
[Test]
public async Task DisposeAsyncSendsCloseSessionAsync()
{
// Arrange
var sut = SessionMock.Create();
sut.SetConnectedAndResponsive();
CloseSessionRequest? closeRequest = null;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Callback((IServiceRequest request, CancellationToken _) =>
closeRequest = request as CloseSessionRequest)
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
// Act
await sut.DisposeAsync().ConfigureAwait(false);
// Assert
sut.Channel.Verify();
Assert.That(closeRequest, Is.Not.Null);
Assert.That(closeRequest!.DeleteSubscriptions, Is.True);
Assert.That(closeRequest.RequestHeader.AuthenticationToken, Is.EqualTo(NodeId.Parse("s=auth")));
}
[Test]
public async Task CloseAsyncShouldCloseSessionWithoutDeletingSubscriptionsAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnectedAndResponsive();
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.SetConnectedAndResponsive();
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.SetConnectedAndResponsive();
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.SetConnectedAndResponsive();
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();
}
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
public async Task ShutdownCancellationOfKeepAliveStillClosesServerSessionAsync(
bool disposeAsync,
bool serverRejectsClose)
{
StatusCode closeStatus = serverRejectsClose ? StatusCodes.BadSessionIdInvalid : StatusCodes.Good;
EndpointDescription endpoint = CreateSessionEndpointDescription("opc.tcp://localhost:4840");
await using var session = SessionMock.Create(endpoint, [endpoint]);
ConfigureSuccessfulOpenResponses(
session.Channel,
[endpoint],
ByteString.From([1, 2, 3, 4]),
NodeId.Parse("s=keep-alive-auth"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var readStarted = new TaskCompletionSource<CancellationToken>(
TaskCreationOptions.RunContinuationsAsynchronously);
var readCompletion = new TaskCompletionSource<IServiceResponse>(
TaskCreationOptions.RunContinuationsAsynchronously);
int keepAliveErrors = 0;
session.KeepAlive += (_, args) =>
{
if (ServiceResult.IsBad(args.Status))
{
Interlocked.Increment(ref keepAliveErrors);
}
};
session.Channel
.Setup(channel => channel.SendRequestAsync(
It.Is<ReadRequest>(request =>
request.NodesToRead.Count == 1 &&
request.NodesToRead[0].NodeId == VariableIds.Server_ServerStatus_State),
It.IsAny<CancellationToken>()))
.Returns((ReadRequest _, CancellationToken ct) =>
new ValueTask<IServiceResponse>(ReadUntilCanceledAsync(ct)));
session.Channel
.Setup(channel => channel.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = closeStatus }
}))
.Verifiable(Times.Once);
session.Channel
.Setup(channel => channel.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
await session.OpenAsync("test", new UserIdentity(), timeout.Token).ConfigureAwait(false);
CancellationToken readCancellation = await readStarted.Task
.WaitAsync(timeout.Token).ConfigureAwait(false);
Assert.That(session.KeepAliveStopped, Is.False);
if (disposeAsync)
{
await session.DisposeAsync().AsTask().WaitAsync(timeout.Token).ConfigureAwait(false);
}
else
{
StatusCode result = await session.CloseAsync(5000, true, timeout.Token).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(closeStatus));
}
Assert.That(readCancellation.IsCancellationRequested, Is.True);
Assert.That(Volatile.Read(ref keepAliveErrors), Is.Zero);
Assert.That(session.KeepAliveStopped, Is.False);
session.Channel.Verify();
async Task<IServiceResponse> ReadUntilCanceledAsync(CancellationToken ct)
{
using CancellationTokenRegistration registration = ct.Register(() =>
readCompletion.TrySetException(
new ServiceResultException(StatusCodes.BadRequestCancelledByClient)));
readStarted.TrySetResult(ct);
return await readCompletion.Task.ConfigureAwait(false);
}
}
[Test]
public async Task KeepAliveServiceFailureWithoutCancellationStillStopsLivenessAsync()
{
EndpointDescription endpoint = CreateSessionEndpointDescription("opc.tcp://localhost:4840");
await using var session = SessionMock.Create(endpoint, [endpoint]);
ConfigureSuccessfulOpenResponses(
session.Channel,
[endpoint],
ByteString.From([1, 2, 3, 4]),
NodeId.Parse("s=keep-alive-auth"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var reportedError = new TaskCompletionSource<StatusCode>(
TaskCreationOptions.RunContinuationsAsynchronously);
session.KeepAlive += (_, args) =>
{
if (args.Status is { } status && ServiceResult.IsBad(status))
{
reportedError.TrySetResult(status.StatusCode);
}
};
session.Channel
.Setup(channel => channel.SendRequestAsync(
It.Is<ReadRequest>(request =>
request.NodesToRead.Count == 1 &&
request.NodesToRead[0].NodeId == VariableIds.Server_ServerStatus_State),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadTimeout));
await session.OpenAsync("test", new UserIdentity(), timeout.Token).ConfigureAwait(false);
StatusCode reportedStatus = await reportedError.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
Assert.That(reportedStatus, Is.EqualTo(StatusCodes.BadTimeout));
Assert.That(session.KeepAliveStopped, Is.True);
await session.CloseAsync(5000, true, timeout.Token).ConfigureAwait(false);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[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();
}
[TestCase(null, "", null, "")]
[TestCase("", null, "", null)]
[TestCase(null, null, null, null)]
[TestCase("", "", "", "")]
public async Task OpenAsyncAcceptsNullAndEmptyTokenFieldsAsync(
string? discoveryPolicyId,
string? authenticatedPolicyId,
string? discoveryPolicyUri,
string? authenticatedPolicyUri)
{
EndpointDescription discovery = CreateSessionEndpointDescription("opc.tcp://localhost:4840");
discovery.UserIdentityTokens[0].PolicyId = discoveryPolicyId;
discovery.UserIdentityTokens[0].SecurityPolicyUri = discoveryPolicyUri;
EndpointDescription authenticated = CreateSessionEndpointDescription("opc.tcp://localhost:4840");
authenticated.UserIdentityTokens[0].PolicyId = authenticatedPolicyId;
authenticated.UserIdentityTokens[0].SecurityPolicyUri = authenticatedPolicyUri;
using var session = SessionMock.Create(discovery, [discovery]);
ConfigureSuccessfulOpenResponses(
session.Channel,
[authenticated],
ByteString.From([1, 2, 3, 4]),
NodeId.Parse("s=token"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await session.OpenAsync("test", new UserIdentity(), timeout.Token).ConfigureAwait(false);
Assert.That(session.Connected, Is.True);
session.Channel.Verify(
channel => channel.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Test]
public void OpenAsyncRejectsChangedAuthenticatedTokenType()
{
EndpointDescription discovery = CreateSessionEndpointDescription("opc.tcp://localhost:4840");
discovery.UserIdentityTokens = [CreateUserTokenPolicy("identity", UserTokenType.Anonymous)];
EndpointDescription authenticated = CreateSessionEndpointDescription("opc.tcp://localhost:4840");
authenticated.UserIdentityTokens = [CreateUserTokenPolicy("identity", UserTokenType.UserName)];
using var session = SessionMock.Create(discovery);
ConfigureSuccessfulOpenResponses(
session.Channel,
[authenticated],
ByteString.From([1, 2, 3, 4]),
NodeId.Parse("s=token"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await session.OpenAsync("test", new UserIdentity(), timeout.Token)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
session.Channel.Verify(
channel => channel.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Test]
public async Task RecreateInPlaceDoesNotReuseAnotherEndpointDiscoverySnapshotAsync()
{
EndpointDescription initial = CreateSessionEndpointDescription("opc.tcp://initial:4840");
EndpointDescription extra = CreateSessionEndpointDescription("opc.tcp://initial:4841");
EndpointDescription replacement = CreateSessionEndpointDescription("opc.tcp://replacement:4840");
using var session = SessionMock.Create(initial, [initial, extra]);
session.SetConnected();
var endpoint = new ConfiguredEndpoint(null, replacement, new EndpointConfiguration());
Mock<ITransportChannel> channel = CreateReconnectChannelMock(session, replacement);
ConfigureSuccessfulOpenResponses(
channel,
[replacement],
ByteString.From([1, 2, 3, 4]),
NodeId.Parse("s=replacement-token"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await session.RecreateInPlaceAsync(endpoint: endpoint, channel: channel.Object, ct: timeout.Token)
.ConfigureAwait(false);
Assert.That(session.Connected, Is.True);
Assert.That(session.ConfiguredEndpoint, Is.SameAs(endpoint));
channel.Verify(value => value.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[TestCase(false)]
[TestCase(true)]
public async Task RecreateRefreshesDiscoveryOnSuppliedChannelAsync(bool reverseConnection)
{
using SessionMock session = CreateSessionWithDiscoverySnapshot();
EndpointDescription refreshed = CoreUtils.Clone(session.ConfiguredEndpoint.Description)!;
refreshed.SecurityLevel = 2;
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<GetEndpointsRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetEndpointsResponse { Endpoints = [refreshed] });
ConfigureSuccessfulOpenResponses(
session.Channel, [refreshed], ByteString.From([1, 2, 3, 4]), NodeId.Parse("s=recreated"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await session.RecreateInPlaceAsync(
connection: reverseConnection ? Mock.Of<ITransportWaitingConnection>() : null,
channel: session.Channel.Object,
ct: timeout.Token).ConfigureAwait(false);
Assert.That(session.Connected, Is.True);
Assert.That(session.ConfiguredEndpoint.Description.EndpointUrl, Is.EqualTo("opc.tcp://localhost:0"));
session.Channel.Verify(channel => channel.SendRequestAsync(
It.Is<GetEndpointsRequest>(request => request.EndpointUrl == "opc.tcp://localhost:0"),
It.IsAny<CancellationToken>()), Times.Once);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<CreateSessionRequest>(), It.IsAny<CancellationToken>()), Times.Once);
session.Channel.Verify(channel => channel.CloseAsync(It.IsAny<CancellationToken>()), Times.Never);
session.Channel.Verify(channel => channel.Dispose(), Times.Never);
}
[TestCaseSource(nameof(s_discoveryUnavailableStatuses))]
public async Task RecreateDiscoveryUnavailableUsesStoredSnapshotAsync(StatusCode status)
{
using SessionMock session = CreateSessionWithDiscoverySnapshot();
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<GetEndpointsRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(status));
ConfigureSuccessfulOpenResponses(
session.Channel, [session.ConfiguredEndpoint.Description],
ByteString.From([1, 2, 3, 4]), NodeId.Parse("s=recreated"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await session.RecreateInPlaceAsync(channel: session.Channel.Object, ct: timeout.Token)
.ConfigureAwait(false);
Assert.That(session.Connected, Is.True);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<GetEndpointsRequest>(), It.IsAny<CancellationToken>()), Times.Once);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(), It.IsAny<CancellationToken>()), Times.Once);
}
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public async Task RecreateDiscoveryComparisonRejectsChangedAuthenticatedEndpointsAsync(
bool discoveryAvailable,
bool changeToken)
{
using SessionMock session = CreateSessionWithDiscoverySnapshot();
EndpointDescription authenticated = CoreUtils.Clone(session.ConfiguredEndpoint.Description)!;
if (changeToken)
{
authenticated.UserIdentityTokens[0].TokenType = UserTokenType.UserName;
}
else
{
authenticated.SecurityLevel = 2;
}
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<GetEndpointsRequest>(), It.IsAny<CancellationToken>()))
.Returns(() => discoveryAvailable
? new ValueTask<IServiceResponse>(new GetEndpointsResponse
{
Endpoints = session.ConfiguredEndpoint.DiscoveryEndpoints
})
: throw new ServiceResultException(StatusCodes.BadServiceUnsupported));
ConfigureSuccessfulOpenResponses(
session.Channel, [authenticated], ByteString.From([1, 2, 3, 4]), NodeId.Parse("s=recreated"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await Assert.ThatAsync(
async () => await session.RecreateInPlaceAsync(
channel: session.Channel.Object, ct: timeout.Token).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>().With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadSecurityChecksFailed)).ConfigureAwait(false);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<CreateSessionRequest>(), It.IsAny<CancellationToken>()), Times.Once);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[TestCaseSource(nameof(s_discoverySecurityStatuses))]
public async Task RecreateDiscoverySecurityFailureDoesNotFallBackAsync(StatusCode status)
{
using SessionMock session = CreateSessionWithDiscoverySnapshot();
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<GetEndpointsRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(status));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await Assert.ThatAsync(
async () => await session.RecreateInPlaceAsync(
channel: session.Channel.Object, ct: timeout.Token).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>().With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(status)).ConfigureAwait(false);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<CreateSessionRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task RecreateDiscoveryCancellationDoesNotActivateAsync()
{
using SessionMock session = CreateSessionWithDiscoverySnapshot();
using var cancellation = new CancellationTokenSource();
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var blocked = new TaskCompletionSource<IServiceResponse>(
TaskCreationOptions.RunContinuationsAsynchronously);
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<GetEndpointsRequest>(), It.IsAny<CancellationToken>()))
.Returns(async (IServiceRequest _, CancellationToken ct) =>
{
entered.TrySetResult(true);
return await blocked.Task.WaitAsync(ct).ConfigureAwait(false);
});
Task recreate = session.RecreateInPlaceAsync(
channel: session.Channel.Object, ct: cancellation.Token);
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
cancellation.Cancel();
await Assert.ThatAsync(
async () => await recreate.ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<CreateSessionRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
finally
{
cancellation.Cancel();
blocked.TrySetCanceled();
}
}
[Test]
public async Task RecreateRejectsEmptyDiscoveryInsteadOfUsingStoredSnapshotAsync()
{
using SessionMock session = CreateSessionWithDiscoverySnapshot();
session.Channel.Setup(channel => channel.SendRequestAsync(
It.IsAny<GetEndpointsRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new GetEndpointsResponse { Endpoints = [] });
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await Assert.ThatAsync(
async () => await session.RecreateInPlaceAsync(
channel: session.Channel.Object, ct: timeout.Token).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>().With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadSecurityChecksFailed)).ConfigureAwait(false);
session.Channel.Verify(channel => channel.SendRequestAsync(
It.IsAny<CreateSessionRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[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();
}
[TestCase(false)]
[TestCase(true)]
public async Task RecreateInPlaceAsyncReusesTokenWhenEnabledOrRequiredAsync(bool requireTokenReuse)
{
EndpointDescription failoverDescription = CreateSessionEndpointDescription("opc.tcp://failover:4840");
using var sut = SessionMock.Create();
sut.SetConnected();
sut.EnableTokenReuseFailover = !requireTokenReuse;
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).ConfigureAwait(false);
Assert.That(sut.SessionId, Is.EqualTo(NodeId.Parse("s=connected")));
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 ReactivateMirroredSessionAsyncRequiresExistingSession()
{
EndpointDescription failoverDescription = CreateSessionEndpointDescription("opc.tcp://failover:4840");
using var sut = SessionMock.Create();
sut.EnableTokenReuseFailover = false;
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<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()),
Times.Never);
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 SessionMock CreateSessionWithDiscoverySnapshot()
{
EndpointDescription description = CreateSessionEndpointDescription("opc.tcp://localhost:0");
description.SecurityLevel = 1;
var session = SessionMock.Create(description);
session.SetConnected();
ArrayOf<EndpointDescription> snapshot = [CoreUtils.Clone(description)!];
typeof(ConfiguredEndpoint).GetProperty(nameof(ConfiguredEndpoint.DiscoveryEndpoints))!
.SetValue(session.ConfiguredEndpoint, snapshot);
return session;
}
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),
typeof(SessionClient),
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, null, false, false]);
Assert.That(task, Is.Not.Null);
return task!;
}
private static readonly StatusCode[] s_discoveryUnavailableStatuses =
[
StatusCodes.BadNotConnected,
StatusCodes.BadTimeout,
StatusCodes.BadServiceUnsupported
];
private static readonly StatusCode[] s_discoverySecurityStatuses =
[
StatusCodes.BadSecurityChecksFailed,
StatusCodes.BadCertificateUntrusted
];
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
];
}
private static void SetupCloseSession(SessionMock sut)
{
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}));
}
private static List<ReadRequest> SetupRead(SessionMock sut)
{
var sent = new List<ReadRequest>();
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Callback((IServiceRequest request, CancellationToken _) => sent.Add((ReadRequest)request))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good },
Results = [new DataValue(new Variant((int)ServerState.Running))]
}));
return sent;
}
private static async Task ReadServerStateAsync(SessionMock sut, CancellationToken ct)
{
await sut.ReadAsync(
null,
0,
TimestampsToReturn.Neither,
[
new ReadValueId
{
NodeId = VariableIds.Server_ServerStatus_State,
AttributeId = Attributes.Value
}
],
ct).ConfigureAwait(false);
}
}
}