mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 08:41:51 +08:00
9b034f8291
# 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>
2972 lines
128 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|