Files
Marc Schier 0000513cde Pluggable user-identity provider model (#3807)
# Description

Introduces a pluggable user-identity provider model covering all OPC UA
Part 6 §6.5 mechanisms (Anonymous, UserName, X509, IssuedToken/JWT) on
both client and server, with full
`Microsoft.Extensions.DependencyInjection` integration, plus the Part 12
§9 `AuthorizationService` two-phase token-request flow **including
refresh-token rotation**, the Part 12 §8 KeyCredential Push model, and a
clean provider model for OPC 10000-12 §7.2 `ApplicationSelfAdmin`. The
work is split across phase-by-phase commits so it can be reviewed
independently; nothing here changes wire-level behaviour or forces
existing applications to migrate.

### Documentation

All under `Docs/`:

- `IdentityProviders.md` — full developer guide: three-layer model,
recipes, configuration reference, "what gets auto-wired" matrix, JWS
algorithm table, identity-augmenters section, **"Implementing your own
provider"** how-to subsections for Entra / OIDC / Windows / AspNetCore
(sibling files merged in per review feedback).
- `DependencyInjection.md` — `Identity` sections with `appsettings.json`
examples, identity-extension matrix, `WithAuthorizationService` +
`WithKeyCredentialPush` + `AddIdentityAugmenter` extensions documented.
- `RoleBasedUserManagement.md` — claim-criteria callout describing
`IIdentityClaims` probe surface.
- `AuthorizationService.md` — Architecture diagram +
`IAccessTokenProvider` conceptual narrative restored from master; modern
`StartRequestToken` / `FinishRequestToken` flow leads; refresh-token
recipe added.
- `KeyCredentialService.md` — Push model + Pull vs Push vs Bridge
decision matrix + Hybrid (Push + Pull) section; explicit experimental
warning for the bridge.
- `GDS.md` — KeyCredential Push, `StartRequestToken`,
`GdsApplicationSelfAdminProvider`, **shipped Refresh-token support with
rotation policy and audit semantics** all documented.
- `MigrationGuide.md` — User Identity Providers section listing
`[Obsolete]` markers + mechanical recipes; SelfAdmin migration via
`IIdentityAugmenter`; `EnableRefreshTokens` default note.
- `README.md` index entries.

## Checklist

- [x] 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.
- [x] 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.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-06-02 01:14:54 +02:00

1644 lines
66 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
#pragma warning disable CA2000
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests
{
[TestFixture]
[Category("Client")]
[Category("Session")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public sealed class SessionTests
{
[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(1000u)),
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(1000u)),
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 FetchNamespaceTablesAsyncShouldFetchAndUpdateTablesAsync()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = new DataValue(new Variant(["http://server1", "http://server2"]));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2"]));
sut.Channel.Verify();
}
[Test]
public async Task FetchNamespaceTablesAsyncShouldFetchAndUpdateTablesAndLogDifferences1Async()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray1 = new DataValue(Variant.From([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
var serverArray1 = new DataValue(Variant.From(["http://server1", "http://server2"]));
var namespaceArray2 = new DataValue(Variant.From([Namespaces.OpcUa, "http://namespace3", "http://namespace2"]));
var serverArray2 = new DataValue(Variant.From(["http://server1", "http://server2", "http://server3"]));
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray1, serverArray1],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray2, serverArray2],
DiagnosticInfos = []
}));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2"]));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace3", "http://namespace2"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2", "http://server3"]));
sut.Channel.Verify();
}
[Test]
public async Task FetchNamespaceTablesAsyncShouldFetchAndUpdateTablesAndLogDifferences2Async()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray1 = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
var serverArray1 = new DataValue(new Variant(["http://server1", "http://server2"]));
var namespaceArray2 = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace3"]));
var serverArray2 = new DataValue(new Variant(["http://server1"]));
sut.Channel
.SetupSequence(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray1, serverArray1],
DiagnosticInfos = []
}))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray2, serverArray2],
DiagnosticInfos = []
}));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2", "http://namespace3"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1", "http://server2"]));
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace3"]));
Assert.That(sut.ServerUris.ToArray(), Is.EquivalentTo(["http://server1"]));
sut.Channel.Verify();
}
[Test]
public async Task FetchNamespaceTablesAsyncShouldHandlePartialSuccessAsync()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = DataValue.FromStatusCode(StatusCodes.BadUnexpectedError);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Act
await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(sut.NamespaceUris.ToArray(), Is.EquivalentTo([Namespaces.OpcUa, "http://namespace2"]));
Assert.That(sut.ServerUris.ToArray(), Is.Empty);
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowInCaseOfBadResponse()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = new DataValue(new Variant(["http://server1", "http://server2"]));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadUnexpectedError },
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowWhenNamespaceArrayCouldNotBeRetrieved()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = DataValue.FromStatusCode(StatusCodes.BadUnexpectedError);
var serverArray = DataValue.FromStatusCode(StatusCodes.BadUnexpectedError);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShoulThrowWhenEmptyResponse()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
ArrayOf<DataValue> dataValues = [];
ArrayOf<DiagnosticInfo> diagnosticInfos = [];
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = dataValues,
DiagnosticInfos = diagnosticInfos
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowWhenInvalidDataTypesForNamespaceTable()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant(12345));
var serverArray = new DataValue(new Variant(67890));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
sut.Channel.Verify();
}
[Test]
public void FetchNamespaceTablesAsyncShouldThrowWhenInvalidDataTypeForServerUrls()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
var namespaceArray = new DataValue(new Variant([Namespaces.OpcUa, "http://namespace2"]));
var serverArray = new DataValue(new Variant(67890));
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ReadRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [namespaceArray, serverArray],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.FetchNamespaceTablesAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldCloseSessionAndChannelSuccessfullyAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldHandleAlreadyClosedSessionAsync()
{
// Arrange
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
sut.Dispose();
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
}
[Test]
public async Task CloseAsyncShouldHandleErrorsDuringCloseAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldCloseSessionWithoutDeletingSubscriptionsAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
sut.DeleteSubscriptionsOnClose = false;
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldHandleChannelErrorsAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(StatusCodes.Good)); // Eats the error from channel close
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldCloseSessionSuccessfullyAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.Good));
sut.Channel.Verify();
}
[Test]
public async Task CloseAsyncShouldHandleErrorsDuringCloseSessionAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
// Act
StatusCode result = await sut.CloseAsync(ct).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
public async Task ReconnectAsyncShouldReconnectSuccessfullyAsync()
{
// Arrange
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
await sut.ReconnectAsync(ct).ConfigureAwait(false);
Assert.That(sut.ServerNonce, Is.EqualTo(serverNonce));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldHandleReconnectFailure()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
[Test]
[Explicit("Requires proper channel creation mocking")]
public void ReconnectAsyncShouldHandleNoSupportedFeatures()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.None);
// TODO: Should properly mock channel creation
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldHandleCancellation()
{
using var sut = SessionMock.Create();
sut.SetConnected();
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
Assert.ThrowsAsync<TaskCanceledException>(
async () => await sut.ReconnectAsync(default).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public async Task ReconnectAsyncShouldHandleServerResponseWithNullNonceAsync()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = default,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
await sut.ReconnectAsync(ct).ConfigureAwait(false);
Assert.That(sut.ServerNonce.IsEmpty, Is.True);
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldThrowWithIncompatibleIdentity()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "T",
TokenType = UserTokenType.Certificate
}
]
};
using var sut = SessionMock.Create(ep);
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenInvalid));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldThrowWithBadActivationResponse()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionNotActivated },
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
sut.Channel.Verify();
}
[Test]
public void ReconnectAsyncShouldThrowWhenTimingOut()
{
using var sut = SessionMock.Create();
sut.SetConnected();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SupportedFeatures)
.Returns(TransportChannelFeatures.Reconnect);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<ActivateSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException())
.Verifiable(Times.Once);
Assert.ThrowsAsync<OperationCanceledException>(
async () => await sut.ReconnectAsync(ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public async Task OpenAsyncShouldOpenSessionSuccessfullyAsync()
{
// Arrange
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
var authToken = NodeId.Parse("s=cookie");
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = serverNonce,
SessionId = NodeId.Parse("s=connected"),
AuthenticationToken = authToken,
ServerEndpoints = [ep]
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r => r.RequestHeader.AuthenticationToken == authToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Read limit and also first keep alive timers
// Server_ServerStatus_State is a ServerState enum which is wire-encoded as Int32.
// Using UInt32 here causes ClientBase.ValidateDataValue to log spurious
// BadTypeMismatch warnings on every keep-alive while the test runs.
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 1),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results =
[
new (new Variant((int)ServerState.Running))
],
DiagnosticInfos = []
}))
.Verifiable(Times.AtLeastOnce);
// Operation limits
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 27),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results = [.. Enumerable
.Range(0, 27)
.Select(_ => new DataValue(Variant.Null))],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
// Namespaces
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ReadRequest>(r => r.NodesToRead.Count == 2),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ReadResponse
{
Results =
[
new(ArrayOf.Create([Namespaces.OpcUa])),
new(ArrayOf.Empty<string>())
],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
var identity = new UserIdentity();
await sut.OpenAsync("test", identity, ct).ConfigureAwait(false);
Assert.That(sut.ServerNonce, Is.EqualTo(ByteString.From([1, 2, 3, 4])));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleCreateSessionSuccessButActivationError()
{
// Arrange
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
var authToken = NodeId.Parse("s=cookie");
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = serverNonce,
SessionId = NodeId.Parse("s=connected"),
AuthenticationToken = authToken,
ServerEndpoints = [ep]
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r => r.RequestHeader.AuthenticationToken == authToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionNotActivated },
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask())
.Verifiable(Times.Once);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleCreateSessionSuccessButActivationErrorAndThenCloseAlsoFails()
{
// Arrange
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
CancellationToken ct = CancellationToken.None;
ByteString serverNonce = [1, 2, 3, 4];
var authToken = NodeId.Parse("s=cookie");
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = serverNonce,
SessionId = NodeId.Parse("s=connected"),
AuthenticationToken = authToken,
ServerEndpoints = [ep]
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.Is<ActivateSessionRequest>(r => r.RequestHeader.AuthenticationToken == authToken),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new ActivateSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadSessionNotActivated },
ServerNonce = serverNonce,
Results = [],
DiagnosticInfos = []
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CloseSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CloseSessionResponse
{
ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadNotConnected }
}))
.Verifiable(Times.Once);
sut.Channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Throws(new ServiceResultException(StatusCodes.BadNotConnected))
.Verifiable(Times.Once);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync(
"test",
60000,
identity,
default,
true,
closeChannel: true,
default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleSessionOpeningFailure()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Exactly(2));
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
sut.Channel.Verify();
}
#if FALSE // TODO: Enable when moving certificate loading into OpenAsync
[Test]
public void OpenAsyncShouldHandleBadSecurityPolicy()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = "Bad",
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy()
]
};
using var sut = SessionMock.Create(ep);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadSecurityPolicyRejected));
sut.Channel.Verify();
}
#endif
[Test]
public void OpenAsyncShouldHandleBadIdentityTokenPolicy()
{
var ep = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
EndpointUrl = "opc.tcp://localhost:4840",
UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "PolicyId",
TokenType = UserTokenType.IssuedToken
}
]
};
using var sut = SessionMock.Create(ep);
var identity = new UserIdentity();
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenInvalid));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleCancellation()
{
using var sut = SessionMock.Create();
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException())
.Verifiable(Times.Once);
var identity = new UserIdentity();
Assert.ThrowsAsync<TaskCanceledException>(
async () => await sut.OpenAsync("test", identity, default).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public void OpenAsyncShouldHandleInvalidServerResponse()
{
using var sut = SessionMock.Create();
CancellationToken ct = CancellationToken.None;
sut.Channel
.Setup(c => c.SendRequestAsync(
It.IsAny<CreateSessionRequest>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<IServiceResponse>(new CreateSessionResponse
{
ServerNonce = default,
SessionId = NodeId.Parse("s=connected")
}))
.Verifiable(Times.Once);
var identity = new UserIdentity();
Assert.ThrowsAsync<ServiceResultException>(
async () => await sut.OpenAsync("test", identity, ct).ConfigureAwait(false));
sut.Channel.Verify();
}
[Test]
public 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 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();
}
}
}