Add 1.5.378 <-> 2.0 interop tests; return BadAttributeIdInvalid for unset optional security attributes (#4630)

# Description

Adds an interop test suite between this stack (2.0) and the released
**1.5.378** NuGet stack, in both directions, and fixes a 2.0 bug the
suite found.

## Interop suite

- **`tests/Opc.Ua.Interop.LegacyPeer`** is a console peer built from the
`OPCFoundation.NetStandard.Opc.Ua.*` 1.5.378 NuGet packages.
- It runs as a child process, because the two stacks share assembly
names.
- It deliberately does not import the repository build settings (its own
`Directory.Build.props/targets/Packages.props`).
- Server mode hosts a small interop server with tight limits, or the 1.5
Quickstarts reference server.
- Client mode runs named checks and prints one JSON result line per
check.
- **`tests/Opc.Ua.Interop.Tests`** builds the peer into `legacy-peer/`
and runs in every CI test leg as a normal `*.Tests.csproj`. It covers:
- sessions over None, the RSA policies and the ECC policies (Sign and
SignAndEncrypt), with anonymous and user name logon, plus
read/write/browse/call/subscribe;
- the existing 2.0 `ClientTest` workers run against the 1.5 reference
server, through the new `ClientTestFramework.ExternalServerUrl`;
- custom data types (structures, unions, optional fields, enums) loaded,
decoded and written back by each side's complex type system;
- certificate trust with auto-accept off: untrusted peers, CA-issued
certificates, revoked certificates;
- multi-chunk messages, encoding limits, response size limits, operation
limits, continuation points and security token renewal (RSA and ECC).
- Every peer check is reported as its own test result.
- `-p:LegacyStackVersion=<version>` tests another 1.5 release.
- Docs: `docs/DeveloperGuide.md`.

## 2.0 fix

**Unset optional security attributes now return
`Bad_AttributeIdInvalid`** (`NodeState.ReadNonValueAttribute`).
- Since 3e5f129c6, unset
RolePermissions/UserRolePermissions/AccessRestrictions returned Good
with a null value.
- This made the 1.5 ComplexTypes loader crash against any 2.0 server.
- 1.5 and Part 4 (Read) return `Bad_AttributeIdInvalid` here.

The suite also found two ECC secure-channel bugs: the Sign-only HKDF
salt length, and renewal chaining the previous secret for policies
without SecureChannelEnhancements. #4626 fixed both on master in the
meantime, so this branch now uses master's version. The ECC Sign and ECC
token-renewal interop tests here cover them.
## Reviewer notes

- **Behaviour change:** `NodeStateOptionalStorageTests` previously
asserted Good with a null value for unset optional attributes. It now
asserts `BadAttributeIdInvalid`.
- Monitored items on such unset attributes now fail with
`BadAttributeIdInvalid`, as in 1.5.
- **Known gaps, not changed here:**
- An unset Description still reads Good with a null value (same root
cause).
- A Null write to AccessRestrictions is accepted, as `NodeStateTests`
asserts.
- **Test runtime:** the suite adds roughly 7 minutes per CI test leg. On
net48 the ECC cases skip, because 2.0 has no ECC on .NET Framework.

## Related Issues

- None filed.

## 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.
- [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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- Ran: interop suite on net10 (188 passed) and on net48 (passed before
the final review fixes), plus Types, Core, InformationModel,
Core.Security and Sessions on net10.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
romanett
2026-10-02 19:35:44 +02:00
committed by GitHub
parent 6b8b2eb79b
commit fbdd0af13b
23 changed files with 5133 additions and 20 deletions
+1
View File
@@ -333,6 +333,7 @@
<Project Path="tests/Opc.Ua.Features.Tests/Opc.Ua.Features.Tests.csproj" />
<Project Path="tests/Opc.Ua.History.Tests/Opc.Ua.History.Tests.csproj" />
<Project Path="tests/Opc.Ua.InformationModel.Tests/Opc.Ua.InformationModel.Tests.csproj" />
<Project Path="tests/Opc.Ua.Interop.Tests/Opc.Ua.Interop.Tests.csproj" />
<Project Path="tests/Opc.Ua.Lds.Tests/Opc.Ua.Lds.Tests.csproj" />
<Project Path="tests/Opc.Ua.Sessions.Tests/Opc.Ua.Sessions.Tests.csproj" />
<Project Path="tests/Opc.Ua.Stress.Tests/Opc.Ua.Stress.Tests.csproj" />
+33
View File
@@ -113,6 +113,39 @@ Conventions and requirements:
- **Testing a specific target framework.** The libraries multi-target, but the test executables run on one framework at a time. To run the suite against a non-default framework, set `CustomTestTarget` (supported values: `net48`, `net8.0`, `net9.0`, `net10.0`). The batch file [`tests/customtest.bat`](../tests/customtest.bat) cleans, restores, and runs the tests for a chosen target; in Visual Studio, uncomment and set the `CustomTestTarget` property in [`targets.props`](../targets.props). A clean build for the target is recommended when switching.
- **CI matrix.** The pull-request gate runs the test suite on **net48** and **net10.0**, and compiles the solution for *every* supported target framework; the remaining test matrices (Debug, .NET 9/8) run in scheduled or manual CI. Fix all failing, flaky, and CodeQL findings in the pipelines. See [Continuous integration](#continuous-integration).
Wire compatibility with the released 1.5 stack is covered by
[`tests/Opc.Ua.Interop.Tests`](../tests/Opc.Ua.Interop.Tests), in both
directions (2.0 client to 1.5 server and 1.5 client to 2.0 server):
- sessions over None, the RSA policies and the ECC policies, with anonymous and
user name logon, and the read, write, browse, call and subscription services;
- the existing 2.0 `ClientTest` workers that do not depend on the in-process
server, run against the 1.5 Quickstarts reference server
(`ClientTestFramework.ExternalServerUrl`);
- custom data types (structures, unions, optional fields, enumerations) loaded,
decoded and written back with the other side's complex type system;
- certificate trust with auto-accept off: untrusted peers, CA-issued
certificates, revoked certificates;
- message sizes and service limits: multi-chunk messages, values above the
peer's encoding limits, responses above the client's MaxMessageSize,
operation limits, browse continuation points and security token renewal.
The 1.5 side is
[`tests/Opc.Ua.Interop.LegacyPeer`](../tests/Opc.Ua.Interop.LegacyPeer), a
console application built from the `OPCFoundation.NetStandard.Opc.Ua.*` NuGet
packages and started as a child process, because the two stacks share assembly
names and cannot be loaded into one process. It deliberately does not import the
repository build settings. In server mode it hosts either a small interop server
with deliberately tight limits or the 1.5 Quickstarts reference server; in client
mode it runs named checks and prints one JSON result line per check, which the
tests report as separate results. The test project builds it and copies it to
`legacy-peer/` next to the tests; pass `-p:LegacyStackVersion=<version>` to test
another 1.5 release, or set `OPCUA_INTEROP_LEGACY_PEER` to the path of a
prebuilt `Opc.Ua.Interop.LegacyPeer.dll`. 1.5.378 cannot reload a certificate it
has just created in a long store path, so the tests keep their PKI folders
directly below the temp folder. The project is a `*.Tests.csproj`, so CI runs it
on every pull-request test leg like any other test project.
Channel recovery regressions use `ManagedSessionReconnectTests` in the client
test project and `ClientChannelManagerManagedTests` / `ReconnectDeadlineTests`
in the core test project. The composed fixture scripts an in-process transport
+12 -13
View File
@@ -4724,10 +4724,9 @@ namespace Opc.Ua
value = Variant.FromStructure(rolePermissions);
return result;
}
if (result != null)
{
return result;
}
// An optional attribute without a value, also after a read
// handler, is not supported by the node (Part 4, Read:
// Bad_AttributeIdInvalid); Good with a null value is invalid.
break;
case Attributes.UserRolePermissions:
ArrayOf<RolePermissionType> userRolePermissions = UserRolePermissions;
@@ -4747,10 +4746,6 @@ namespace Opc.Ua
value = Variant.FromStructure(userRolePermissions);
return result;
}
if (result != null)
{
return result;
}
break;
case Attributes.AccessRestrictions:
AccessRestrictionType? accessRestrictions = AccessRestrictions;
@@ -4769,16 +4764,20 @@ namespace Opc.Ua
value = (ushort)accessRestrictions.GetValueOrDefault();
return result;
}
if (result != null)
{
return result;
}
break;
}
return ServiceResult.Create(StatusCodes.BadAttributeIdInvalid, null);
return s_badAttributeIdInvalid;
}
/// <summary>
/// The result of reading an attribute the node does not support.
/// Shared because the permission checks of every service request
/// read the optional security attributes of each node.
/// </summary>
private static readonly ServiceResult s_badAttributeIdInvalid =
new(StatusCodes.BadAttributeIdInvalid);
/// <summary>
/// When overridden in a derived class, iReads the value for the value attribute.
/// </summary>
@@ -122,6 +122,15 @@ namespace Opc.Ua.Client.TestFramework
/// </summary>
public uint SharedSessionTimeout { get; set; } = 120_000;
public bool SupportsExternalServerUrl { get; set; }
/// <summary>
/// When set before <c>OneTimeSetUpCoreAsync</c>, the tests run
/// against the server at this URL instead of an in-process reference
/// server, without reading the test run parameters.
/// <see cref="ServerFixture"/> and <see cref="ReferenceServer"/> stay
/// null, so only tests that do not use them can run this way.
/// </summary>
public string ExternalServerUrl { get; set; }
public bool UseSamplingGroupsInReferenceNodeManager { get; set; }
/// <summary>
@@ -285,17 +294,21 @@ namespace Opc.Ua.Client.TestFramework
m_logger.LogInformation("Using the Pki Root {FilePath}", PkiRoot);
}
// An external server set in code (the interop tests run the
// client tests against a 1.5 server process) keeps the default
// test sets, which address the same reference server nodes.
string customUrl = ExternalServerUrl;
if (customUrl != null)
{
m_logger.UsingExternalServerUrl(customUrl);
}
// The parameters are read from the .runsettings file
string customUrl = null;
if (SupportsExternalServerUrl)
else if (SupportsExternalServerUrl)
{
customUrl = TestContext.Parameters["ServerUrl"];
if (customUrl?.StartsWith(UriScheme, StringComparison.Ordinal) == true)
{
if (m_logger.IsEnabled(LogLevel.Information))
{
m_logger.LogInformation("Using the external Server Url {Url}", customUrl);
}
m_logger.UsingExternalServerUrl(customUrl);
// load custom test sets
TestSetStatic = ReadCustomTestSet("TestSetStatic");
@@ -878,4 +891,13 @@ namespace Opc.Ua.Client.TestFramework
return list;
}
}
/// <summary>
/// Source-generated log messages of <see cref="ClientTestFramework"/>.
/// </summary>
internal static partial class ClientTestFrameworkLog
{
[LoggerMessage(Level = LogLevel.Information, Message = "Using the external Server Url {Url}")]
public static partial void UsingExternalServerUrl(this ILogger logger, string url);
}
}
@@ -0,0 +1,9 @@
<Project>
<!--
The legacy interop peer is built against the published 1.5.378 NuGet packages, not
against the sources in this repository. It deliberately does not chain to the
repository Directory.Build.props: the repository build settings (central package
management, target framework profiles, analyzers, versioning) apply to the 2.0
sources and would either pin a 2.0 version or reject this project outright.
-->
</Project>
@@ -0,0 +1,3 @@
<Project>
<!-- Stops the repository Directory.Build.targets from applying. See Directory.Build.props. -->
</Project>
@@ -0,0 +1,6 @@
<Project>
<!-- Stops the repository Directory.Packages.props from applying. See Directory.Build.props. -->
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
@@ -0,0 +1,984 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Extensions.Logging;
using Opc.Ua.Client;
using Opc.Ua.Client.ComplexTypes;
using Opc.Ua.Configuration;
namespace Opc.Ua.Interop.LegacyPeer
{
/// <summary>
/// Runs the 1.5.x client side of the interop tests against a server
/// that exposes the Quickstarts reference server address space.
/// </summary>
/// <remarks>
/// Every check prints one line "RESULT {json}" with the properties
/// check, outcome (Passed, Failed) and message. The 2.0 tests turn each
/// line into a test result of its own. "Connect" and "CloseSession" run
/// always; the other checks run when named by --checks.
/// </remarks>
public static class LegacyClientChecks
{
public const string ApplicationName = "LegacyInteropClient";
public const string ReferenceServerNamespace = "http://opcfoundation.org/Quickstarts/ReferenceServer";
public const string TestDataNamespace = "http://test.org/UA/Data/";
/// <summary>
/// The checks a client run can select, in the order they run.
/// </summary>
private static readonly (string Name, Func<ClientContext, Task> Check)[] s_checks =
[
("NamespaceArray", NamespaceArrayAsync),
("ReadServerStatusStructure", ReadServerStatusStructureAsync),
("BrowseObjectsFolder", BrowseObjectsFolderAsync),
("TranslateBrowsePath", TranslateBrowsePathAsync),
("ReadScalars", ReadScalarsAsync),
("ReadAttributes", ReadAttributesAsync),
("WriteAndReadBack", WriteAndReadBackAsync),
("CallMethods", CallMethodsAsync),
("Subscription", SubscriptionAsync),
("ComplexTypes", ComplexTypesAsync),
("LargeArrayRoundTrip", LargeArrayRoundTripAsync),
("LargeByteStringRoundTrip", LargeByteStringRoundTripAsync),
("OversizedRequestRejected", OversizedRequestRejectedAsync),
("BrowseContinuationPoints", BrowseContinuationPointsAsync),
("ReadManyNodes", ReadManyNodesAsync),
("TokenRenewal", TokenRenewalAsync)
];
private const string kDefaultChecks =
"NamespaceArray,ReadServerStatusStructure,BrowseObjectsFolder,TranslateBrowsePath," +
"ReadScalars,ReadAttributes,WriteAndReadBack,CallMethods,Subscription";
public static async Task<int> RunAsync(PeerOptions options)
{
string url = options.Get("url");
string policy = options.Get("policy", SecurityPolicies.None);
var mode = (MessageSecurityMode)Enum.Parse(
typeof(MessageSecurityMode),
options.Get("mode", nameof(MessageSecurityMode.None)),
true);
string user = options.Get("user", string.Empty);
string password = options.Get("password", string.Empty);
string expectedConnectError = options.Get("expect-connect-error", string.Empty);
int tokenLifetime = options.GetInt("token-lifetime", 0);
var selected = new HashSet<string>(
options.Get("checks", kDefaultChecks).Split(',', StringSplitOptions.RemoveEmptyEntries),
StringComparer.Ordinal);
// Connect and CloseSession always run; naming them is allowed.
string[] unknown = [.. selected.Where(name =>
name != "Connect" && name != "CloseSession" && s_checks.All(c => c.Name != name))];
if (unknown.Length > 0)
{
throw new ArgumentException("unknown checks: " + string.Join(", ", unknown));
}
// The harness passes a deadline shorter than its own timeout, so the
// checks end, and report, before the harness kills the process.
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(options.GetInt("timeout-seconds", 300)));
CancellationToken ct = cts.Token;
ITelemetryContext telemetry = DefaultTelemetry.Create(
builder => builder.SetMinimumLevel(LogLevel.Warning));
var application = new ApplicationInstance(telemetry)
{
ApplicationName = ApplicationName,
ApplicationType = ApplicationType.Client
};
// Quotas match the 2.0 reference server fixture so the large
// message checks are limited by the server, not by this client.
ApplicationConfiguration config = await application
.Build(
"urn:localhost:opcfoundation.org:" + ApplicationName,
"uri:opcfoundation.org:" + ApplicationName)
.SetMaxMessageSize(16 * 1024 * 1024)
.SetMaxByteStringLength(16 * 1024 * 1024)
.SetMaxStringLength(4 * 1024 * 1024)
.SetMaxArrayLength(1024 * 1024)
.AsClient()
.AddSecurityConfiguration(
LegacyPki.ApplicationCertificates(
"CN=" + ApplicationName + ", O=OPC Foundation, DC=localhost",
options.PkiRoot,
options.Ecc),
options.PkiRoot)
.SetAutoAcceptUntrustedCertificates(options.AutoAccept)
.CreateAsync(ct)
.ConfigureAwait(false);
if (tokenLifetime > 0)
{
config.TransportQuotas.SecurityTokenLifetime = tokenLifetime;
}
bool haveCertificate = await application
.CheckApplicationInstanceCertificatesAsync(true, null, ct)
.ConfigureAwait(false);
if (!haveCertificate)
{
throw new InvalidOperationException("The application certificate is invalid.");
}
if (options.GetBool("init-only", false))
{
Console.WriteLine("LEGACY-PKI-READY " + config.ApplicationUri);
return Program.ExitSuccess;
}
var checks = new CheckRunner();
ISession session = null;
await checks.RunAsync("Connect", async () =>
{
try
{
EndpointDescription endpoint = await SelectEndpointAsync(config, url, policy, mode, ct)
.ConfigureAwait(false);
IUserIdentity identity = string.IsNullOrEmpty(user)
? new UserIdentity()
: new UserIdentity(user, System.Text.Encoding.UTF8.GetBytes(password));
var configured = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config));
session = await new DefaultSessionFactory(telemetry)
.CreateAsync(config, configured, false, false, ApplicationName, 60000, identity, null, ct)
.ConfigureAwait(false);
}
catch (ServiceResultException sre) when (!string.IsNullOrEmpty(expectedConnectError))
{
string actual = StatusCodes.GetBrowseName(sre.StatusCode);
Require(expectedConnectError.Split(',').Contains(actual),
$"expected {expectedConnectError}, the connect failed with {actual}: {sre.Message}");
return;
}
Require(string.IsNullOrEmpty(expectedConnectError),
$"expected the connect to fail with {expectedConnectError}, but it succeeded");
Require(session.Connected, "the session is not connected");
Require(
session.Endpoint.SecurityPolicyUri == policy && session.Endpoint.SecurityMode == mode,
$"connected to {session.Endpoint.SecurityPolicyUri}/{session.Endpoint.SecurityMode}");
}).ConfigureAwait(false);
if (session == null)
{
return checks.Finish();
}
try
{
var context = new ClientContext(session, telemetry, options, ct);
foreach ((string name, Func<ClientContext, Task> check) in s_checks)
{
if (selected.Contains(name))
{
await checks.RunAsync(name, () => check(context)).ConfigureAwait(false);
}
}
}
finally
{
await checks.RunAsync("CloseSession", async () =>
{
StatusCode status = await ((Session)session).CloseAsync(ct).ConfigureAwait(false);
Require(StatusCode.IsGood(status), "CloseSession returned " + status);
}).ConfigureAwait(false);
session.Dispose();
}
return checks.Finish();
}
private static Task NamespaceArrayAsync(ClientContext c)
{
Require(c.Ns > 0 && c.Ns != ushort.MaxValue, "reference server namespace is missing");
return Task.CompletedTask;
}
private static async Task ReadServerStatusStructureAsync(ClientContext c)
{
DataValue value = await c.Session
.ReadValueAsync(VariableIds.Server_ServerStatus, c.Ct)
.ConfigureAwait(false);
Require(StatusCode.IsGood(value.StatusCode), "status " + value.StatusCode);
var status = ExtensionObject.ToEncodeable(value.Value as ExtensionObject) as ServerStatusDataType;
Require(status != null, "ServerStatus did not decode to ServerStatusDataType: " + value.Value);
Require(status.State == ServerState.Running, "server state " + status.State);
Require(status.BuildInfo != null && !string.IsNullOrEmpty(status.BuildInfo.ProductUri),
"BuildInfo is empty");
}
private static async Task BrowseObjectsFolderAsync(ClientContext c)
{
ReferenceDescriptionCollection references = await BrowseAsync(c, ObjectIds.ObjectsFolder, 0)
.ConfigureAwait(false);
Require(references.Any(r => r.BrowseName.Name == "Server"), "Server object not found");
Require(references.Any(r => r.BrowseName.Name == "CTT"), "CTT folder not found");
}
private static async Task TranslateBrowsePathAsync(ClientContext c)
{
var paths = new BrowsePathCollection
{
new BrowsePath
{
StartingNode = ObjectIds.ObjectsFolder,
RelativePath = RelativePath.Parse("Server/ServerStatus/CurrentTime", c.Session.TypeTree)
}
};
TranslateBrowsePathsToNodeIdsResponse response = await c.Session
.TranslateBrowsePathsToNodeIdsAsync(null, paths, c.Ct)
.ConfigureAwait(false);
Require(StatusCode.IsGood(response.Results[0].StatusCode), "status " + response.Results[0].StatusCode);
Require(
(NodeId)response.Results[0].Targets[0].TargetId == VariableIds.Server_ServerStatus_CurrentTime,
"unexpected target " + response.Results[0].Targets[0].TargetId);
}
private static async Task ReadScalarsAsync(ClientContext c)
{
var nodes = new ReadValueIdCollection
{
Value(c.Id("Scalar_Static_Boolean")),
Value(c.Id("Scalar_Static_Int32")),
Value(c.Id("Scalar_Static_Double")),
Value(c.Id("Scalar_Static_String")),
Value(c.Id("Scalar_Static_DateTime")),
Value(c.Id("Scalar_Static_Guid")),
Value(c.Id("Scalar_Static_ByteString")),
Value(c.Id("Scalar_Static_LocalizedText")),
Value(c.Id("Scalar_Static_QualifiedName")),
Value(c.Id("Scalar_Static_NodeId")),
Value(c.Id("Scalar_Static_Arrays_Int32")),
Value(c.Id("Scalar_Static_Arrays_String"))
};
ReadResponse response = await c.Session
.ReadAsync(null, 0, TimestampsToReturn.Both, nodes, c.Ct)
.ConfigureAwait(false);
Require(response.Results.Count == nodes.Count, "result count " + response.Results.Count);
for (int ii = 0; ii < nodes.Count; ii++)
{
Require(StatusCode.IsGood(response.Results[ii].StatusCode),
$"{nodes[ii].NodeId}: {response.Results[ii].StatusCode}");
}
}
private static async Task ReadAttributesAsync(ClientContext c)
{
NodeId nodeId = c.Id("Scalar_Static_Int32");
var nodes = new ReadValueIdCollection
{
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.NodeClass },
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.BrowseName },
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType },
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevel }
};
ReadResponse response = await c.Session
.ReadAsync(null, 0, TimestampsToReturn.Neither, nodes, c.Ct)
.ConfigureAwait(false);
Require((int)response.Results[0].Value == (int)NodeClass.Variable,
"NodeClass " + response.Results[0].Value);
Require(((QualifiedName)response.Results[1].Value).Name == "Scalar_Static_Int32",
"BrowseName " + response.Results[1].Value);
Require((NodeId)response.Results[2].Value == DataTypeIds.Int32,
"DataType " + response.Results[2].Value);
}
private static async Task WriteAndReadBackAsync(ClientContext c)
{
NodeId int32Id = c.Id("Scalar_Static_Int32");
NodeId stringId = c.Id("Scalar_Static_String");
NodeId doubleArrayId = c.Id("Scalar_Static_Arrays_Double");
double[] doubles = [1.5, -2.25, 1e300];
const string text = "written by 1.5 äöü";
var writes = new WriteValueCollection
{
Write(int32Id, 1234567),
Write(stringId, text),
Write(doubleArrayId, doubles)
};
WriteResponse response = await c.Session.WriteAsync(null, writes, c.Ct).ConfigureAwait(false);
for (int ii = 0; ii < writes.Count; ii++)
{
Require(StatusCode.IsGood(response.Results[ii]), $"{writes[ii].NodeId}: {response.Results[ii]}");
}
ReadResponse read = await c.Session
.ReadAsync(null, 0, TimestampsToReturn.Neither,
new ReadValueIdCollection { Value(int32Id), Value(stringId), Value(doubleArrayId) }, c.Ct)
.ConfigureAwait(false);
Require(Equals(read.Results[0].Value, 1234567), "Int32 read back " + read.Results[0].Value);
Require(Equals(read.Results[1].Value, text), "String read back " + read.Results[1].Value);
Require(read.Results[2].Value is double[] readDoubles && readDoubles.SequenceEqual(doubles),
"Double[] read back " + read.Results[2].Value);
}
private static async Task CallMethodsAsync(ClientContext c)
{
NodeId methods = c.Id("Methods");
var requests = new CallMethodRequestCollection
{
new CallMethodRequest
{
ObjectId = methods,
MethodId = c.Id("Methods_Hello"),
InputArguments = new VariantCollection { new Variant("legacy") }
},
new CallMethodRequest
{
ObjectId = methods,
MethodId = c.Id("Methods_Add"),
InputArguments = new VariantCollection { new Variant(1.5f), new Variant(2u) }
}
};
CallResponse response = await c.Session.CallAsync(null, requests, c.Ct).ConfigureAwait(false);
Require(StatusCode.IsGood(response.Results[0].StatusCode), "Hello " + response.Results[0].StatusCode);
Require(Equals(response.Results[0].OutputArguments[0].Value, "hello legacy"),
"Hello returned " + response.Results[0].OutputArguments[0]);
Require(StatusCode.IsGood(response.Results[1].StatusCode), "Add " + response.Results[1].StatusCode);
Require(Equals(response.Results[1].OutputArguments[0].Value, 3.5f),
"Add returned " + response.Results[1].OutputArguments[0]);
}
private static async Task SubscriptionAsync(ClientContext c)
{
var subscription = new Subscription(c.Session.DefaultSubscription)
{
PublishingInterval = 100,
KeepAliveCount = 10,
LifetimeCount = 100
};
c.Session.AddSubscription(subscription);
await subscription.CreateAsync(c.Ct).ConfigureAwait(false);
int notifications = 0;
var received = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = VariableIds.Server_ServerStatus_CurrentTime,
AttributeId = Attributes.Value,
SamplingInterval = 100,
QueueSize = 10
};
item.Notification += (_, e) =>
{
if (e.NotificationValue is MonitoredItemNotification &&
Interlocked.Increment(ref notifications) >= 3)
{
received.TrySetResult(true);
}
};
subscription.AddItem(item);
await subscription.ApplyChangesAsync(c.Ct).ConfigureAwait(false);
Require(ServiceResult.IsGood(item.Status.Error), "monitored item " + item.Status.Error);
Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(15), c.Ct))
.ConfigureAwait(false);
Require(completed == received.Task, $"only {notifications} data change notifications in 15 s");
await subscription.DeleteAsync(true, c.Ct).ConfigureAwait(false);
await c.Session.RemoveSubscriptionAsync(subscription, c.Ct).ConfigureAwait(false);
}
/// <summary>
/// Loads the server's custom data types with the 1.5 complex type
/// system, decodes every variable whose data type is a custom
/// structure, enumeration, union or structure with optional fields,
/// and writes a sample of the decoded values back unchanged.
/// </summary>
private static async Task ComplexTypesAsync(ClientContext c)
{
var typeSystem = new ComplexTypeSystem(c.Session, c.Telemetry);
bool loaded;
try
{
loaded = await typeSystem.LoadAsync(false, true, c.Ct).ConfigureAwait(false);
}
catch (Exception e)
{
string diagnosis = await DiagnoseEncodingNodesAsync(c).ConfigureAwait(false);
throw new InvalidOperationException(
$"loading the type system failed with {e.GetType().Name}: {e.Message}. {diagnosis}",
e);
}
Require(loaded, "the complex type system did not load every type");
Type[] types = typeSystem.GetDefinedTypes();
string[] typeNames = [.. types.Select(t => t.Name)];
foreach (string required in new[] { "ScalarStructureDataType", "VectorUnion", "VectorWithOptionalFields" })
{
Require(typeNames.Contains(required),
$"type {required} was not created; created: {string.Join(", ", typeNames)}");
}
// Every variable below Objects whose data type is not a namespace 0 type.
List<ReferenceDescription> variables = await BrowseTreeAsync(c, ObjectIds.ObjectsFolder, 30_000)
.ConfigureAwait(false);
var nodes = variables
.Where(r => r.NodeClass == NodeClass.Variable && r.NodeId.NamespaceIndex != 0)
.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, c.Session.NamespaceUris))
.Distinct()
.ToList();
DataValueCollection dataTypes = await ReadAttributeAsync(c, nodes, Attributes.DataType)
.ConfigureAwait(false);
var custom = new List<NodeId>();
for (int ii = 0; ii < nodes.Count; ii++)
{
if (dataTypes[ii].Value is NodeId dataType && dataType.NamespaceIndex != 0)
{
custom.Add(nodes[ii]);
}
}
Require(custom.Count >= 10, $"only {custom.Count} variables with a custom data type found");
DataValueCollection values = await ReadAttributeAsync(c, custom, Attributes.Value).ConfigureAwait(false);
DataValueCollection accessLevels = await ReadAttributeAsync(c, custom, Attributes.UserAccessLevel)
.ConfigureAwait(false);
var undecoded = new List<string>();
var writes = new WriteValueCollection();
int structures = 0;
for (int ii = 0; ii < custom.Count; ii++)
{
if (StatusCode.IsBad(values[ii].StatusCode))
{
continue;
}
foreach (ExtensionObject extension in ExtensionObjects(values[ii].Value))
{
structures++;
if (extension.Body is not IEncodeable)
{
undecoded.Add($"{custom[ii]} ({extension.TypeId}, {extension.Encoding})");
}
}
if (writes.Count < 50 &&
accessLevels[ii].Value is byte access &&
(access & AccessLevels.CurrentWrite) != 0 &&
ExtensionObjects(values[ii].Value).Any())
{
writes.Add(new WriteValue
{
NodeId = custom[ii],
AttributeId = Attributes.Value,
Value = new DataValue(values[ii].WrappedValue)
});
}
}
Require(structures > 0, "no structure values were read");
Require(undecoded.Count == 0,
$"{undecoded.Count} of {structures} structures were not decoded: " +
string.Join("; ", undecoded.Take(10)));
Require(writes.Count > 0, "no writable structure variable found");
WriteResponse response = await c.Session.WriteAsync(null, writes, c.Ct).ConfigureAwait(false);
var rejected = new List<string>();
for (int ii = 0; ii < writes.Count; ii++)
{
if (StatusCode.IsBad(response.Results[ii]))
{
rejected.Add($"{writes[ii].NodeId}: {response.Results[ii]}");
}
}
Require(rejected.Count == 0,
$"{rejected.Count} of {writes.Count} structure writes were rejected: " +
string.Join("; ", rejected.Take(10)));
DataValueCollection readBack = await ReadAttributeAsync(
c, [.. writes.Select(w => w.NodeId)], Attributes.Value).ConfigureAwait(false);
var changed = new List<string>();
for (int ii = 0; ii < writes.Count; ii++)
{
if (!Utils.IsEqual(writes[ii].Value.Value, readBack[ii].Value))
{
changed.Add(writes[ii].NodeId.ToString());
}
}
Require(changed.Count == 0,
$"{changed.Count} structures changed in a write and read round trip: " +
string.Join("; ", changed.Take(10)));
}
/// <summary>
/// Reads every encoding node of every structure type the way the 1.5
/// node cache does (all attributes including the optional ones,
/// through ReadNodesAsync) and lists the nodes that fail, with the
/// reason. The node cache keeps such a node as a placeholder without
/// BrowseName, which the type loader then dereferences. A server that
/// answers an unset optional attribute (RolePermissions,
/// AccessRestrictions, ...) with Good and a null value instead of
/// Bad_AttributeIdInvalid fails every node this way.
/// </summary>
private static async Task<string> DiagnoseEncodingNodesAsync(ClientContext c)
{
try
{
var structureTypes = new List<NodeId>();
var queue = new Queue<NodeId>();
queue.Enqueue(DataTypeIds.Structure);
while (queue.Count > 0)
{
NodeId type = queue.Dequeue();
foreach (ReferenceDescription subtype in await BrowseAsync(
c, type, 0, ReferenceTypeIds.HasSubtype).ConfigureAwait(false))
{
var id = ExpandedNodeId.ToNodeId(subtype.NodeId, c.Session.NamespaceUris);
structureTypes.Add(id);
queue.Enqueue(id);
}
}
var encodings = new List<NodeId>();
foreach (NodeId type in structureTypes)
{
encodings.AddRange((await BrowseAsync(c, type, 0, ReferenceTypeIds.HasEncoding)
.ConfigureAwait(false))
.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, c.Session.NamespaceUris)));
}
(IList<Node> nodes, IList<ServiceResult> errors) = await c.Session
.ReadNodesAsync(encodings, NodeClass.Unspecified, true, c.Ct)
.ConfigureAwait(false);
var failed = new List<string>();
for (int ii = 0; ii < encodings.Count; ii++)
{
if (ServiceResult.IsBad(errors[ii]) || nodes[ii]?.BrowseName == null)
{
failed.Add($"{encodings[ii]}: {errors[ii]}");
}
}
return $"Of {encodings.Count} encoding nodes of {structureTypes.Count} structure types, " +
$"{failed.Count} could not be read as nodes: {string.Join("; ", failed.Take(10))}";
}
catch (Exception e)
{
return "The encoding node diagnosis failed: " + e.Message;
}
}
private static async Task LargeArrayRoundTripAsync(ClientContext c)
{
// 200 000 Int32 = 800 kB, far more than one 64 kB message chunk.
int[] values = [.. Enumerable.Range(0, 200_000).Select(i => (i * 7919) ^ 0x5A5A)];
await WriteAndCompareAsync(c, c.Id("Scalar_Static_Arrays_Int32"), values).ConfigureAwait(false);
}
private static async Task LargeByteStringRoundTripAsync(ClientContext c)
{
// 3 MB, below the 4 MB ByteString limit of the reference server fixture.
byte[] value = new byte[3 * 1024 * 1024];
new Random(4711).NextBytes(value);
await WriteAndCompareAsync(c, c.Id("Scalar_Static_ByteString"), value).ConfigureAwait(false);
}
/// <summary>
/// A ByteString above the server's MaxByteStringLength but inside its
/// MaxMessageSize must be rejected with a status code, and the
/// session must remain usable afterwards.
/// </summary>
private static async Task OversizedRequestRejectedAsync(ClientContext c)
{
byte[] value = new byte[5 * 1024 * 1024];
StatusCode result;
try
{
WriteResponse response = await c.Session
.WriteAsync(null, new WriteValueCollection { Write(c.Id("Scalar_Static_ByteString"), value) }, c.Ct)
.ConfigureAwait(false);
result = response.Results[0];
}
catch (ServiceResultException sre)
{
result = sre.StatusCode;
}
Require(StatusCode.IsBad(result), "a 5 MB ByteString write was accepted");
DataValue status = await c.Session.ReadValueAsync(VariableIds.Server_ServerStatus_State, c.Ct)
.ConfigureAwait(false);
Require(StatusCode.IsGood(status.StatusCode),
$"the session is unusable after the rejected write ({result}): {status.StatusCode}");
Console.WriteLine("INFO OversizedRequestRejected: " + StatusCodes.GetBrowseName(result.Code));
}
/// <summary>
/// Browses a folder one reference at a time with BrowseNext and
/// compares with a browse without limit; then releases an open
/// continuation point.
/// </summary>
private static async Task BrowseContinuationPointsAsync(ClientContext c)
{
NodeId folder = c.Id("Scalar_Static");
ReferenceDescriptionCollection all = await BrowseAsync(c, folder, 0).ConfigureAwait(false);
Require(all.Count > 10, $"only {all.Count} references below {folder}");
ReferenceDescriptionCollection paged = await BrowseAsync(c, folder, 3).ConfigureAwait(false);
Require(paged.Count == all.Count, $"paged browse returned {paged.Count} of {all.Count} references");
Require(
paged.Select(r => r.NodeId.ToString()).SequenceEqual(all.Select(r => r.NodeId.ToString())),
"paged browse returned different references");
BrowseResponse first = await c.Session
.BrowseAsync(null, null, 3, new BrowseDescriptionCollection { BrowseAll(folder) }, c.Ct)
.ConfigureAwait(false);
Require(first.Results[0].ContinuationPoint != null, "no continuation point returned");
BrowseNextResponse released = await c.Session
.BrowseNextAsync(null, true, new ByteStringCollection { first.Results[0].ContinuationPoint }, c.Ct)
.ConfigureAwait(false);
Require(StatusCode.IsGood(released.Results[0].StatusCode),
"releasing the continuation point returned " + released.Results[0].StatusCode);
BrowseNextResponse reused = await c.Session
.BrowseNextAsync(null, false, new ByteStringCollection { first.Results[0].ContinuationPoint }, c.Ct)
.ConfigureAwait(false);
Require(reused.Results[0].StatusCode == StatusCodes.BadContinuationPointInvalid,
"a released continuation point returned " + reused.Results[0].StatusCode);
}
/// <summary>
/// Reads more nodes than the server's MaxNodesPerRead. The session
/// reads the limit from the server's OperationLimits and splits the
/// request by it; with client splitting switched off, the server
/// rejects the oversized request with BadTooManyOperations.
/// </summary>
private static async Task ReadManyNodesAsync(ClientContext c)
{
await ((Session)c.Session).FetchOperationLimitsAsync(c.Ct).ConfigureAwait(false);
uint limit = c.Session.OperationLimits.MaxNodesPerRead;
Require(limit > 0, "the server published no MaxNodesPerRead");
var nodes = new ReadValueIdCollection();
for (int ii = 0; ii < (limit * 2) + 1; ii++)
{
nodes.Add(Value(VariableIds.Server_ServerStatus_State));
}
ReadResponse split = await c.Session
.ReadAsync(null, 0, TimestampsToReturn.Neither, nodes, c.Ct)
.ConfigureAwait(false);
int good = split.Results.Count(r => StatusCode.IsGood(r.StatusCode));
Require(good == nodes.Count,
$"{good} of {nodes.Count} reads split by MaxNodesPerRead {limit} succeeded");
StatusCode tooMany;
c.Session.OperationLimits.MaxNodesPerRead = 0;
try
{
ReadResponse response = await c.Session
.ReadAsync(null, 0, TimestampsToReturn.Neither, nodes, c.Ct)
.ConfigureAwait(false);
tooMany = response.ResponseHeader.ServiceResult;
}
catch (ServiceResultException sre)
{
tooMany = sre.StatusCode;
}
finally
{
c.Session.OperationLimits.MaxNodesPerRead = limit;
}
Require(tooMany == StatusCodes.BadTooManyOperations,
$"reading {nodes.Count} nodes in one request with MaxNodesPerRead {limit} returned {tooMany}");
}
/// <summary>
/// Keeps reading for longer than the revised security token lifetime
/// (the client renews at 75 %), so at least one token renewal happens
/// between the two stacks while requests are in flight.
/// </summary>
private static async Task TokenRenewalAsync(ClientContext c)
{
int seconds = c.Options.GetInt("token-test-seconds", 65);
var elapsed = Stopwatch.StartNew();
int reads = 0;
while (elapsed.Elapsed < TimeSpan.FromSeconds(seconds))
{
DataValue value = await c.Session
.ReadValueAsync(VariableIds.Server_ServerStatus_CurrentTime, c.Ct)
.ConfigureAwait(false);
Require(StatusCode.IsGood(value.StatusCode),
$"read {reads} after {elapsed.Elapsed.TotalSeconds:F0} s returned {value.StatusCode}");
reads++;
await Task.Delay(500, c.Ct).ConfigureAwait(false);
}
Require(c.Session.Connected, "the session disconnected");
}
private static async Task WriteAndCompareAsync(ClientContext c, NodeId nodeId, object value)
{
WriteResponse response = await c.Session
.WriteAsync(null, new WriteValueCollection { Write(nodeId, value) }, c.Ct)
.ConfigureAwait(false);
Require(StatusCode.IsGood(response.Results[0]), $"write of {nodeId} returned {response.Results[0]}");
DataValue read = await c.Session.ReadValueAsync(nodeId, c.Ct).ConfigureAwait(false);
Require(StatusCode.IsGood(read.StatusCode), $"read of {nodeId} returned {read.StatusCode}");
Require(Utils.IsEqual(value, read.Value), $"{nodeId} read back a different value");
}
private static IEnumerable<ExtensionObject> ExtensionObjects(object value)
{
switch (value)
{
case ExtensionObject extension:
yield return extension;
break;
case ExtensionObject[] extensions:
foreach (ExtensionObject item in extensions.Where(e => e != null))
{
yield return item;
}
break;
}
}
private static async Task<DataValueCollection> ReadAttributeAsync(
ClientContext c,
List<NodeId> nodes,
uint attributeId)
{
var results = new DataValueCollection();
for (int offset = 0; offset < nodes.Count; offset += 500)
{
var batch = new ReadValueIdCollection(nodes
.Skip(offset)
.Take(500)
.Select(n => new ReadValueId { NodeId = n, AttributeId = attributeId }));
ReadResponse response = await c.Session
.ReadAsync(null, 0, TimestampsToReturn.Neither, batch, c.Ct)
.ConfigureAwait(false);
results.AddRange(response.Results);
}
return results;
}
private static BrowseDescription BrowseAll(NodeId nodeId, NodeId referenceTypeId = null)
{
return new BrowseDescription
{
NodeId = nodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = referenceTypeId ?? ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All
};
}
/// <summary>
/// Browses one node, following continuation points.
/// </summary>
private static async Task<ReferenceDescriptionCollection> BrowseAsync(
ClientContext c,
NodeId nodeId,
uint maxReferences,
NodeId referenceTypeId = null)
{
BrowseResponse response = await c.Session
.BrowseAsync(
null,
null,
maxReferences,
new BrowseDescriptionCollection { BrowseAll(nodeId, referenceTypeId) },
c.Ct)
.ConfigureAwait(false);
BrowseResult result = response.Results[0];
Require(StatusCode.IsGood(result.StatusCode), $"browse of {nodeId} returned {result.StatusCode}");
var references = new ReferenceDescriptionCollection(result.References);
byte[] continuationPoint = result.ContinuationPoint;
while (continuationPoint != null && continuationPoint.Length > 0)
{
BrowseNextResponse next = await c.Session
.BrowseNextAsync(null, false, new ByteStringCollection { continuationPoint }, c.Ct)
.ConfigureAwait(false);
Require(StatusCode.IsGood(next.Results[0].StatusCode),
$"browse next of {nodeId} returned {next.Results[0].StatusCode}");
references.AddRange(next.Results[0].References);
continuationPoint = next.Results[0].ContinuationPoint;
}
return references;
}
/// <summary>
/// Breadth-first browse of the hierarchy below a node.
/// </summary>
private static async Task<List<ReferenceDescription>> BrowseTreeAsync(
ClientContext c,
NodeId root,
int maxNodes)
{
var found = new List<ReferenceDescription>();
var visited = new HashSet<NodeId> { root };
var queue = new Queue<NodeId>();
queue.Enqueue(root);
while (queue.Count > 0 && found.Count < maxNodes)
{
NodeId nodeId = queue.Dequeue();
if (nodeId == ObjectIds.Server)
{
continue;
}
foreach (ReferenceDescription reference in await BrowseAsync(c, nodeId, 0).ConfigureAwait(false))
{
if (reference.NodeId.IsAbsolute)
{
continue;
}
var target = ExpandedNodeId.ToNodeId(reference.NodeId, c.Session.NamespaceUris);
if (visited.Add(target))
{
found.Add(reference);
queue.Enqueue(target);
}
}
}
return found;
}
private static async Task<EndpointDescription> SelectEndpointAsync(
ApplicationConfiguration config,
string url,
string policy,
MessageSecurityMode mode,
CancellationToken ct)
{
using DiscoveryClient client = await DiscoveryClient
.CreateAsync(config, new Uri(url), DiagnosticsMasks.None, ct)
.ConfigureAwait(false);
EndpointDescriptionCollection endpoints = await client
.GetEndpointsAsync(null, ct)
.ConfigureAwait(false);
EndpointDescription endpoint = endpoints.FirstOrDefault(e =>
e.EndpointUrl.StartsWith(Utils.UriSchemeOpcTcp, StringComparison.Ordinal) &&
e.SecurityPolicyUri == policy &&
e.SecurityMode == mode);
if (endpoint == null)
{
throw new InvalidOperationException(
$"The server offers no {policy}/{mode} endpoint. Offered: " +
string.Join(", ", endpoints.Select(e => $"{e.SecurityPolicyUri}/{e.SecurityMode}")));
}
return endpoint;
}
private static ReadValueId Value(NodeId nodeId)
{
return new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value };
}
private static WriteValue Write(NodeId nodeId, object value)
{
return new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
}
private static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
/// <summary>
/// What the checks share.
/// </summary>
private sealed class ClientContext
{
public ClientContext(ISession session, ITelemetryContext telemetry, PeerOptions options, CancellationToken ct)
{
Session = session;
Telemetry = telemetry;
Options = options;
Ct = ct;
Ns = (ushort)session.NamespaceUris.GetIndex(ReferenceServerNamespace);
}
public ISession Session { get; }
public ITelemetryContext Telemetry { get; }
public PeerOptions Options { get; }
public CancellationToken Ct { get; }
public ushort Ns { get; }
public NodeId Id(string name)
{
return new NodeId(name, Ns);
}
}
/// <summary>
/// Runs named checks and prints one result line per check.
/// </summary>
private sealed class CheckRunner
{
private int m_failed;
private int m_passed;
public async Task RunAsync(string name, Func<Task> check)
{
var elapsed = Stopwatch.StartNew();
string outcome = "Passed";
string message = string.Empty;
try
{
await check().ConfigureAwait(false);
m_passed++;
}
catch (Exception e)
{
m_failed++;
outcome = "Failed";
message = e is ServiceResultException sre
? StatusCodes.GetBrowseName(sre.StatusCode) + " " + sre.Message
: e.GetType().Name + " " + e.Message;
if (e is not InvalidOperationException)
{
message += Environment.NewLine + e.StackTrace;
}
}
string json = JsonSerializer.Serialize(new
{
check = name,
outcome,
message,
milliseconds = elapsed.ElapsedMilliseconds
});
Console.WriteLine("RESULT " + json);
Console.Out.Flush();
}
public int Finish()
{
Console.WriteLine($"SUMMARY passed={m_passed} failed={m_failed}");
return m_failed == 0 ? Program.ExitSuccess : Program.ExitChecksFailed;
}
}
}
}
@@ -0,0 +1,456 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Interop.LegacyPeer
{
/// <summary>
/// Hosts the 1.5.x server side of the interop tests.
/// </summary>
public static class LegacyServerHost
{
public const string InteropApplicationName = "LegacyInteropServer";
public const string ReferenceApplicationName = "ReferenceServer";
public static async Task<int> RunAsync(PeerOptions options)
{
int port = int.Parse(options.Get("port"), CultureInfo.InvariantCulture);
bool reference = options.Get("kind", "interop") switch
{
"interop" => false,
"reference" => true,
string kind => throw new ArgumentException("unknown --kind " + kind)
};
string applicationName = reference ? ReferenceApplicationName : InteropApplicationName;
string endpointUrl = $"opc.tcp://localhost:{port}/{applicationName}";
ITelemetryContext telemetry = DefaultTelemetry.Create(
builder => builder.SetMinimumLevel(LogLevel.Warning));
var application = new ApplicationInstance(telemetry)
{
ApplicationName = applicationName,
ApplicationType = ApplicationType.Server
};
// The quotas and limits are part of what the interop tests check:
// the interop server is deliberately tight so the tests can cross
// its limits; the reference server mirrors the limits of the 2.0
// reference server test fixture, which the reused 2.0 client tests
// are written against.
IApplicationConfigurationBuilderServerSelected server = application
.Build(
"urn:localhost:opcfoundation.org:" + applicationName,
"uri:opcfoundation.org:" + applicationName)
.SetMaxMessageSize(reference ? 16 * 1024 * 1024 : InteropLimits.MaxMessageSize)
.SetMaxByteStringLength(reference ? 4 * 1024 * 1024 : InteropLimits.MaxByteStringLength)
.SetMaxStringLength(reference ? 4 * 1024 * 1024 : InteropLimits.MaxByteStringLength)
.SetMaxArrayLength(reference ? 1024 * 1024 : InteropLimits.MaxArrayLength)
.AsServer(new[] { endpointUrl });
IApplicationConfigurationBuilderServerPolicies policies = server
.AddUnsecurePolicyNone()
.AddSignPolicies()
.AddSignAndEncryptPolicies();
if (options.Ecc)
{
policies = policies
.AddEccSignPolicies()
.AddEccSignAndEncryptPolicies();
}
await policies
.AddUserTokenPolicy(UserTokenType.Anonymous)
.AddUserTokenPolicy(UserTokenType.UserName)
.AddUserTokenPolicy(UserTokenType.Certificate)
.SetOperationLimits(reference
? new OperationLimits
{
MaxNodesPerBrowse = 2500,
MaxNodesPerRead = 1000,
MaxNodesPerWrite = 1000,
MaxNodesPerMethodCall = 1000,
MaxMonitoredItemsPerCall = 1000,
MaxNodesPerHistoryReadData = 1000,
MaxNodesPerHistoryReadEvents = 1000,
MaxNodesPerHistoryUpdateData = 1000,
MaxNodesPerHistoryUpdateEvents = 1000,
MaxNodesPerNodeManagement = 1000,
MaxNodesPerRegisterNodes = 1000,
MaxNodesPerTranslateBrowsePathsToNodeIds = 1000
}
: new OperationLimits
{
MaxNodesPerBrowse = InteropLimits.MaxNodesPerOperation,
MaxNodesPerRead = InteropLimits.MaxNodesPerOperation,
MaxNodesPerWrite = InteropLimits.MaxNodesPerOperation,
MaxNodesPerMethodCall = InteropLimits.MaxNodesPerOperation,
MaxMonitoredItemsPerCall = InteropLimits.MaxNodesPerOperation,
MaxNodesPerTranslateBrowsePathsToNodeIds = InteropLimits.MaxNodesPerOperation
})
.SetDiagnosticsEnabled(true)
.SetShutdownDelay(0)
.AddSecurityConfiguration(
LegacyPki.ApplicationCertificates(
"CN=" + applicationName + ", O=OPC Foundation, DC=localhost",
options.PkiRoot,
options.Ecc),
options.PkiRoot)
.SetAutoAcceptUntrustedCertificates(options.AutoAccept)
.CreateAsync()
.ConfigureAwait(false);
bool haveCertificate = await application
.CheckApplicationInstanceCertificatesAsync(true)
.ConfigureAwait(false);
if (!haveCertificate)
{
throw new InvalidOperationException("The application certificate is invalid.");
}
if (options.GetBool("init-only", false))
{
Console.WriteLine("LEGACY-PKI-READY " + application.ApplicationConfiguration.ApplicationUri);
return Program.ExitSuccess;
}
StandardServer instance;
if (reference)
{
var referenceServer = new ReferenceServer();
Quickstarts.Servers.Utils.AddDefaultNodeManagers(referenceServer);
instance = referenceServer;
}
else
{
instance = new LegacyInteropServer();
}
await application.StartAsync(instance).ConfigureAwait(false);
// The test harness waits for this exact line before it connects.
Console.WriteLine("LEGACY-SERVER-READY " + endpointUrl);
Console.Out.Flush();
// Run until the harness closes standard input or asks to stop.
string line;
while ((line = await Console.In.ReadLineAsync().ConfigureAwait(false)) != null)
{
if (string.Equals(line.Trim(), "stop", StringComparison.OrdinalIgnoreCase))
{
break;
}
}
await application.StopAsync().ConfigureAwait(false);
Console.WriteLine("LEGACY-SERVER-STOPPED");
return Program.ExitSuccess;
}
}
/// <summary>
/// The transport quotas and operation limits of the interop server.
/// Mirrored by the 2.0 tests, which cross them on purpose.
/// </summary>
public static class InteropLimits
{
public const int MaxMessageSize = 4 * 1024 * 1024;
public const int MaxByteStringLength = 1024 * 1024;
public const int MaxArrayLength = 100_000;
public const int MaxNodesPerOperation = 100;
}
/// <summary>
/// A 1.5.378 server exposing the interop address space and accepting
/// the interop user name credentials.
/// </summary>
public sealed class LegacyInteropServer : StandardServer
{
public const string UserName = "interop";
public const string Password = "interop-password";
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server,
ApplicationConfiguration configuration)
{
var nodeManagers = new INodeManager[] { new InteropNodeManager(server, configuration) };
return new MasterNodeManager(server, configuration, null, nodeManagers);
}
protected override ServerProperties LoadServerProperties()
{
// SoftwareVersion and BuildNumber come from the 1.5.378 stack
// assemblies, so a client can tell which stack it talks to.
return new ServerProperties
{
ManufacturerName = "OPC Foundation",
ProductName = "1.5.378 Interop Server",
ProductUri = "http://opcfoundation.org/UA/Interop/LegacyServer",
SoftwareVersion = Utils.GetAssemblySoftwareVersion(),
BuildNumber = Utils.GetAssemblyBuildNumber(),
BuildDate = DateTime.UtcNow
};
}
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
server.SessionManager.ImpersonateUser += OnImpersonateUser;
}
private static void OnImpersonateUser(ISession session, ImpersonateEventArgs args)
{
if (args.NewIdentity is UserNameIdentityToken userName)
{
if (userName.UserName == UserName &&
PasswordMatches(userName))
{
args.Identity = new UserIdentity(userName);
return;
}
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"Invalid user name or password.");
}
}
private static bool PasswordMatches(UserNameIdentityToken token)
{
object decrypted = token.DecryptedPassword;
string password = decrypted is byte[] bytes
? System.Text.Encoding.UTF8.GetString(bytes)
: decrypted as string;
return password == Password;
}
}
/// <summary>
/// The address space of the legacy server: a folder "Interop" below the
/// Objects folder holding scalar, array and structure variables, a
/// counter that changes every 100 ms, and an Add method.
/// </summary>
public sealed class InteropNodeManager : CustomNodeManager2
{
public const string NamespaceUri = "urn:opcfoundation.org:interop:legacy";
// Owned by the predefined node table of the base class, which disposes it.
#pragma warning disable CA2213
private BaseDataVariableState m_counter;
#pragma warning restore CA2213
private Timer m_timer;
public InteropNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, NamespaceUri)
{
}
public override void CreateAddressSpace(
IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
{
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out IList<IReference> references))
{
externalReferences[ObjectIds.ObjectsFolder] = references = new List<IReference>();
}
var folder = new FolderState(null)
{
SymbolicName = "Interop",
ReferenceTypeId = ReferenceTypes.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId("Interop", NamespaceIndex),
BrowseName = new QualifiedName("Interop", NamespaceIndex),
DisplayName = new LocalizedText("en", "Interop"),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
EventNotifier = EventNotifiers.None
};
folder.AddReference(ReferenceTypes.Organizes, true, ObjectIds.ObjectsFolder);
references.Add(new NodeStateReference(ReferenceTypes.Organizes, false, folder.NodeId));
CreateVariable(folder, "Boolean", DataTypeIds.Boolean, ValueRanks.Scalar, true);
CreateVariable(folder, "Int32", DataTypeIds.Int32, ValueRanks.Scalar, 42);
CreateVariable(folder, "UInt64", DataTypeIds.UInt64, ValueRanks.Scalar, ulong.MaxValue);
CreateVariable(folder, "Double", DataTypeIds.Double, ValueRanks.Scalar, 3.25);
CreateVariable(folder, "String", DataTypeIds.String, ValueRanks.Scalar, "legacy");
CreateVariable(folder, "DateTime", DataTypeIds.DateTime, ValueRanks.Scalar,
new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc));
CreateVariable(folder, "Guid", DataTypeIds.Guid, ValueRanks.Scalar,
new Uuid(new Guid("8d2b5c6e-6c0e-4a4c-9f2b-0b6a3a6b1e01")));
CreateVariable(folder, "ByteString", DataTypeIds.ByteString, ValueRanks.Scalar,
new byte[] { 1, 2, 3, 4, 5 });
CreateVariable(folder, "LocalizedText", DataTypeIds.LocalizedText, ValueRanks.Scalar,
new LocalizedText("de", "Hallo"));
CreateVariable(folder, "QualifiedName", DataTypeIds.QualifiedName, ValueRanks.Scalar,
new QualifiedName("Name", NamespaceIndex));
CreateVariable(folder, "NodeId", DataTypeIds.NodeId, ValueRanks.Scalar,
new NodeId("Interop", NamespaceIndex));
CreateVariable(folder, "Int32Array", DataTypeIds.Int32, ValueRanks.OneDimension,
new[] { 1, 2, 3 });
CreateVariable(folder, "StringArray", DataTypeIds.String, ValueRanks.OneDimension,
new[] { "a", "b", "c" });
CreateVariable(folder, "Range", DataTypeIds.Range, ValueRanks.Scalar,
new Range(100, 0));
m_counter = CreateVariable(folder, "Counter", DataTypeIds.Int32, ValueRanks.Scalar, 0);
m_counter.AccessLevel = AccessLevels.CurrentRead;
m_counter.UserAccessLevel = AccessLevels.CurrentRead;
CreateAddMethod(folder);
AddPredefinedNode(SystemContext, folder);
AddRootNotifier(folder);
m_timer = new Timer(OnTick, null, 100, 100);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
m_timer?.Dispose();
m_timer = null;
}
base.Dispose(disposing);
}
private void OnTick(object state)
{
lock (Lock)
{
if (m_counter == null)
{
return;
}
m_counter.Value = (int)m_counter.Value + 1;
m_counter.Timestamp = DateTime.UtcNow;
m_counter.ClearChangeMasks(SystemContext, false);
}
}
private BaseDataVariableState CreateVariable(
NodeState parent,
string name,
NodeId dataType,
int valueRank,
object value)
{
var variable = new BaseDataVariableState(parent)
{
SymbolicName = name,
ReferenceTypeId = ReferenceTypes.Organizes,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(name, NamespaceIndex),
BrowseName = new QualifiedName(name, NamespaceIndex),
DisplayName = new LocalizedText("en", name),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
DataType = dataType,
ValueRank = valueRank,
AccessLevel = AccessLevels.CurrentReadOrWrite,
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
Historizing = false,
Value = value,
StatusCode = StatusCodes.Good,
Timestamp = DateTime.UtcNow
};
if (valueRank == ValueRanks.OneDimension)
{
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { 0 });
}
parent.AddChild(variable);
return variable;
}
private void CreateAddMethod(NodeState parent)
{
var method = new MethodState(parent)
{
SymbolicName = "Add",
ReferenceTypeId = ReferenceTypeIds.HasComponent,
NodeId = new NodeId("Add", NamespaceIndex),
BrowseName = new QualifiedName("Add", NamespaceIndex),
DisplayName = new LocalizedText("en", "Add"),
WriteMask = AttributeWriteMask.None,
UserWriteMask = AttributeWriteMask.None,
Executable = true,
UserExecutable = true
};
method.InputArguments = CreateArguments(method, "InputArguments", BrowseNames.InputArguments,
new Argument { Name = "a", DataType = DataTypeIds.Int32, ValueRank = ValueRanks.Scalar },
new Argument { Name = "b", DataType = DataTypeIds.Int32, ValueRank = ValueRanks.Scalar });
method.OutputArguments = CreateArguments(method, "OutputArguments", BrowseNames.OutputArguments,
new Argument { Name = "sum", DataType = DataTypeIds.Int32, ValueRank = ValueRanks.Scalar });
method.OnCallMethod = (context, m, inputs, outputs) =>
{
outputs[0] = (int)inputs[0] + (int)inputs[1];
return ServiceResult.Good;
};
parent.AddChild(method);
}
private PropertyState<Argument[]> CreateArguments(
MethodState method,
string suffix,
string browseName,
params Argument[] arguments)
{
foreach (Argument argument in arguments)
{
argument.Description = new LocalizedText(argument.Name);
}
var property = new PropertyState<Argument[]>(method)
{
NodeId = new NodeId(method.SymbolicName + "_" + suffix, NamespaceIndex),
BrowseName = browseName,
DisplayName = new LocalizedText(browseName),
TypeDefinitionId = VariableTypeIds.PropertyType,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
DataType = DataTypeIds.Argument,
ValueRank = ValueRanks.OneDimension,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = arguments
};
property.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { (uint)arguments.Length });
return property;
}
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
OPC UA peer built from the released 1.5.378 NuGet packages. The interop tests in
tests/Opc.Ua.Interop.Tests start it as a separate process, because the 1.5 and 2.0
stacks share assembly names and namespaces and cannot be loaded into one process.
Override the package version with -p:LegacyStackVersion=<version>.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<!-- CI only installs the latest SDK/runtime; run on whatever major is present. -->
<RollForward>LatestMajor</RollForward>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>Opc.Ua.Interop.LegacyPeer</AssemblyName>
<RootNamespace>Opc.Ua.Interop.LegacyPeer</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>false</IsTestProject>
<LegacyStackVersion Condition="'$(LegacyStackVersion)' == ''">1.5.378.176</LegacyStackVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="$(LegacyStackVersion)" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="$(LegacyStackVersion)" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="$(LegacyStackVersion)" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client.ComplexTypes" Version="$(LegacyStackVersion)" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Quickstarts.Servers" Version="$(LegacyStackVersion)" />
</ItemGroup>
</Project>
+235
View File
@@ -0,0 +1,235 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading.Tasks;
namespace Opc.Ua.Interop.LegacyPeer
{
/// <summary>
/// Entry point of the 1.5.x interop peer.
/// </summary>
/// <remarks>
/// <code>
/// server --port &lt;port&gt; --pki &lt;dir&gt; [--kind interop|reference]
/// [--autoaccept true|false] [--ecc true|false] [--init-only true]
/// Starts a 1.5.x server, prints "LEGACY-SERVER-READY &lt;url&gt;" and
/// runs until standard input is closed or a line "stop" is read.
/// 'interop' (default) is a small server with a known address space;
/// 'reference' is the 1.5.x Quickstarts reference server with all of
/// its default node managers.
/// client --url &lt;url&gt; --pki &lt;dir&gt; [--policy &lt;uri&gt;] [--mode &lt;mode&gt;]
/// [--user &lt;name&gt; --password &lt;password&gt;] [--checks a,b,c]
/// [--autoaccept true|false] [--ecc true|false]
/// [--expect-connect-error &lt;StatusCode name,...&gt;]
/// [--token-lifetime &lt;ms&gt;] [--token-test-seconds &lt;s&gt;]
/// [--timeout-seconds &lt;s&gt;]
/// Runs the client checks against a server that exposes the
/// Quickstarts reference server address space. Prints one
/// "RESULT {json}" line per check and exits with 0 when every
/// check passed.
/// With --init-only true, server and client create their certificates
/// and stores, print "LEGACY-PKI-READY &lt;application uri&gt;" and exit, so a test can
/// set up trust lists before the peer connects.
/// </code>
/// </remarks>
public static class Program
{
public const int ExitSuccess = 0;
public const int ExitChecksFailed = 1;
public const int ExitUsage = 2;
public const int ExitFatal = 3;
public static async Task<int> Main(string[] args)
{
if (args.Length == 0)
{
return Usage("missing command");
}
PeerOptions options;
try
{
options = PeerOptions.Parse(args);
}
catch (ArgumentException e)
{
return Usage(e.Message);
}
try
{
switch (args[0])
{
case "server":
return await LegacyServerHost.RunAsync(options).ConfigureAwait(false);
case "client":
return await LegacyClientChecks.RunAsync(options).ConfigureAwait(false);
default:
return Usage("unknown command " + args[0]);
}
}
catch (Exception e)
{
Console.Error.WriteLine("FATAL " + e);
return ExitFatal;
}
}
private static int Usage(string error)
{
Console.Error.WriteLine("error: " + error);
Console.Error.WriteLine(
"usage: server --port <port> --pki <dir> [--kind interop|reference] " +
"[--autoaccept true|false] [--ecc true|false]");
Console.Error.WriteLine(
" client --url <url> --pki <dir> [--policy <uri>] [--mode <mode>] " +
"[--user <name> --password <password>] [--checks a,b,c] [--autoaccept true|false] " +
"[--ecc true|false] [--expect-connect-error <code>] [--token-lifetime <ms>] " +
"[--token-test-seconds <s>]");
return ExitUsage;
}
}
/// <summary>
/// The --name value options of the peer.
/// </summary>
public sealed class PeerOptions
{
private readonly Dictionary<string, string> m_options;
private PeerOptions(Dictionary<string, string> options)
{
m_options = options;
}
public static PeerOptions Parse(string[] args)
{
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int ii = 1; ii < args.Length; ii++)
{
string name = args[ii];
if (!name.StartsWith("--", StringComparison.Ordinal) || ii + 1 >= args.Length)
{
throw new ArgumentException("expected --name value, got " + name);
}
options[name.Substring(2)] = args[++ii];
}
return new PeerOptions(options);
}
/// <summary>
/// The PKI root of the peer.
/// </summary>
public string PkiRoot => Get("pki");
/// <summary>
/// Whether untrusted peer certificates are accepted (default true).
/// </summary>
public bool AutoAccept => GetBool("autoaccept", true);
/// <summary>
/// Whether the peer uses ECC application certificates and policies in
/// addition to RSA (default false).
/// </summary>
public bool Ecc => GetBool("ecc", false);
public string Get(string name, string defaultValue = null)
{
if (m_options.TryGetValue(name, out string value))
{
return value;
}
return defaultValue ?? throw new ArgumentException("missing --" + name);
}
public bool GetBool(string name, bool defaultValue)
{
return m_options.TryGetValue(name, out string value)
? bool.Parse(value)
: defaultValue;
}
public int GetInt(string name, int defaultValue)
{
return m_options.TryGetValue(name, out string value)
? int.Parse(value, CultureInfo.InvariantCulture)
: defaultValue;
}
}
/// <summary>
/// Certificate configuration of the legacy peer. The stores live below
/// the PKI root in the default 1.5 layout: own, issuer, trusted and
/// rejected, each with certs, private and crl folders. The tests rely
/// on this layout to provision certificates and trust lists before the
/// peer starts.
/// </summary>
public static class LegacyPki
{
/// <summary>
/// The application certificates: RSA only, or RSA and the ECC curves
/// 1.5.x supports.
/// </summary>
public static CertificateIdentifierCollection ApplicationCertificates(
string subjectName,
string pkiRoot,
bool ecc)
{
if (ecc)
{
// The default set puts the certificates into the PKI root
// itself; keep them in 'own' like the RSA-only set.
CertificateIdentifierCollection certificates =
Configuration.ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates(
subjectName,
CertificateStoreType.Directory,
pkiRoot);
foreach (CertificateIdentifier certificate in certificates)
{
certificate.StorePath = Path.Combine(pkiRoot, "own");
}
return certificates;
}
return new CertificateIdentifierCollection
{
new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
SubjectName = subjectName,
CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
}
};
}
}
}
@@ -0,0 +1,405 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// Certificate trust between the stacks, with auto-accept switched off:
/// untrusted peers are rejected and accepted once trusted, CA-issued
/// certificates are accepted when only the CA is trusted, and a
/// certificate on the CA's revocation list is rejected. Each test has
/// its own PKI and server.
/// </summary>
[TestFixture]
[Category("Interop")]
[NonParallelizable]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class CertificateTrustInteropTests
{
private static readonly TimeSpan s_peerTimeout = TimeSpan.FromMinutes(2);
private static readonly TimeSpan s_storeTimeout = TimeSpan.FromSeconds(15);
/// <summary>
/// What a client may see when the server rejects its certificate. A
/// server should hide the reason behind BadSecurityChecksFailed.
/// </summary>
private static readonly StatusCode[] s_clientRejectedCodes =
[
StatusCodes.BadSecurityChecksFailed,
StatusCodes.BadCertificateUntrusted,
StatusCodes.BadCertificateRevoked
];
private static readonly string s_clientRejectedNames = string.Join(
",",
s_clientRejectedCodes.Select(c => c.SymbolicId));
private ITelemetryContext m_telemetry;
private string m_pkiRoot;
[SetUp]
public void SetUp()
{
m_telemetry = NUnitTelemetryContext.Create();
m_pkiRoot = InteropPki.CreateRoot();
}
[TearDown]
public void TearDown()
{
InteropPki.Delete(m_pkiRoot);
}
private string ServerPki => InteropPki.ServerPki(m_pkiRoot);
private string ClientPki => InteropPki.ClientPki(m_pkiRoot);
/// <summary>
/// The 1.5 server rejects an untrusted 2.0 client, stores its
/// certificate in the rejected store, and accepts it once trusted.
/// </summary>
[Test]
public async Task LegacyServerRejectsUntrustedClientUntilTrustedAsync()
{
(LegacyPeerProcess server, string url) = await StartLegacyServerAsync(autoAccept: false)
.ConfigureAwait(false);
using (server)
{
await using ClientFixture client = await CreateClientAsync(autoAccept: true).ConfigureAwait(false);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => ConnectAsync(client, url));
Assert.That(sre.StatusCode, Is.AnyOf(s_clientRejectedCodes), sre.ToString());
Assert.That(
await InteropPki.WaitForCertificatesAsync(ServerPki, InteropPki.Rejected, s_storeTimeout)
.ConfigureAwait(false),
Is.Not.Empty,
"the 1.5 server did not store the rejected certificate");
InteropPki.Trust(ServerPki, ClientPki);
await ConnectReadAndCloseAsync(client, url).ConfigureAwait(false);
await server.StopAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
}
}
/// <summary>
/// The 2.0 client rejects an untrusted 1.5 server certificate and
/// accepts it once trusted.
/// </summary>
[Test]
public async Task ClientRejectsUntrustedLegacyServerUntilTrustedAsync()
{
(LegacyPeerProcess server, string url) = await StartLegacyServerAsync(autoAccept: true)
.ConfigureAwait(false);
using (server)
{
await using ClientFixture client = await CreateClientAsync(autoAccept: false).ConfigureAwait(false);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => ConnectAsync(client, url));
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadCertificateUntrusted), sre.ToString());
InteropPki.Trust(ClientPki, ServerPki);
await ConnectReadAndCloseAsync(client, url).ConfigureAwait(false);
await server.StopAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
}
}
/// <summary>
/// The 2.0 server rejects an untrusted 1.5 client, stores its
/// certificate in the rejected store, and accepts it once trusted.
/// </summary>
[Test]
public async Task ServerRejectsUntrustedLegacyClientUntilTrustedAsync()
{
ServerFixture<ReferenceServer> server = await StartServerAsync(autoAccept: false).ConfigureAwait(false);
try
{
string url = ServerUrl(server);
PeerRun rejected = await RunLegacyClientAsync(url, true, "--expect-connect-error", s_clientRejectedNames)
.ConfigureAwait(false);
rejected.AssertPassed("Connect");
Assert.That(
await InteropPki.WaitForCertificatesAsync(ServerPki, InteropPki.Rejected, s_storeTimeout)
.ConfigureAwait(false),
Is.Not.Empty,
"the 2.0 server did not store the rejected certificate");
InteropPki.Trust(ServerPki, ClientPki);
PeerRun accepted = await RunLegacyClientAsync(url, true).ConfigureAwait(false);
AssertConnected(accepted);
}
finally
{
await server.StopAsync().ConfigureAwait(false);
}
}
/// <summary>
/// The 1.5 client rejects an untrusted 2.0 server certificate and
/// accepts it once trusted.
/// </summary>
[Test]
public async Task LegacyClientRejectsUntrustedServerUntilTrustedAsync()
{
ServerFixture<ReferenceServer> server = await StartServerAsync(autoAccept: true).ConfigureAwait(false);
try
{
string url = ServerUrl(server);
PeerRun rejected = await RunLegacyClientAsync(
url,
false,
"--expect-connect-error",
"BadCertificateUntrusted").ConfigureAwait(false);
rejected.AssertPassed("Connect");
InteropPki.Trust(ClientPki, ServerPki);
PeerRun accepted = await RunLegacyClientAsync(url, false).ConfigureAwait(false);
AssertConnected(accepted);
}
finally
{
await server.StopAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Both applications use certificates of one CA, and each trusts only
/// the CA (with an empty CRL): the 2.0 client and the 1.5 server
/// accept each other. With the client certificate on the CRL the
/// server rejects it.
/// </summary>
[Test]
public async Task CaIssuedCertificatesWithLegacyServerAsync([Values] bool revokeClient)
{
using Certificate ca = InteropPki.CreateCa("CN=Interop Test CA, O=OPC Foundation");
// Let each side create its self-signed certificate, then replace
// it with one of the CA, with the same subject and names.
(LegacyPeerProcess init, int exitCode) = await LegacyPeerProcess.RunAsync(
s_peerTimeout,
"server", "--port", "4840", "--pki", ServerPki, "--init-only", "true").ConfigureAwait(false);
using (init)
{
Assert.That(exitCode, Is.Zero, init.Output);
}
await (await CreateClientAsync(autoAccept: true).ConfigureAwait(false)).DisposeAsync()
.ConfigureAwait(false);
using Certificate serverCertificate = InteropPki.ReissueApplicationCertificate(ServerPki, ca);
using Certificate clientCertificate = InteropPki.ReissueApplicationCertificate(ClientPki, ca);
InteropPki.TrustCa(ServerPki, ca, revokeClient
? InteropPki.CreateCrl(ca, clientCertificate)
: InteropPki.CreateCrl(ca));
InteropPki.TrustCa(ClientPki, ca, InteropPki.CreateCrl(ca));
(LegacyPeerProcess server, string url) = await StartLegacyServerAsync(autoAccept: false)
.ConfigureAwait(false);
using (server)
{
await using ClientFixture client = await CreateClientAsync(autoAccept: false).ConfigureAwait(false);
if (revokeClient)
{
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => ConnectAsync(client, url));
Assert.That(sre.StatusCode, Is.AnyOf(s_clientRejectedCodes), sre.ToString());
}
else
{
await ConnectReadAndCloseAsync(client, url).ConfigureAwait(false);
}
await server.StopAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
}
}
/// <summary>
/// Both applications use certificates of one CA, and each trusts only
/// the CA (with an empty CRL): the 1.5 client and the 2.0 server
/// accept each other. With the client certificate on the CRL the
/// server rejects it.
/// </summary>
[Test]
public async Task CaIssuedCertificatesWithLegacyClientAsync([Values] bool revokeClient)
{
using Certificate ca = InteropPki.CreateCa("CN=Interop Test CA, O=OPC Foundation");
// Let each side create its self-signed certificate, then replace
// it with one of the CA, with the same subject and names.
ServerFixture<ReferenceServer> initServer = await StartServerAsync(autoAccept: true).ConfigureAwait(false);
await initServer.StopAsync().ConfigureAwait(false);
(LegacyPeerProcess init, int exitCode) = await LegacyPeerProcess.RunAsync(
s_peerTimeout,
"client", "--url", "opc.tcp://localhost:4840", "--pki", ClientPki, "--init-only", "true")
.ConfigureAwait(false);
using (init)
{
Assert.That(exitCode, Is.Zero, init.Output);
}
using Certificate serverCertificate = InteropPki.ReissueApplicationCertificate(ServerPki, ca);
using Certificate clientCertificate = InteropPki.ReissueApplicationCertificate(ClientPki, ca);
InteropPki.TrustCa(ServerPki, ca, revokeClient
? InteropPki.CreateCrl(ca, clientCertificate)
: InteropPki.CreateCrl(ca));
InteropPki.TrustCa(ClientPki, ca, InteropPki.CreateCrl(ca));
ServerFixture<ReferenceServer> server = await StartServerAsync(autoAccept: false).ConfigureAwait(false);
try
{
string url = ServerUrl(server);
if (revokeClient)
{
PeerRun rejected = await RunLegacyClientAsync(
url,
false,
"--expect-connect-error",
s_clientRejectedNames).ConfigureAwait(false);
rejected.AssertPassed("Connect");
}
else
{
PeerRun accepted = await RunLegacyClientAsync(url, false).ConfigureAwait(false);
AssertConnected(accepted);
}
}
finally
{
await server.StopAsync().ConfigureAwait(false);
}
}
private static void AssertConnected(PeerRun run)
{
run.AssertPassed("Connect");
run.AssertPassed("NamespaceArray");
run.AssertPassed("CloseSession");
}
private Task<(LegacyPeerProcess Server, string Url)> StartLegacyServerAsync(bool autoAccept)
{
return LegacyPeerProcess.StartServerAsync(
ServerPki,
s_peerTimeout,
"--autoaccept", autoAccept ? "true" : "false");
}
private async Task<ServerFixture<ReferenceServer>> StartServerAsync(bool autoAccept)
{
var server = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
{
AutoAccept = autoAccept,
SecurityNone = true
};
await server.LoadConfigurationAsync(ServerPki).ConfigureAwait(false);
await server.StartAsync().ConfigureAwait(false);
return server;
}
private static string ServerUrl(ServerFixture<ReferenceServer> server)
{
return string.Format(
CultureInfo.InvariantCulture,
"opc.tcp://localhost:{0}/{1}",
server.Port,
nameof(ReferenceServer));
}
private Task<PeerRun> RunLegacyClientAsync(string url, bool autoAccept, params string[] extra)
{
return PeerRun.ExecuteAsync(
s_peerTimeout,
[
"client",
"--url", url,
"--pki", ClientPki,
"--policy", SecurityPolicies.Basic256Sha256,
"--mode", nameof(MessageSecurityMode.SignAndEncrypt),
"--autoaccept", autoAccept ? "true" : "false",
"--checks", "NamespaceArray",
.. extra
]);
}
private async Task<ClientFixture> CreateClientAsync(bool autoAccept)
{
var client = new ClientFixture(telemetry: m_telemetry);
await client.LoadClientConfigurationAsync(ClientPki).ConfigureAwait(false);
if (!autoAccept)
{
client.Config.SecurityConfiguration.AutoAcceptUntrustedCertificates = false;
if (client.Config.CertificateManager is not CertificateManager manager)
{
Assert.Fail("Unexpected certificate manager " + client.Config.CertificateManager?.GetType());
return client;
}
manager.AutoAcceptUntrustedCertificates = false;
}
return client;
}
private static async Task<ISession> ConnectAsync(ClientFixture client, string url)
{
ArrayOf<EndpointDescription> endpoints = await client.GetEndpointsAsync(new Uri(url))
.ConfigureAwait(false);
EndpointDescription description = endpoints.ToArray().First(e =>
e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256 &&
e.SecurityMode == MessageSecurityMode.SignAndEncrypt);
var endpoint = new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(client.Config));
return await client.ConnectAsync(endpoint).ConfigureAwait(false);
}
private static async Task ConnectReadAndCloseAsync(ClientFixture client, string url)
{
ISession session = await ConnectAsync(client, url).ConfigureAwait(false);
try
{
DataValue state = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(state.StatusCode), Is.True, state.StatusCode.ToString());
}
finally
{
await session.CloseAsync().ConfigureAwait(false);
session.Dispose();
}
}
}
}
@@ -0,0 +1,300 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// The ECC security policies between the stacks, both directions, with
/// anonymous logon and with a user name token, whose password the
/// client encrypts with the ECC secret of the policy. Both sides use
/// their default RSA and ECC application certificates.
/// </summary>
[TestFixture]
[Category("Interop")]
[NonParallelizable]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class EccInteropTests
{
private static readonly TimeSpan s_peerTimeout = TimeSpan.FromMinutes(3);
/// <summary>
/// The ECC policies of OPC UA 1.05 that 1.5.x implements (the
/// curve25519/448 policies are 2.0 only).
/// </summary>
public static IEnumerable<TestCaseData> EccCases()
{
foreach (string policy in new[]
{
SecurityPolicies.ECC_nistP256,
SecurityPolicies.ECC_nistP384,
SecurityPolicies.ECC_brainpoolP256r1,
SecurityPolicies.ECC_brainpoolP384r1
})
{
string name = policy.Substring(policy.LastIndexOf('#') + 1);
yield return new TestCaseData(policy, MessageSecurityMode.SignAndEncrypt, false)
.SetArgDisplayNames(name, "SignAndEncrypt", "Anonymous");
yield return new TestCaseData(policy, MessageSecurityMode.SignAndEncrypt, true)
.SetArgDisplayNames(name, "SignAndEncrypt", "UserName");
// Sign only derives its keys with a different HKDF salt length
// than SignAndEncrypt (Part 6, 6.8.1).
yield return new TestCaseData(policy, MessageSecurityMode.Sign, false)
.SetArgDisplayNames(name, "Sign", "Anonymous");
}
}
private ITelemetryContext m_telemetry;
private string m_pkiRoot;
private LegacyPeerProcess m_legacyServer;
private string m_legacyServerUrl;
private ClientFixture m_client;
private ArrayOf<EndpointDescription> m_legacyEndpoints;
private ServerFixture<ReferenceServer> m_server;
private string m_serverUrl;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
m_telemetry = NUnitTelemetryContext.Create();
m_pkiRoot = InteropPki.CreateRoot();
(m_legacyServer, m_legacyServerUrl) = await LegacyPeerProcess
.StartServerAsync(InteropPki.ServerPki(m_pkiRoot) + "l", s_peerTimeout, "--ecc", "true")
.ConfigureAwait(false);
m_client = new ClientFixture(telemetry: m_telemetry);
await m_client.LoadClientConfigurationAsync(InteropPki.ClientPki(m_pkiRoot)).ConfigureAwait(false);
m_legacyEndpoints = await m_client.GetEndpointsAsync(new Uri(m_legacyServerUrl)).ConfigureAwait(false);
m_server = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
{
AutoAccept = true,
SecurityNone = true
};
await m_server.LoadConfigurationAsync(InteropPki.ServerPki(m_pkiRoot)).ConfigureAwait(false);
m_server.Config.ServerConfiguration.UserTokenPolicies += new UserTokenPolicy(UserTokenType.UserName);
await m_server.StartAsync().ConfigureAwait(false);
m_serverUrl = string.Format(
CultureInfo.InvariantCulture,
"opc.tcp://localhost:{0}/{1}",
m_server.Port,
nameof(ReferenceServer));
}
[OneTimeTearDown]
public async Task OneTimeTearDownAsync()
{
try
{
if (m_server != null)
{
await m_server.StopAsync().ConfigureAwait(false);
}
if (m_client != null)
{
await m_client.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
await InteropPki.StopAndDeleteAsync(m_legacyServer, m_pkiRoot).ConfigureAwait(false);
}
}
/// <summary>
/// The 2.0 client opens a session on an ECC endpoint of the 1.5
/// server and reads the server state.
/// </summary>
[Test]
[TestCaseSource(nameof(EccCases))]
public async Task ClientToLegacyServerAsync(string policy, MessageSecurityMode mode, bool userName)
{
if (!m_client.Config.SecurityConfiguration.SupportedSecurityPolicies.Contains(policy))
{
Assert.Ignore($"The 2.0 client does not support {policy} on this platform.");
}
EndpointDescription description = m_legacyEndpoints.ToArray()
.FirstOrDefault(e => e.SecurityPolicyUri == policy && e.SecurityMode == mode);
if (description == null)
{
Assert.Ignore($"The 1.5 server does not offer {policy}/{mode} on this platform.");
}
IUserIdentity identity = userName
? new UserIdentity("interop", System.Text.Encoding.UTF8.GetBytes("interop-password"))
: null;
var endpoint = new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(m_client.Config));
ISession session = await m_client.ConnectAsync(endpoint, identity).ConfigureAwait(false);
try
{
Assert.That(session.Endpoint.SecurityPolicyUri, Is.EqualTo(policy));
DataValue state = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(state.StatusCode), Is.True, state.StatusCode.ToString());
}
finally
{
await session.CloseAsync().ConfigureAwait(false);
session.Dispose();
}
}
/// <summary>
/// The 1.5 client opens a session on an ECC endpoint of the 2.0
/// reference server and runs its read, write and call checks.
/// </summary>
[Test]
[TestCaseSource(nameof(EccCases))]
public async Task LegacyClientToServerAsync(string policy, MessageSecurityMode mode, bool userName)
{
bool offered = m_server.Config.ServerConfiguration.SecurityPolicies.ToArray()
.Any(p => p.SecurityPolicyUri == policy && p.SecurityMode == mode);
if (!offered)
{
Assert.Ignore($"The 2.0 server does not offer {policy}/{mode} on this platform.");
}
var arguments = new List<string>
{
"client",
"--url", m_serverUrl,
"--pki", InteropPki.ClientPki(m_pkiRoot) + "l",
"--ecc", "true",
"--policy", policy,
"--mode", mode.ToString(),
"--checks", "NamespaceArray,ReadScalars,WriteAndReadBack,CallMethods"
};
if (userName)
{
arguments.AddRange(["--user", "user1", "--password", "password"]);
}
PeerRun run = await PeerRun.ExecuteAsync(s_peerTimeout, [.. arguments]).ConfigureAwait(false);
foreach (string check in new[]
{
"Connect", "NamespaceArray", "ReadScalars", "WriteAndReadBack", "CallMethods", "CloseSession"
})
{
run.AssertPassed(check);
}
}
/// <summary>
/// The 2.0 client keeps reading on an ECC_nistP256 SignAndEncrypt
/// channel of the 1.5 server through a security token renewal. ECC
/// renewals derive the new keys with HKDF; the ECC policies of OPC UA
/// 1.05 do not chain the previous secret into them. Both stacks revise
/// the lifetime to at least 60 s and the client renews at 75 %, so
/// this runs about a minute.
/// </summary>
[Test]
[CancelAfter(180_000)]
public async Task ClientToLegacyServerTokenRenewalAsync(CancellationToken ct)
{
const string policy = SecurityPolicies.ECC_nistP256;
EndpointDescription description = m_legacyEndpoints.ToArray().FirstOrDefault(e =>
e.SecurityPolicyUri == policy && e.SecurityMode == MessageSecurityMode.SignAndEncrypt);
if (description == null ||
!m_client.Config.SecurityConfiguration.SupportedSecurityPolicies.Contains(policy))
{
Assert.Ignore($"{policy} is not available on this platform.");
}
await using var client = new ClientFixture(telemetry: m_telemetry);
await client.LoadClientConfigurationAsync(InteropPki.ClientPki(m_pkiRoot)).ConfigureAwait(false);
client.Config.TransportQuotas.SecurityTokenLifetime = 60_000;
client.SessionTimeout = 120_000;
var endpoint = new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(client.Config));
ISession session = await client.ConnectAsync(endpoint).ConfigureAwait(false);
try
{
var elapsed = System.Diagnostics.Stopwatch.StartNew();
int reads = 0;
while (elapsed.Elapsed < TimeSpan.FromSeconds(65))
{
DataValue value = await session
.ReadValueAsync(VariableIds.Server_ServerStatus_CurrentTime, ct)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(value.StatusCode), Is.True,
$"read {reads} after {elapsed.Elapsed.TotalSeconds:F0} s: {value.StatusCode}");
reads++;
await Task.Delay(500, ct).ConfigureAwait(false);
}
}
finally
{
await session.CloseAsync(ct).ConfigureAwait(false);
session.Dispose();
}
}
/// <summary>
/// The 1.5 client keeps reading on an ECC_nistP256 SignAndEncrypt
/// channel of the 2.0 server through a security token renewal.
/// </summary>
[Test]
public async Task LegacyClientToServerTokenRenewalAsync()
{
const string policy = SecurityPolicies.ECC_nistP256;
bool offered = m_server.Config.ServerConfiguration.SecurityPolicies.ToArray()
.Any(p => p.SecurityPolicyUri == policy && p.SecurityMode == MessageSecurityMode.SignAndEncrypt);
if (!offered)
{
Assert.Ignore($"The 2.0 server does not offer {policy} on this platform.");
}
PeerRun run = await PeerRun.ExecuteAsync(
s_peerTimeout,
[
"client",
"--url", m_serverUrl,
"--pki", InteropPki.ClientPki(m_pkiRoot) + "l",
"--ecc", "true",
"--policy", policy,
"--mode", nameof(MessageSecurityMode.SignAndEncrypt),
"--checks", "TokenRenewal",
"--token-lifetime", "60000",
"--token-test-seconds", "65"
]).ConfigureAwait(false);
run.AssertPassed("Connect");
run.AssertPassed("TokenRenewal");
run.AssertPassed("CloseSession");
}
}
}
+286
View File
@@ -0,0 +1,286 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Opc.Ua.Security.Certificates;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// PKI folders and certificate helpers for the interop tests.
/// </summary>
/// <remarks>
/// Both stacks keep trusted, issuer and rejected stores below a PKI
/// root, each with certs and crl folders. The own store (certs and
/// private folders) is "own" for the RSA-only certificate of the 1.5
/// peer and the PKI root itself for the default RSA and ECC set both
/// stacks create otherwise. Certificates are written as DER files named
/// "&lt;CN&gt; [&lt;thumbprint&gt;].der", private keys as PFX files without
/// password in the private folder of the own store, CRLs as DER files in
/// the crl folder.
/// </remarks>
internal static class InteropPki
{
public const string Own = "own";
public const string Trusted = "trusted";
public const string Issuer = "issuer";
public const string Rejected = "rejected";
/// <summary>
/// Creates a short root below the temp folder. 1.5.378 cannot reload
/// a certificate it has just created when the store path is long, so
/// the root deliberately avoids deep folders.
/// </summary>
public static string CreateRoot()
{
string root = Path.Combine(
Path.GetTempPath(),
"uaio-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
Directory.CreateDirectory(root);
return root;
}
public static string ServerPki(string root)
{
return Path.Combine(root, "s");
}
public static string ClientPki(string root)
{
return Path.Combine(root, "c");
}
public static void Delete(string root)
{
if (string.IsNullOrEmpty(root))
{
return;
}
try
{
Directory.Delete(root, true);
}
catch (IOException)
{
// Best effort; the temp folder is cleaned up eventually.
}
catch (UnauthorizedAccessException)
{
// Best effort.
}
}
/// <summary>
/// Stops a peer server, if one was started, and deletes the PKI root
/// even when stopping fails.
/// </summary>
public static async Task StopAndDeleteAsync(LegacyPeerProcess server, string root)
{
try
{
if (server != null)
{
await server.StopAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
server.Dispose();
}
}
finally
{
Delete(root);
}
}
/// <summary>
/// Waits until a store of a PKI root holds a certificate. Both stacks
/// write rejected certificates in the background after they reject a
/// connection, so the client can see the error before the file exists.
/// </summary>
public static async Task<string[]> WaitForCertificatesAsync(string pkiRoot, string store, TimeSpan timeout)
{
var elapsed = System.Diagnostics.Stopwatch.StartNew();
string[] certificates = Certificates(pkiRoot, store);
while (certificates.Length == 0 && elapsed.Elapsed < timeout)
{
await Task.Delay(100).ConfigureAwait(false);
certificates = Certificates(pkiRoot, store);
}
return certificates;
}
/// <summary>
/// The certificates (DER files) in a store of a PKI root.
/// </summary>
public static string[] Certificates(string pkiRoot, string store)
{
string folder = Path.Combine(StorePath(pkiRoot, store), "certs");
return Directory.Exists(folder) ? Directory.GetFiles(folder, "*.der") : [];
}
/// <summary>
/// The folder of a store of a PKI root.
/// </summary>
public static string StorePath(string pkiRoot, string store)
{
if (store == Own && !Directory.Exists(Path.Combine(pkiRoot, Own, "certs")))
{
return pkiRoot;
}
return Path.Combine(pkiRoot, store);
}
/// <summary>
/// Makes the application certificates of one PKI root trusted by
/// another one.
/// </summary>
public static void Trust(string trustingPkiRoot, string trustedPkiRoot)
{
string[] certificates = Certificates(trustedPkiRoot, Own);
if (certificates.Length == 0)
{
throw new InvalidOperationException($"No application certificate in {trustedPkiRoot}.");
}
string folder = Path.Combine(trustingPkiRoot, Trusted, "certs");
Directory.CreateDirectory(folder);
foreach (string certificate in certificates)
{
File.Copy(certificate, Path.Combine(folder, Path.GetFileName(certificate)), true);
}
// 1.5 reloads a cached directory store only when the folder's
// LastWriteTimeUtc is not older than its last check; a copy within
// the same file system clock tick would keep the stale trust list.
Directory.SetLastWriteTimeUtc(folder, DateTime.UtcNow.AddSeconds(2));
}
/// <summary>
/// Creates a self-signed RSA CA.
/// </summary>
public static Certificate CreateCa(string subjectName)
{
return CertificateBuilder.Create(subjectName)
.SetCAConstraint(0)
.SetLifeTime(TimeSpan.FromDays(365))
.SetRSAKeySize(2048)
.CreateForRSA();
}
/// <summary>
/// Replaces the RSA application certificate of a PKI root with one
/// issued by the CA, with the same subject and alternative names, so
/// the application finds and keeps it on its next start.
/// </summary>
public static Certificate ReissueApplicationCertificate(string pkiRoot, Certificate ca)
{
string certs = Path.Combine(StorePath(pkiRoot, Own), "certs");
string keys = Path.Combine(StorePath(pkiRoot, Own), "private");
foreach (string file in Directory.GetFiles(certs, "*.der"))
{
X509Certificate2 existing = LoadDer(file);
try
{
if (existing.GetRSAPublicKey() == null)
{
continue;
}
X509Extension alternativeNames = existing.Extensions
.Cast<X509Extension>()
.First(e => e.Oid.Value == "2.5.29.17");
Certificate issued = CertificateBuilder.Create(existing.SubjectName)
.SetLifeTime(TimeSpan.FromDays(180))
.AddExtension(alternativeNames)
.SetIssuer(ca)
.SetRSAKeySize(2048)
.CreateForRSA();
File.Delete(file);
File.Delete(Path.Combine(keys, Path.ChangeExtension(Path.GetFileName(file), ".pfx")));
string baseName =
$"{existing.GetNameInfo(X509NameType.SimpleName, false)} [{issued.Thumbprint}]";
File.WriteAllBytes(Path.Combine(certs, baseName + ".der"), issued.RawData);
// The caller owns the copy, which holds the private key.
using (X509Certificate2 x509 = issued.AsX509Certificate2())
{
File.WriteAllBytes(Path.Combine(keys, baseName + ".pfx"), x509.Export(X509ContentType.Pfx));
}
return issued;
}
finally
{
existing.Dispose();
}
}
throw new InvalidOperationException($"No RSA application certificate in {pkiRoot}.");
}
/// <summary>
/// Trusts the CA in a PKI root, together with its CRL.
/// </summary>
public static void TrustCa(string pkiRoot, Certificate ca, X509CRL crl)
{
string certs = Path.Combine(pkiRoot, Trusted, "certs");
string crls = Path.Combine(pkiRoot, Trusted, "crl");
Directory.CreateDirectory(certs);
Directory.CreateDirectory(crls);
File.WriteAllBytes(Path.Combine(certs, $"InteropCA [{ca.Thumbprint}].der"), ca.RawData);
File.WriteAllBytes(Path.Combine(crls, "InteropCA.crl"), crl.RawData);
}
/// <summary>
/// A CRL of the CA revoking the given certificates (an empty CRL when
/// none are given).
/// </summary>
public static X509CRL CreateCrl(Certificate ca, params Certificate[] revoked)
{
// The collection adds a reference to each certificate; dispose it
// so the certificates are released (the assembly checks for leaks).
using var revokedCertificates = new CertificateCollection();
foreach (Certificate certificate in revoked)
{
revokedCertificates.Add(certificate);
}
#pragma warning disable CS0618 // the static helper is the simplest CRL builder for a test
return CertificateFactory.RevokeCertificate(ca, null, revokedCertificates);
#pragma warning restore CS0618
}
private static X509Certificate2 LoadDer(string file)
{
#if NET9_0_OR_GREATER
return X509CertificateLoader.LoadCertificateFromFile(file);
#else
return new X509Certificate2(File.ReadAllBytes(file));
#endif
}
}
}
@@ -0,0 +1,55 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using NUnit.Framework;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// Assembly-level setup/teardown that verifies no Certificate
/// instances are leaked during the test run.
/// </summary>
[SetUpFixture]
public class LeakDetectionSetup
{
[OneTimeSetUp]
public void GlobalSetup()
{
Certificate.ResetLeakCounters();
}
[OneTimeTearDown]
public void GlobalTeardown()
{
LeakDetectionHelpers.AssertNoCertificateLeaks();
}
}
}
@@ -0,0 +1,309 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Server.TestFramework;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// A 1.5.x client (the legacy peer in client mode) against this stack's
/// reference server. The peer runs once per configuration and reports
/// each check separately; every check is its own test result here.
/// </summary>
[TestFixture]
[Category("Interop")]
[NonParallelizable]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class LegacyClientInteropTests
{
private static readonly TimeSpan s_clientTimeout = TimeSpan.FromMinutes(4);
/// <summary>
/// The session-level checks every security configuration runs.
/// </summary>
private static readonly string[] s_basicChecks =
[
"Connect",
"NamespaceArray",
"ReadServerStatusStructure",
"BrowseObjectsFolder",
"TranslateBrowsePath",
"ReadScalars",
"ReadAttributes",
"WriteAndReadBack",
"CallMethods",
"Subscription",
"CloseSession"
];
/// <summary>
/// Checks of complex types, message sizes and service limits; they do
/// not depend on the security configuration and run once.
/// </summary>
private static readonly string[] s_extendedChecks =
[
"Connect",
"ComplexTypes",
"LargeArrayRoundTrip",
"LargeByteStringRoundTrip",
"OversizedRequestRejected",
"BrowseContinuationPoints",
"ReadManyNodes",
"CloseSession"
];
private static readonly Dictionary<string, (string Policy, MessageSecurityMode Mode, bool UserName)>
s_securityConfigurations = new()
{
["None"] = (SecurityPolicies.None, MessageSecurityMode.None, false),
["Basic256Sha256-Sign"] = (SecurityPolicies.Basic256Sha256, MessageSecurityMode.Sign, false),
["Basic256Sha256-SignAndEncrypt"] =
(SecurityPolicies.Basic256Sha256, MessageSecurityMode.SignAndEncrypt, false),
["Basic256Sha256-SignAndEncrypt-UserName"] =
(SecurityPolicies.Basic256Sha256, MessageSecurityMode.SignAndEncrypt, true),
["Aes128_Sha256_RsaOaep-SignAndEncrypt"] =
(SecurityPolicies.Aes128_Sha256_RsaOaep, MessageSecurityMode.SignAndEncrypt, false),
["Aes256_Sha256_RsaPss-SignAndEncrypt"] =
(SecurityPolicies.Aes256_Sha256_RsaPss, MessageSecurityMode.SignAndEncrypt, false),
["Aes256_Sha256_RsaPss-SignAndEncrypt-UserName"] =
(SecurityPolicies.Aes256_Sha256_RsaPss, MessageSecurityMode.SignAndEncrypt, true)
};
private readonly Dictionary<string, Task<PeerRun>> m_runs = new(StringComparer.Ordinal);
private ServerFixture<ReferenceServer> m_serverFixture;
private string m_pkiRoot;
private string m_serverUrl;
public static IEnumerable<TestCaseData> BasicCases()
{
foreach (string configuration in s_securityConfigurations.Keys)
{
foreach (string check in s_basicChecks)
{
yield return new TestCaseData(configuration, check);
}
}
}
public static IEnumerable<TestCaseData> ExtendedCases()
{
return s_extendedChecks.Select(check => new TestCaseData(check));
}
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
m_pkiRoot = InteropPki.CreateRoot();
// All node managers: the TestData model holds the custom structure
// types the complex type check decodes. Operation limits: the read
// limit check crosses MaxNodesPerRead.
m_serverFixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
{
AutoAccept = true,
SecurityNone = true,
AllNodeManagers = true,
OperationLimits = true
};
await m_serverFixture.LoadConfigurationAsync(InteropPki.ServerPki(m_pkiRoot)).ConfigureAwait(false);
// The fixture only advertises anonymous logon; the user name cases
// log on as a user of the reference server's user database.
m_serverFixture.Config.ServerConfiguration.UserTokenPolicies +=
new UserTokenPolicy(UserTokenType.UserName);
await m_serverFixture.StartAsync().ConfigureAwait(false);
m_serverUrl = string.Format(
CultureInfo.InvariantCulture,
"opc.tcp://localhost:{0}/{1}",
m_serverFixture.Port,
nameof(ReferenceServer));
}
[OneTimeTearDown]
public async Task OneTimeTearDownAsync()
{
foreach (Task<PeerRun> run in m_runs.Values)
{
try
{
await run.ConfigureAwait(false);
}
catch (Exception)
{
// Already reported by the tests that awaited it.
}
}
if (m_serverFixture != null)
{
await m_serverFixture.StopAsync().ConfigureAwait(false);
}
InteropPki.Delete(m_pkiRoot);
}
/// <summary>
/// A session-level check of the 1.5 client in one security
/// configuration: discovery and session, namespace array, structure
/// decoding, browse, translate, read, write, method calls,
/// subscription and close.
/// </summary>
[Test]
[TestCaseSource(nameof(BasicCases))]
public async Task LegacyClientCheckAsync(string configuration, string check)
{
(string policy, MessageSecurityMode mode, bool userName) = s_securityConfigurations[configuration];
var arguments = new List<string>
{
"--policy", policy,
"--mode", mode.ToString(),
"--checks", string.Join(",", s_basicChecks)
};
if (userName)
{
// A user of the reference server's user database.
arguments.AddRange(["--user", "user1", "--password", "password"]);
}
PeerRun run = await GetRunAsync(configuration, arguments).ConfigureAwait(false);
run.AssertPassed(check);
}
/// <summary>
/// The 1.5 client loads and decodes the custom data types of the 2.0
/// server (structures, unions, optional fields, enumerations) and
/// writes them back; sends messages much larger than a chunk; crosses
/// the server's MaxByteStringLength and MaxNodesPerRead; and pages a
/// browse with continuation points.
/// </summary>
[Test]
[TestCaseSource(nameof(ExtendedCases))]
public async Task LegacyClientExtendedCheckAsync(string check)
{
PeerRun run = await GetRunAsync(
"extended",
[
"--policy", SecurityPolicies.Basic256Sha256,
"--mode", nameof(MessageSecurityMode.SignAndEncrypt),
"--checks", string.Join(",", s_extendedChecks)
]).ConfigureAwait(false);
run.AssertPassed(check);
}
/// <summary>
/// The 1.5 client keeps reading through a security token renewal of
/// a SignAndEncrypt channel. Both stacks revise the token lifetime to
/// at least 60 s and the client renews at 75 %, so this runs about a
/// minute.
/// </summary>
[Test]
public async Task LegacyClientTokenRenewalAsync()
{
PeerRun run = await GetRunAsync(
"token-renewal",
[
"--policy", SecurityPolicies.Basic256Sha256,
"--mode", nameof(MessageSecurityMode.SignAndEncrypt),
"--checks", "TokenRenewal",
"--token-lifetime", "60000",
"--token-test-seconds", "65"
]).ConfigureAwait(false);
run.AssertPassed("Connect");
run.AssertPassed("TokenRenewal");
run.AssertPassed("CloseSession");
}
/// <summary>
/// Starts the peer for a configuration once and shares the result
/// between the tests that report its checks.
/// </summary>
private Task<PeerRun> GetRunAsync(string key, List<string> arguments)
{
if (!m_runs.TryGetValue(key, out Task<PeerRun> run))
{
arguments.InsertRange(0,
[
"client",
"--url", m_serverUrl,
"--pki", InteropPki.ClientPki(m_pkiRoot)
]);
run = PeerRun.ExecuteAsync(s_clientTimeout, [.. arguments]);
m_runs[key] = run;
}
return run;
}
}
/// <summary>
/// The outcome of a peer run in client mode.
/// </summary>
public sealed class PeerRun
{
public int ExitCode { get; private set; }
public string Output { get; private set; }
public IReadOnlyList<PeerCheckResult> Results { get; private set; }
/// <summary>
/// Asserts that the run completed (exit code 0 or 1 - all checks
/// passed or some failed - and a SUMMARY line) and that the named
/// check passed. A crash of the peer after its last result, e.g. while
/// it disposes its session, fails every check of the run.
/// </summary>
public void AssertPassed(string check)
{
Assert.That(
ExitCode,
Is.AnyOf(0, 1),
"The 1.5 client did not complete normally." + Environment.NewLine + Output);
Assert.That(
Output,
Does.Contain("SUMMARY "),
"The 1.5 client did not complete its checks." + Environment.NewLine + Output);
PeerCheckResult.AssertPassed(Results, check, Output);
}
public static async Task<PeerRun> ExecuteAsync(TimeSpan timeout, params string[] arguments)
{
(LegacyPeerProcess peer, int exitCode) = await LegacyPeerProcess.RunAsync(timeout, arguments)
.ConfigureAwait(false);
using (peer)
{
return new PeerRun
{
ExitCode = exitCode,
Output = peer.Output,
Results = peer.Results
};
}
}
}
}
@@ -0,0 +1,419 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Server.TestFramework;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// The 1.5.378 peer (tests/Opc.Ua.Interop.LegacyPeer) running as a child
/// process. The 1.5 and 2.0 stacks share assembly names and namespaces,
/// so the peer cannot be loaded into the test process.
/// </summary>
public sealed class LegacyPeerProcess : IDisposable
{
/// <summary>
/// Overrides the location of Opc.Ua.Interop.LegacyPeer.dll.
/// </summary>
public const string PeerPathVariable = "OPCUA_INTEROP_LEGACY_PEER";
private const string kPeerAssembly = "Opc.Ua.Interop.LegacyPeer.dll";
private const string kResultPrefix = "RESULT ";
private const string kServerReadyPrefix = "LEGACY-SERVER-READY ";
/// <summary>
/// How much earlier than the harness timeout a client run ends its
/// checks on its own, so it still reports a result for each check.
/// </summary>
private static readonly TimeSpan s_clientDeadlineMargin = TimeSpan.FromSeconds(30);
private readonly Process m_process;
private readonly StringBuilder m_output = new();
private readonly List<string> m_lines = [];
private readonly TaskCompletionSource<int> m_exited =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Lock m_lock = new();
private LegacyPeerProcess(Process process)
{
m_process = process;
}
/// <summary>
/// Everything the peer wrote to standard output and error so far.
/// </summary>
public string Output
{
get
{
lock (m_lock)
{
return m_output.ToString();
}
}
}
/// <summary>
/// The standard output lines the peer wrote so far.
/// </summary>
public IReadOnlyList<string> Lines
{
get
{
lock (m_lock)
{
return [.. m_lines];
}
}
}
/// <summary>
/// The check results a peer in client mode printed so far.
/// </summary>
public IReadOnlyList<PeerCheckResult> Results =>
[
.. Lines
.Where(l => l.StartsWith(kResultPrefix, StringComparison.Ordinal))
.Select(l => PeerCheckResult.Parse(l.Substring(kResultPrefix.Length)))
];
/// <summary>
/// Runs the peer to completion and returns it for inspection of its
/// exit code, output and results.
/// </summary>
public static async Task<(LegacyPeerProcess Peer, int ExitCode)> RunAsync(
TimeSpan timeout,
params string[] arguments)
{
if (arguments.Length > 0 && arguments[0] == "client")
{
// The peer cancels its checks before the harness kills it, so
// a hanging check is reported as that check's failure.
TimeSpan deadline = timeout - s_clientDeadlineMargin;
if (deadline < s_clientDeadlineMargin)
{
deadline = s_clientDeadlineMargin;
}
arguments =
[
.. arguments,
"--timeout-seconds",
((int)deadline.TotalSeconds).ToString(CultureInfo.InvariantCulture)
];
}
LegacyPeerProcess peer = Start(arguments);
try
{
int exitCode = await peer.WaitForExitAsync(timeout).ConfigureAwait(false);
return (peer, exitCode);
}
catch
{
peer.Dispose();
throw;
}
}
/// <summary>
/// Starts the peer in server mode on a free port and waits until it
/// listens. The port is only reserved until the peer binds it, so a
/// peer that exits before it is ready (another process took the
/// port) is restarted on another port.
/// </summary>
public static async Task<(LegacyPeerProcess Server, string Url)> StartServerAsync(
string pkiRoot,
TimeSpan timeout,
params string[] arguments)
{
const int maxAttempts = 3;
for (int attempt = 1; ; attempt++)
{
int port = ServerFixtureUtils.GetNextFreeIPPort();
LegacyPeerProcess server = Start(
[
"server",
"--port", port.ToString(CultureInfo.InvariantCulture),
"--pki", pkiRoot,
.. arguments
]);
try
{
string ready = await server
.WaitForLineAsync(kServerReadyPrefix, timeout, failOnExit: attempt == maxAttempts)
.ConfigureAwait(false);
if (ready != null)
{
return (server, ready.Substring(kServerReadyPrefix.Length).Trim());
}
TestContext.Progress.WriteLine(
$"The 1.5 server exited before it listened on port {port}; retrying.");
}
catch
{
server.Dispose();
throw;
}
server.Dispose();
}
}
/// <summary>
/// Starts the peer with the given arguments.
/// </summary>
public static LegacyPeerProcess Start(params string[] arguments)
{
string peer = FindPeer();
var startInfo = new ProcessStartInfo
{
FileName = FindDotnetHost(),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(peer)
};
startInfo.Arguments = string.Join(" ", new[] { peer }.Concat(arguments).Select(Quote));
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
var peerProcess = new LegacyPeerProcess(process);
process.OutputDataReceived += (_, e) => peerProcess.OnOutput(e.Data, true);
process.ErrorDataReceived += (_, e) => peerProcess.OnOutput(e.Data, false);
process.Exited += (_, _) => peerProcess.OnExited();
if (!process.Start())
{
throw new InvalidOperationException("Could not start " + startInfo.FileName);
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
return peerProcess;
}
/// <summary>
/// Waits for a standard output line starting with the prefix and
/// returns it. Fails when the timeout elapses first, and when the peer
/// exits first unless <paramref name="failOnExit"/> is false, in
/// which case it returns null.
/// </summary>
public async Task<string> WaitForLineAsync(string prefix, TimeSpan timeout, bool failOnExit = true)
{
var deadline = Stopwatch.StartNew();
while (deadline.Elapsed < timeout)
{
string line = FindLine(prefix);
if (line != null)
{
return line;
}
if (m_exited.Task.IsCompleted)
{
// The exit event can arrive before the last redirected
// lines; collect them before deciding.
m_process.WaitForExit();
line = FindLine(prefix);
if (line != null)
{
return line;
}
if (!failOnExit)
{
return null;
}
break;
}
await Task.Delay(100).ConfigureAwait(false);
}
Assert.Fail(
$"The 1.5.378 peer did not print '{prefix}' within {timeout} " +
$"(exited: {m_exited.Task.IsCompleted}).{Environment.NewLine}{Output}");
return null;
}
/// <summary>
/// Waits for the peer to exit and returns its exit code. Kills the
/// peer and fails when it outlives the timeout.
/// </summary>
public async Task<int> WaitForExitAsync(TimeSpan timeout)
{
Task completed = await Task.WhenAny(m_exited.Task, Task.Delay(timeout)).ConfigureAwait(false);
if (completed != m_exited.Task)
{
Kill();
Assert.Fail($"The 1.5.378 peer did not exit within {timeout}.{Environment.NewLine}{Output}");
}
int exitCode = await m_exited.Task.ConfigureAwait(false);
// The exit event can arrive before the last redirected line.
m_process.WaitForExit();
return exitCode;
}
/// <summary>
/// Asks a peer running in server mode to stop and waits for it.
/// </summary>
public async Task StopAsync(TimeSpan timeout)
{
if (m_exited.Task.IsCompleted)
{
return;
}
try
{
await m_process.StandardInput.WriteLineAsync("stop").ConfigureAwait(false);
await m_process.StandardInput.FlushAsync().ConfigureAwait(false);
m_process.StandardInput.Close();
}
catch (IOException)
{
// The peer already exited.
}
Task completed = await Task.WhenAny(m_exited.Task, Task.Delay(timeout)).ConfigureAwait(false);
if (completed != m_exited.Task)
{
Kill();
}
}
/// <inheritdoc/>
public void Dispose()
{
Kill();
m_process.Dispose();
}
private string FindLine(string prefix)
{
return Lines.FirstOrDefault(l => l.StartsWith(prefix, StringComparison.Ordinal));
}
private void Kill()
{
try
{
if (!m_process.HasExited)
{
#if NETFRAMEWORK
m_process.Kill();
#else
m_process.Kill(entireProcessTree: true);
#endif
m_process.WaitForExit(10_000);
}
}
catch (Exception e) when (e is InvalidOperationException or Win32Exception or AggregateException)
{
// Never started, already gone, or exiting while being killed
// (.NET Framework reports that as access denied).
}
}
private void OnOutput(string line, bool standardOutput)
{
if (line == null)
{
return;
}
lock (m_lock)
{
m_output.AppendLine(standardOutput ? line : "[stderr] " + line);
if (standardOutput)
{
m_lines.Add(line);
}
}
TestContext.Progress.WriteLine("[1.5.378] " + line);
}
private void OnExited()
{
int exitCode;
try
{
exitCode = m_process.ExitCode;
}
catch (InvalidOperationException)
{
exitCode = -1;
}
m_exited.TrySetResult(exitCode);
}
private static string FindPeer()
{
string configured = Environment.GetEnvironmentVariable(PeerPathVariable);
// Absolute, because the peer runs with its own folder as working
// directory and gets the path as argument.
string peer = Path.GetFullPath(!string.IsNullOrEmpty(configured)
? configured
: Path.Combine(TestContext.CurrentContext.TestDirectory, "legacy-peer", kPeerAssembly));
if (!File.Exists(peer))
{
Assert.Fail(
$"The 1.5.378 interop peer was not found at {peer}. It is built by the " +
$"BuildLegacyInteropPeer target of Opc.Ua.Interop.Tests.csproj, or set {PeerPathVariable}.");
}
return peer;
}
/// <summary>
/// The dotnet host that runs the peer. 'dotnet test' publishes the
/// host it runs under in DOTNET_HOST_PATH; otherwise the host on the
/// PATH is used.
/// </summary>
private static string FindDotnetHost()
{
string host = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH");
if (!string.IsNullOrEmpty(host) && File.Exists(host))
{
return host;
}
return Path.DirectorySeparatorChar == '\\' ? "dotnet.exe" : "dotnet";
}
private static string Quote(string argument)
{
if (argument.Length > 0 && argument.IndexOfAny([' ', '\t', '"']) < 0)
{
return argument;
}
return "\"" + argument.Replace("\"", "\\\"") + "\"";
}
}
}
@@ -0,0 +1,535 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.ComplexTypes;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Sessions.Tests;
using Opc.Ua.Tests;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// This stack's client tests against the 1.5.x Quickstarts reference
/// server (the legacy peer with --kind reference and all of its default
/// node managers). The tests are the server-independent workers of
/// <see cref="ClientTest"/>, which run here against the 1.5 server
/// instead of the 2.0 reference server, plus a load of the 1.5 server's
/// custom data types with the 2.0 complex type system.
/// </summary>
[TestFixture]
[Category("Interop")]
[NonParallelizable]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class LegacyReferenceServerTests
{
private static readonly TimeSpan s_startTimeout = TimeSpan.FromMinutes(2);
private LegacyPeerProcess m_server;
private string m_pkiRoot;
private ClientTest m_clientTest;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
m_pkiRoot = InteropPki.CreateRoot();
(m_server, string url) = await LegacyPeerProcess
.StartServerAsync(InteropPki.ServerPki(m_pkiRoot), s_startTimeout, "--kind", "reference")
.ConfigureAwait(false);
m_clientTest = new ClientTest(Utils.UriSchemeOpcTcp) { ExternalServerUrl = url };
await m_clientTest.OneTimeSetUpAsync().ConfigureAwait(false);
}
[OneTimeTearDown]
public async Task OneTimeTearDownAsync()
{
try
{
if (m_clientTest != null)
{
await m_clientTest.OneTimeTearDownAsync().ConfigureAwait(false);
}
}
finally
{
await InteropPki.StopAndDeleteAsync(m_server, m_pkiRoot).ConfigureAwait(false);
}
}
[SetUp]
public Task SetUpAsync()
{
return m_clientTest.SetUpAsync();
}
[TearDown]
public Task TearDownAsync()
{
return m_clientTest.TearDownAsync();
}
[Test]
[Order(100)]
public Task GetEndpointsAsync()
{
return m_clientTest.GetEndpointsAsync();
}
[Test]
[Order(100)]
public Task FindServersAsync()
{
return m_clientTest.FindServersAsync();
}
[Test]
[Order(100)]
public Task FindServersOnNetworkAsync()
{
return m_clientTest.FindServersOnNetworkAsync();
}
[Test]
[Order(105)]
public Task GetEndpointsOnDiscoveryChannelAsync()
{
return m_clientTest.GetEndpointsOnDiscoveryChannelAsync(true);
}
[Test]
[Order(110)]
public Task InvalidConfigurationAsync()
{
return m_clientTest.InvalidConfigurationAsync();
}
[Test]
[Order(202)]
public Task ConnectAndCloseAsyncReadAfterCloseAsync()
{
return m_clientTest.ConnectAndCloseAsyncReadAfterCloseAsync();
}
[Test]
[Order(204)]
public Task ConnectAndCloseAsyncReadAfterCloseSessionReconnectAsync()
{
return m_clientTest.ConnectAndCloseAsyncReadAfterCloseSessionReconnectAsync();
}
[Test]
[Order(210)]
[TestCase(false, false)]
[TestCase(true, false)]
[TestCase(false, true)]
[CancelAfter(120_000)]
public Task ConnectAndReconnectAsync(bool reconnectAbort, bool useMaxReconnectPeriod, CancellationToken ct)
{
return m_clientTest.ConnectAndReconnectAsync(reconnectAbort, useMaxReconnectPeriod, ct);
}
[Test]
[Order(240)]
public Task ConnectMultipleSessionsAsync()
{
return m_clientTest.ConnectMultipleSessionsAsync();
}
[Test]
[Order(250)]
[TestCase(true)]
[TestCase(false)]
[CancelAfter(120_000)]
public Task ReconnectSessionOnAlternateChannelAsync(bool closeChannel, CancellationToken ct)
{
return m_clientTest.ReconnectSessionOnAlternateChannelAsync(closeChannel, ct);
}
[Test]
[Order(260)]
[TestCase(SecurityPolicies.None, true)]
[TestCase(SecurityPolicies.Basic256Sha256, true)]
[TestCase(SecurityPolicies.Basic256Sha256, false)]
[TestCase(SecurityPolicies.Aes256_Sha256_RsaPss, false)]
[CancelAfter(120_000)]
public Task ReconnectSessionOnAlternateChannelWithSavedSessionSecretsAsync(
string securityPolicy,
bool anonymous,
CancellationToken ct)
{
return m_clientTest.ReconnectSessionOnAlternateChannelWithSavedSessionSecretsAsync(
securityPolicy,
anonymous,
ct);
}
[Test]
[Order(270)]
public Task RecreateSessionWithRenewUserIdentityAsync()
{
return m_clientTest.RecreateSessionWithRenewUserIdentityAsync();
}
[Test]
[Order(300)]
public Task GetOperationLimitsAsync()
{
return m_clientTest.GetOperationLimitsTestAsync();
}
[Test]
public void ReadPublicProperties()
{
m_clientTest.ReadPublicProperties();
}
[Test]
public Task ChangePreferredLocalesAsync()
{
return m_clientTest.ChangePreferredLocalesAsync();
}
[Test]
public Task ReadValueAsync()
{
return m_clientTest.ReadValueAsync();
}
[Test]
public Task ReadValueTypedAsync()
{
return m_clientTest.ReadValueTypedAsync();
}
[Test]
public Task ReadValueFromTestSimulationAsync()
{
return m_clientTest.ReadValueFromTestSimulationAsync();
}
[Test]
public Task ReadValuesAsync()
{
return m_clientTest.ReadValuesAsync();
}
[Test]
public Task ReadDataTypeDefinitionAsync()
{
return m_clientTest.ReadDataTypeDefinitionAsync();
}
[Test]
public Task ReadDataTypeDefinition2Async()
{
return m_clientTest.ReadDataTypeDefinition2Async();
}
[Test]
public Task ReadDataTypeDefinitionNodesAsync()
{
return m_clientTest.ReadDataTypeDefinitionNodesAsync();
}
[Test]
[Order(400)]
[TestCase(SecurityPolicies.Basic256Sha256, false)]
[TestCase(SecurityPolicies.Basic256Sha256, true)]
public Task BrowseFullAddressSpaceAsync(string securityPolicy, bool operationLimits)
{
return m_clientTest.BrowseFullAddressSpaceAsync(securityPolicy, operationLimits);
}
[Test]
[Order(450)]
public Task ReadDisplayNamesAsync()
{
return m_clientTest.ReadDisplayNamesAsync();
}
[Test]
[Order(480)]
public Task SubscriptionAsync()
{
return m_clientTest.SubscriptionAsync();
}
[Test]
[Order(550)]
public Task ReadNodeSyncAsync()
{
return m_clientTest.ReadNodeSyncAsync();
}
[Test]
[Order(550)]
public Task ReadNodeAsync()
{
return m_clientTest.ReadNodeAsync();
}
[Test]
[Order(570)]
[TestCase(0)]
[TestCase(ClientTest.MaxReferences)]
public Task ReadNodesAsync(int nodeCount)
{
return m_clientTest.ReadNodesAsync(nodeCount);
}
[Test]
[Order(620)]
public Task ReadAvailableEncodingsAsync()
{
return m_clientTest.ReadAvailableEncodingsAsync();
}
[Test]
[Order(800)]
[TestCase(true)]
[TestCase(false)]
public Task TransferSubscriptionNativeAsync(bool sendInitialData)
{
return m_clientTest.TransferSubscriptionNativeAsync(sendInitialData);
}
[Test]
[Order(10000)]
public Task ReadBuildInfoAsync()
{
return m_clientTest.ReadBuildInfoAsync();
}
/// <summary>
/// Loads the custom data types of the 1.5 server (TestData
/// structures, unions, structures with optional fields,
/// enumerations) with the 2.0 complex type system, decodes every
/// variable whose data type is not a namespace 0 type, and writes a
/// sample of the decoded structures back unchanged.
/// </summary>
[Test]
[Order(900)]
[CancelAfter(300_000)]
public async Task LoadAndDecodeLegacyComplexTypesAsync(CancellationToken ct)
{
ISession session = m_clientTest.Session;
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
using ComplexTypeSystem typeSystem =
ComplexTypeSystem.Create(session, new ComplexTypeBuilderFactory(), telemetry);
bool loaded = await typeSystem.LoadAsync(throwOnError: true, ct: ct).ConfigureAwait(false);
Assert.That(loaded, Is.True, "the complex type system did not load every type");
string[] typeNames = [.. typeSystem.GetDefinedTypes().Select(t => t.Name)];
Assert.That(
typeNames,
Is.SupersetOf(new[] { "ScalarStructureDataType", "VectorUnion", "VectorWithOptionalFields" }),
string.Join(", ", typeNames));
List<NodeId> variables = await BrowseVariablesAsync(session, ct).ConfigureAwait(false);
DataValue[] dataTypes = await ReadAsync(session, variables, Attributes.DataType, ct)
.ConfigureAwait(false);
var custom = new List<NodeId>();
for (int ii = 0; ii < variables.Count; ii++)
{
if (dataTypes[ii].WrappedValue.TryGetValue(out NodeId dataType) && dataType.NamespaceIndex != 0)
{
custom.Add(variables[ii]);
}
}
Assert.That(custom, Has.Count.GreaterThanOrEqualTo(10), "variables with a custom data type");
DataValue[] values = await ReadAsync(session, custom, Attributes.Value, ct).ConfigureAwait(false);
DataValue[] accessLevels = await ReadAsync(session, custom, Attributes.UserAccessLevel, ct)
.ConfigureAwait(false);
var undecoded = new List<string>();
var writes = new List<WriteValue>();
int structures = 0;
for (int ii = 0; ii < custom.Count; ii++)
{
if (StatusCode.IsBad(values[ii].StatusCode))
{
continue;
}
ExtensionObject[] extensions = ExtensionObjects(values[ii].WrappedValue);
foreach (ExtensionObject extension in extensions)
{
structures++;
if (!extension.TryGetValue(out IEncodeable _))
{
undecoded.Add($"{custom[ii]} ({extension.TypeId})");
}
}
if (extensions.Length > 0 &&
writes.Count < 50 &&
accessLevels[ii].WrappedValue.TryGetValue(out byte access) &&
(access & AccessLevels.CurrentWrite) != 0)
{
writes.Add(new WriteValue
{
NodeId = custom[ii],
AttributeId = Attributes.Value,
Value = new DataValue(values[ii].WrappedValue)
});
}
}
Assert.That(structures, Is.GreaterThan(0), "no structure values were read");
Assert.That(undecoded, Is.Empty, $"{undecoded.Count} of {structures} structures were not decoded");
Assert.That(writes, Is.Not.Empty, "no writable structure variable found");
ArrayOf<WriteValue> nodesToWrite = [.. writes];
WriteResponse response = await session.WriteAsync(null, nodesToWrite, ct).ConfigureAwait(false);
string[] rejected =
[
.. writes
.Select((w, ii) => (w, ii))
.Where(x => StatusCode.IsBad(response.Results[x.ii]))
.Select(x => $"{x.w.NodeId}: {response.Results[x.ii]}")
];
Assert.That(rejected, Is.Empty, "structure writes rejected by the 1.5 server");
DataValue[] readBack = await ReadAsync(session, [.. writes.Select(w => w.NodeId)], Attributes.Value, ct)
.ConfigureAwait(false);
string[] changed =
[
.. writes
.Select((w, ii) => (w, ii))
.Where(x => !x.w.Value.WrappedValue.Equals(readBack[x.ii].WrappedValue))
.Select(x => x.w.NodeId.ToString())
];
Assert.That(changed, Is.Empty, "structures changed in a write and read round trip");
}
private static ExtensionObject[] ExtensionObjects(Variant value)
{
if (value.TryGetValue(out ExtensionObject extension))
{
return [extension];
}
if (value.TryGetValue(out ArrayOf<ExtensionObject> extensions))
{
return [.. extensions.ToArray().Where(e => !e.IsNull)];
}
return [];
}
/// <summary>
/// The variables below the Objects folder outside namespace 0.
/// </summary>
private static async Task<List<NodeId>> BrowseVariablesAsync(ISession session, CancellationToken ct)
{
var variables = new List<NodeId>();
var visited = new HashSet<NodeId> { ObjectIds.ObjectsFolder };
var queue = new Queue<NodeId>();
queue.Enqueue(ObjectIds.ObjectsFolder);
while (queue.Count > 0 && visited.Count < 30_000)
{
NodeId nodeId = queue.Dequeue();
if (nodeId == ObjectIds.Server)
{
continue;
}
ArrayOf<BrowseDescription> nodesToBrowse =
[
new BrowseDescription
{
NodeId = nodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All
}
];
BrowseResponse response = await session.BrowseAsync(null, null, 0, nodesToBrowse, ct)
.ConfigureAwait(false);
var references = new List<ReferenceDescription>(response.Results[0].References.ToArray());
ByteString continuationPoint = response.Results[0].ContinuationPoint;
while (!continuationPoint.IsEmpty)
{
BrowseNextResponse next = await session
.BrowseNextAsync(null, false, [continuationPoint], ct)
.ConfigureAwait(false);
references.AddRange(next.Results[0].References.ToArray());
continuationPoint = next.Results[0].ContinuationPoint;
}
foreach (ReferenceDescription reference in references)
{
if (reference.NodeId.IsAbsolute)
{
continue;
}
var target = ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris);
if (!visited.Add(target))
{
continue;
}
queue.Enqueue(target);
if (reference.NodeClass == NodeClass.Variable && target.NamespaceIndex != 0)
{
variables.Add(target);
}
}
}
return variables;
}
private static async Task<DataValue[]> ReadAsync(
ISession session,
List<NodeId> nodes,
uint attributeId,
CancellationToken ct)
{
var results = new List<DataValue>();
for (int offset = 0; offset < nodes.Count; offset += 500)
{
ArrayOf<ReadValueId> nodesToRead =
[
.. nodes
.Skip(offset)
.Take(500)
.Select(n => new ReadValueId { NodeId = n, AttributeId = attributeId })
];
ReadResponse response = await session
.ReadAsync(null, 0, TimestampsToReturn.Neither, nodesToRead, ct)
.ConfigureAwait(false);
results.AddRange(response.Results.ToArray());
}
return [.. results];
}
}
}
@@ -0,0 +1,819 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// This stack's client against a 1.5.378 server (the legacy peer in
/// server mode). The server exposes an "Interop" folder below Objects
/// in the namespace <see cref="kLegacyNamespace"/>.
/// </summary>
[TestFixture]
[Category("Interop")]
[NonParallelizable]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class LegacyServerInteropTests
{
private const string kLegacyNamespace = "urn:opcfoundation.org:interop:legacy";
private const string kUserName = "interop";
private const string kPassword = "interop-password";
private static readonly TimeSpan s_startTimeout = TimeSpan.FromMinutes(2);
private ITelemetryContext m_telemetry;
private LegacyPeerProcess m_server;
private ClientFixture m_clientFixture;
private string m_pkiRoot;
private Uri m_serverUrl;
private ArrayOf<EndpointDescription> m_endpoints;
private ISession m_session;
private ushort m_ns;
public static readonly object[] SecurityCases =
[
new object[] { SecurityPolicies.None, MessageSecurityMode.None, false },
new object[] { SecurityPolicies.Basic256Sha256, MessageSecurityMode.Sign, false },
new object[] { SecurityPolicies.Basic256Sha256, MessageSecurityMode.SignAndEncrypt, false },
new object[] { SecurityPolicies.Basic256Sha256, MessageSecurityMode.SignAndEncrypt, true },
new object[] { SecurityPolicies.Aes128_Sha256_RsaOaep, MessageSecurityMode.SignAndEncrypt, false },
new object[] { SecurityPolicies.Aes256_Sha256_RsaPss, MessageSecurityMode.Sign, false },
new object[] { SecurityPolicies.Aes256_Sha256_RsaPss, MessageSecurityMode.SignAndEncrypt, true }
];
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
m_telemetry = NUnitTelemetryContext.Create();
m_pkiRoot = InteropPki.CreateRoot();
(m_server, string url) = await LegacyPeerProcess
.StartServerAsync(InteropPki.ServerPki(m_pkiRoot), s_startTimeout)
.ConfigureAwait(false);
m_serverUrl = new Uri(url);
// The fixture-wide session is idle between tests; the 10 s default
// lets the 1.5 server, a separate process, expire it during a stall
// of the test host (see ClientTestFramework.SharedSessionTimeout).
m_clientFixture = new ClientFixture(telemetry: m_telemetry) { SessionTimeout = 120_000 };
await m_clientFixture.LoadClientConfigurationAsync(InteropPki.ClientPki(m_pkiRoot))
.ConfigureAwait(false);
m_endpoints = await m_clientFixture.GetEndpointsAsync(m_serverUrl).ConfigureAwait(false);
m_session = await ConnectAsync(
SecurityPolicies.Basic256Sha256,
MessageSecurityMode.SignAndEncrypt,
null).ConfigureAwait(false);
m_ns = m_session.NamespaceUris.GetIndexOrAppend(kLegacyNamespace);
}
[OneTimeTearDown]
public async Task OneTimeTearDownAsync()
{
try
{
if (m_session != null)
{
await m_session.CloseAsync().ConfigureAwait(false);
m_session.Dispose();
}
if (m_clientFixture != null)
{
await m_clientFixture.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
await InteropPki.StopAndDeleteAsync(m_server, m_pkiRoot).ConfigureAwait(false);
}
}
[Test]
[Order(100)]
public void GetEndpointsReturnsLegacyEndpoints()
{
Assert.That(m_endpoints.Count, Is.GreaterThanOrEqualTo(SecurityCases.Length - 1));
foreach (EndpointDescription endpoint in m_endpoints)
{
Assert.That(endpoint.Server.ApplicationUri, Does.Contain("LegacyInteropServer"));
if (endpoint.SecurityMode != MessageSecurityMode.None)
{
Assert.That(endpoint.ServerCertificate.IsEmpty, Is.False, endpoint.SecurityPolicyUri);
}
}
}
/// <summary>
/// Opens a session for each security configuration, reads the server
/// state and closes the session.
/// </summary>
[Test]
[Order(110)]
[TestCaseSource(nameof(SecurityCases))]
public async Task ConnectReadAndCloseAsync(
string securityPolicyUri,
MessageSecurityMode securityMode,
bool userNameIdentity)
{
IUserIdentity identity = userNameIdentity
? new UserIdentity(kUserName, System.Text.Encoding.UTF8.GetBytes(kPassword))
: null;
ISession session = await ConnectAsync(securityPolicyUri, securityMode, identity)
.ConfigureAwait(false);
try
{
Assert.That(session.Endpoint.SecurityPolicyUri, Is.EqualTo(securityPolicyUri));
Assert.That(session.Endpoint.SecurityMode, Is.EqualTo(securityMode));
DataValue state = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(state.StatusCode), Is.True, state.StatusCode.ToString());
Assert.That(state.WrappedValue.TryGetValue(out int serverState), Is.True);
Assert.That((ServerState)serverState, Is.EqualTo(ServerState.Running));
}
finally
{
StatusCode closed = await session.CloseAsync().ConfigureAwait(false);
session.Dispose();
Assert.That(StatusCode.IsGood(closed), Is.True, closed.ToString());
}
}
[Test]
[Order(120)]
public void ConnectWithWrongPasswordIsRejected()
{
var identity = new UserIdentity(kUserName, System.Text.Encoding.UTF8.GetBytes("wrong"));
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => ConnectAsync(SecurityPolicies.Basic256Sha256, MessageSecurityMode.SignAndEncrypt, identity));
Assert.That(
sre.StatusCode.Code,
Is.AnyOf(StatusCodes.BadUserAccessDenied, StatusCodes.BadIdentityTokenRejected),
sre.ToString());
}
[Test]
[Order(200)]
public async Task ReadServerStatusStructureAsync()
{
DataValue value = await m_session.ReadValueAsync(VariableIds.Server_ServerStatus)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(value.StatusCode), Is.True, value.StatusCode.ToString());
Assert.That(value.WrappedValue.TryGetStructure(out ServerStatusDataType status), Is.True,
value.WrappedValue.ToString());
Assert.That(status.State, Is.EqualTo(ServerState.Running));
Assert.That(status.BuildInfo.ProductUri, Is.Not.Empty);
// A 1.5 release; -p:LegacyStackVersion selects which one.
Assert.That(status.BuildInfo.SoftwareVersion, Does.StartWith("1.5."));
}
[Test]
[Order(210)]
public async Task BrowseInteropFolderAsync()
{
ArrayOf<BrowseDescription> nodesToBrowse =
[
new BrowseDescription
{
NodeId = new NodeId("Interop", m_ns),
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All
}
];
BrowseResponse response = await m_session
.BrowseAsync(null, null, 0, nodesToBrowse, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(response.Results[0].StatusCode), Is.True);
string[] names = [.. response.Results[0].References.ToArray().Select(r => r.BrowseName.Name)];
Assert.That(
names,
Is.SupersetOf(new[] { "Int32", "Double", "String", "Range", "Counter", "Add", "Int32Array" }));
}
[Test]
[Order(220)]
public async Task TranslateBrowsePathAsync()
{
ArrayOf<BrowsePath> paths =
[
new BrowsePath
{
StartingNode = ObjectIds.ObjectsFolder,
RelativePath = new RelativePath
{
Elements =
[
new RelativePathElement
{
ReferenceTypeId = ReferenceTypeIds.Organizes,
TargetName = new QualifiedName("Interop", m_ns)
},
new RelativePathElement
{
ReferenceTypeId = ReferenceTypeIds.Organizes,
TargetName = new QualifiedName("Int32", m_ns)
}
]
}
}
];
TranslateBrowsePathsToNodeIdsResponse response = await m_session
.TranslateBrowsePathsToNodeIdsAsync(null, paths, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(response.Results[0].StatusCode), Is.True,
response.Results[0].StatusCode.ToString());
Assert.That(
ExpandedNodeId.ToNodeId(response.Results[0].Targets[0].TargetId, m_session.NamespaceUris),
Is.EqualTo(new NodeId("Int32", m_ns)));
}
[Test]
[Order(300)]
public async Task ReadBuiltInTypesAsync()
{
DataValue[] values = await ReadValuesAsync(
"Boolean", "Int32", "UInt64", "Double", "String", "DateTime", "Guid", "ByteString",
"LocalizedText", "QualifiedName", "NodeId", "Int32Array", "StringArray").ConfigureAwait(false);
Assert.That(values[0].WrappedValue.TryGetValue(out bool b) && b, Is.True, "Boolean");
Assert.That(values[1].WrappedValue.TryGetValue(out int i32), Is.True, "Int32");
Assert.That(i32, Is.EqualTo(42));
Assert.That(values[2].WrappedValue.TryGetValue(out ulong u64), Is.True, "UInt64");
Assert.That(u64, Is.EqualTo(ulong.MaxValue));
Assert.That(values[3].WrappedValue.TryGetValue(out double d), Is.True, "Double");
Assert.That(d, Is.EqualTo(3.25));
Assert.That(values[4].WrappedValue.TryGetValue(out string s), Is.True, "String");
Assert.That(s, Is.EqualTo("legacy"));
Assert.That(values[5].WrappedValue.TryGetValue(out DateTimeUtc dt), Is.True, "DateTime");
Assert.That((DateTime)dt, Is.EqualTo(new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc)));
Assert.That(values[6].WrappedValue.TryGetValue(out Uuid guid), Is.True, "Guid");
Assert.That((Guid)guid, Is.EqualTo(new Guid("8d2b5c6e-6c0e-4a4c-9f2b-0b6a3a6b1e01")));
Assert.That(values[7].WrappedValue.TryGetValue(out ByteString bytes), Is.True, "ByteString");
Assert.That(bytes.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3, 4, 5 }));
Assert.That(values[8].WrappedValue.TryGetValue(out LocalizedText text), Is.True, "LocalizedText");
Assert.That(text.Locale, Is.EqualTo("de"));
Assert.That(text.Text, Is.EqualTo("Hallo"));
Assert.That(values[9].WrappedValue.TryGetValue(out QualifiedName qn), Is.True, "QualifiedName");
Assert.That(qn, Is.EqualTo(new QualifiedName("Name", m_ns)));
Assert.That(values[10].WrappedValue.TryGetValue(out NodeId nodeId), Is.True, "NodeId");
Assert.That(nodeId, Is.EqualTo(new NodeId("Interop", m_ns)));
Assert.That(values[11].WrappedValue.TryGetValue(out ArrayOf<int> ints), Is.True, "Int32Array");
Assert.That(ints.ToArray(), Is.EqualTo(new[] { 1, 2, 3 }));
Assert.That(values[12].WrappedValue.TryGetValue(out ArrayOf<string> strings), Is.True, "StringArray");
Assert.That(strings.ToArray(), Is.EqualTo(new[] { "a", "b", "c" }));
}
[Test]
[Order(310)]
public async Task ReadStructureAsync()
{
DataValue[] values = await ReadValuesAsync("Range").ConfigureAwait(false);
Assert.That(values[0].WrappedValue.TryGetStructure(out Range range), Is.True,
values[0].WrappedValue.ToString());
Assert.That(range.Low, Is.Zero);
Assert.That(range.High, Is.EqualTo(100));
}
[Test]
[Order(320)]
public async Task ReadNodeAttributesAsync()
{
var nodeId = new NodeId("Int32", m_ns);
ArrayOf<ReadValueId> nodesToRead =
[
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.NodeClass },
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.BrowseName },
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType },
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ValueRank },
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevel }
];
ReadResponse response = await m_session
.ReadAsync(null, 0, TimestampsToReturn.Neither, nodesToRead, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(response.Results[0].WrappedValue.TryGetValue(out int nodeClass), Is.True);
Assert.That((NodeClass)nodeClass, Is.EqualTo(NodeClass.Variable));
Assert.That(response.Results[1].WrappedValue.TryGetValue(out QualifiedName browseName), Is.True);
Assert.That(browseName.Name, Is.EqualTo("Int32"));
Assert.That(response.Results[2].WrappedValue.TryGetValue(out NodeId dataType), Is.True);
Assert.That(dataType, Is.EqualTo(DataTypeIds.Int32));
Assert.That(response.Results[3].WrappedValue.TryGetValue(out int valueRank), Is.True);
Assert.That(valueRank, Is.EqualTo(ValueRanks.Scalar));
Assert.That(response.Results[4].WrappedValue.TryGetValue(out byte accessLevel), Is.True);
Assert.That(accessLevel, Is.EqualTo(AccessLevels.CurrentReadOrWrite));
}
[Test]
[Order(400)]
public async Task WriteAndReadBackAsync()
{
ArrayOf<WriteValue> nodesToWrite =
[
Write("Double", new Variant(-1.5e-300)),
Write("String", new Variant("written by 2.0 äöü 中")),
Write("Int32Array", new Variant(new[] { 7, 8, 9, 10 })),
Write("Range", new Variant(new ExtensionObject(new Range { Low = -5, High = 5 })))
];
WriteResponse response = await m_session
.WriteAsync(null, nodesToWrite, CancellationToken.None)
.ConfigureAwait(false);
for (int ii = 0; ii < nodesToWrite.Count; ii++)
{
Assert.That(StatusCode.IsGood(response.Results[ii]), Is.True,
$"{nodesToWrite[ii].NodeId}: {response.Results[ii]}");
}
DataValue[] values = await ReadValuesAsync("Double", "String", "Int32Array", "Range")
.ConfigureAwait(false);
Assert.That(values[0].WrappedValue.TryGetValue(out double d) && d == -1.5e-300, Is.True);
Assert.That(values[1].WrappedValue.TryGetValue(out string s), Is.True);
Assert.That(s, Is.EqualTo("written by 2.0 äöü 中"));
Assert.That(values[2].WrappedValue.TryGetValue(out ArrayOf<int> ints), Is.True);
Assert.That(ints.ToArray(), Is.EqualTo(new[] { 7, 8, 9, 10 }));
Assert.That(values[3].WrappedValue.TryGetStructure(out Range range), Is.True);
Assert.That(range.Low, Is.EqualTo(-5));
Assert.That(range.High, Is.EqualTo(5));
}
[Test]
[Order(410)]
public async Task WriteWithWrongTypeIsRejectedAsync()
{
ArrayOf<WriteValue> nodesToWrite = [Write("Int32", new Variant("not a number"))];
WriteResponse response = await m_session
.WriteAsync(null, nodesToWrite, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(response.Results[0].Code, Is.EqualTo(StatusCodes.BadTypeMismatch));
}
[Test]
[Order(500)]
public async Task CallMethodAsync()
{
ArrayOf<CallMethodRequest> requests =
[
new CallMethodRequest
{
ObjectId = new NodeId("Interop", m_ns),
MethodId = new NodeId("Add", m_ns),
InputArguments = [new Variant(40), new Variant(2)]
}
];
CallResponse response = await m_session
.CallAsync(null, requests, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(response.Results[0].StatusCode), Is.True,
response.Results[0].StatusCode.ToString());
Assert.That(response.Results[0].OutputArguments[0].TryGetValue(out int sum), Is.True);
Assert.That(sum, Is.EqualTo(42));
}
[Test]
[Order(510)]
public async Task CallMethodWithBadArgumentsIsRejectedAsync()
{
ArrayOf<CallMethodRequest> requests =
[
new CallMethodRequest
{
ObjectId = new NodeId("Interop", m_ns),
MethodId = new NodeId("Add", m_ns),
InputArguments = [new Variant(1)]
}
];
CallResponse response = await m_session
.CallAsync(null, requests, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(response.Results[0].StatusCode.Code, Is.EqualTo(StatusCodes.BadArgumentsMissing));
}
[Test]
[Order(600)]
[CancelAfter(60_000)]
public async Task SubscribeToDataChangesAsync(CancellationToken ct)
{
var subscription = new Subscription(m_session.DefaultSubscription)
{
PublishingInterval = 100,
KeepAliveCount = 10,
LifetimeCount = 100,
PublishingEnabled = true
};
Assert.That(m_session.AddSubscription(subscription), Is.True);
try
{
await subscription.CreateAsync(ct).ConfigureAwait(false);
int notifications = 0;
var received = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = new NodeId("Counter", m_ns),
AttributeId = Attributes.Value,
SamplingInterval = 100,
QueueSize = 10
};
item.Notification += (_, _) =>
{
if (Interlocked.Increment(ref notifications) >= 5)
{
received.TrySetResult(true);
}
};
subscription.AddItem(item);
await subscription.ApplyChangesAsync(ct).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(item.Status.Error), Is.True, item.Status.Error?.ToString());
Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(20), ct))
.ConfigureAwait(false);
Assert.That(completed, Is.SameAs(received.Task),
$"Only {notifications} data change notifications from the 1.5.378 server in 20 s.");
}
finally
{
await subscription.DeleteAsync(true, ct).ConfigureAwait(false);
await m_session.RemoveSubscriptionAsync(subscription, ct).ConfigureAwait(false);
}
}
/// <summary>
/// An Int32 array of 90 000 elements (360 kB, many message chunks)
/// below the server's MaxArrayLength of 100 000.
/// </summary>
[Test]
[Order(700)]
public async Task LargeArrayRoundTripAsync()
{
int[] values = [.. Enumerable.Range(0, 90_000).Select(i => (i * 7919) ^ 0x5A5A)];
await WriteAndCompareAsync("Int32Array", new Variant(values)).ConfigureAwait(false);
}
/// <summary>
/// A ByteString of 900 kB below the server's MaxByteStringLength of 1 MB.
/// </summary>
[Test]
[Order(710)]
public async Task LargeByteStringRoundTripAsync()
{
byte[] value = new byte[900 * 1024];
new Random(4711).NextBytes(value);
await WriteAndCompareAsync("ByteString", new Variant(ByteString.From(value))).ConfigureAwait(false);
}
/// <summary>
/// Requests the 1.5 server cannot decode because a value crosses its
/// MaxArrayLength or MaxByteStringLength are rejected with a status
/// code, and the session stays usable.
/// </summary>
[Test]
[Order(720)]
public async Task ValueAboveServerLimitIsRejectedAsync(
[Values("Int32Array", "ByteString")] string variable)
{
Variant value = variable == "Int32Array"
? new Variant(new int[150_000])
: new Variant(ByteString.From(new byte[2 * 1024 * 1024]));
StatusCode result;
try
{
ArrayOf<WriteValue> nodesToWrite = [Write(variable, value)];
WriteResponse response = await m_session
.WriteAsync(null, nodesToWrite, CancellationToken.None)
.ConfigureAwait(false);
result = response.Results[0];
}
catch (ServiceResultException sre)
{
result = sre.StatusCode;
}
Assert.That(StatusCode.IsBad(result), Is.True, "the oversized write was accepted");
TestContext.Out.WriteLine($"The 1.5 server rejected the oversized {variable} with {result}.");
DataValue state = await m_session.ReadValueAsync(VariableIds.Server_ServerStatus_State)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(state.StatusCode), Is.True,
$"the session is unusable after the rejected write ({result}): {state.StatusCode}");
}
/// <summary>
/// A client that announces a 256 kB MaxMessageSize reads a 900 kB
/// value: the 1.5 server must answer with a status code instead of a
/// response the client cannot receive, and the session stays usable.
/// </summary>
[Test]
[Order(730)]
public async Task ResponseAboveClientLimitIsRejectedAsync()
{
byte[] value = new byte[900 * 1024];
await WriteAndCompareAsync("ByteString", new Variant(ByteString.From(value))).ConfigureAwait(false);
await using var smallClient = new ClientFixture(telemetry: m_telemetry);
await smallClient.LoadClientConfigurationAsync(InteropPki.ClientPki(m_pkiRoot)).ConfigureAwait(false);
smallClient.Config.TransportQuotas.MaxMessageSize = 256 * 1024;
ISession session = await ConnectAsync(
smallClient,
SecurityPolicies.Basic256Sha256,
MessageSecurityMode.SignAndEncrypt,
null).ConfigureAwait(false);
try
{
StatusCode result;
try
{
DataValue read = await session.ReadValueAsync(new NodeId("ByteString", m_ns))
.ConfigureAwait(false);
result = read.StatusCode;
}
catch (ServiceResultException sre)
{
result = sre.StatusCode;
}
Assert.That(StatusCode.IsBad(result), Is.True, "a response above the client limit was delivered");
TestContext.Out.WriteLine($"The 1.5 server answered the oversized response with {result}.");
DataValue state = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(state.StatusCode), Is.True,
$"the session is unusable after the oversized response ({result}): {state.StatusCode}");
}
finally
{
await session.CloseAsync().ConfigureAwait(false);
session.Dispose();
}
}
/// <summary>
/// Browses the Interop folder three references at a time with
/// BrowseNext, compares with a browse without limit, and releases an
/// open continuation point.
/// </summary>
[Test]
[Order(740)]
public async Task BrowseWithContinuationPointsAsync()
{
var folder = new NodeId("Interop", m_ns);
List<ReferenceDescription> all = await BrowseAllAsync(folder, 0).ConfigureAwait(false);
List<ReferenceDescription> paged = await BrowseAllAsync(folder, 3).ConfigureAwait(false);
Assert.That(all, Has.Count.GreaterThan(10));
Assert.That(
paged.Select(r => r.NodeId.ToString()),
Is.EqualTo(all.Select(r => r.NodeId.ToString())),
"the paged browse returned different references");
BrowseResponse first = await m_session
.BrowseAsync(null, null, 3, [BrowseForward(folder)], CancellationToken.None)
.ConfigureAwait(false);
ByteString continuationPoint = first.Results[0].ContinuationPoint;
Assert.That(continuationPoint.IsEmpty, Is.False, "no continuation point returned");
BrowseNextResponse released = await m_session
.BrowseNextAsync(null, true, [continuationPoint], CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(released.Results[0].StatusCode), Is.True,
released.Results[0].StatusCode.ToString());
BrowseNextResponse reused = await m_session
.BrowseNextAsync(null, false, [continuationPoint], CancellationToken.None)
.ConfigureAwait(false);
Assert.That(
reused.Results[0].StatusCode.Code,
Is.EqualTo(StatusCodes.BadContinuationPointInvalid),
"a released continuation point was accepted");
}
/// <summary>
/// The session reads the 1.5 server's MaxNodesPerRead of 100 from its
/// OperationLimits and splits a read of 250 nodes; with client
/// splitting switched off the 1.5 server rejects the read with
/// BadTooManyOperations.
/// </summary>
[Test]
[Order(750)]
public async Task ReadAboveOperationLimitAsync()
{
Assert.That(m_session.OperationLimits.MaxNodesPerRead, Is.EqualTo(100u),
"the session did not take MaxNodesPerRead from the 1.5 server");
ArrayOf<ReadValueId> nodesToRead =
[
.. Enumerable.Range(0, 250).Select(_ => new ReadValueId
{
NodeId = VariableIds.Server_ServerStatus_State,
AttributeId = Attributes.Value
})
];
ReadResponse split = await m_session
.ReadAsync(null, 0, TimestampsToReturn.Neither, nodesToRead, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(split.Results.ToArray().Count(r => StatusCode.IsGood(r.StatusCode)), Is.EqualTo(250));
uint limit = m_session.OperationLimits.MaxNodesPerRead;
m_session.OperationLimits.MaxNodesPerRead = 0;
StatusCode result;
try
{
ReadResponse response = await m_session
.ReadAsync(null, 0, TimestampsToReturn.Neither, nodesToRead, CancellationToken.None)
.ConfigureAwait(false);
result = response.ResponseHeader.ServiceResult;
}
catch (ServiceResultException sre)
{
result = sre.StatusCode;
}
finally
{
m_session.OperationLimits.MaxNodesPerRead = limit;
}
Assert.That(result.Code, Is.EqualTo(StatusCodes.BadTooManyOperations));
}
/// <summary>
/// Keeps reading on a SignAndEncrypt channel through a security token
/// renewal. Both stacks revise the lifetime to at least 60 s and the
/// client renews at 75 %, so this runs about a minute.
/// </summary>
[Test]
[Order(800)]
[CancelAfter(180_000)]
public async Task ReadThroughTokenRenewalAsync(CancellationToken ct)
{
await using var client = new ClientFixture(telemetry: m_telemetry);
await client.LoadClientConfigurationAsync(InteropPki.ClientPki(m_pkiRoot)).ConfigureAwait(false);
client.Config.TransportQuotas.SecurityTokenLifetime = 60_000;
client.SessionTimeout = 120_000;
ISession session = await ConnectAsync(
client,
SecurityPolicies.Basic256Sha256,
MessageSecurityMode.SignAndEncrypt,
null).ConfigureAwait(false);
try
{
var elapsed = System.Diagnostics.Stopwatch.StartNew();
int reads = 0;
while (elapsed.Elapsed < TimeSpan.FromSeconds(65))
{
DataValue value = await session
.ReadValueAsync(VariableIds.Server_ServerStatus_CurrentTime, ct)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(value.StatusCode), Is.True,
$"read {reads} after {elapsed.Elapsed.TotalSeconds:F0} s: {value.StatusCode}");
reads++;
await Task.Delay(500, ct).ConfigureAwait(false);
}
Assert.That(session.Connected, Is.True);
}
finally
{
await session.CloseAsync(ct).ConfigureAwait(false);
session.Dispose();
}
}
private async Task WriteAndCompareAsync(string name, Variant value)
{
ArrayOf<WriteValue> nodesToWrite = [Write(name, value)];
WriteResponse response = await m_session
.WriteAsync(null, nodesToWrite, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(response.Results[0]), Is.True, $"{name}: {response.Results[0]}");
DataValue[] read = await ReadValuesAsync(name).ConfigureAwait(false);
Assert.That(read[0].WrappedValue, Is.EqualTo(value), $"{name} read back a different value");
}
private static BrowseDescription BrowseForward(NodeId nodeId)
{
return new BrowseDescription
{
NodeId = nodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All
};
}
private async Task<List<ReferenceDescription>> BrowseAllAsync(NodeId nodeId, uint maxReferences)
{
BrowseResponse response = await m_session
.BrowseAsync(null, null, maxReferences, [BrowseForward(nodeId)], CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(response.Results[0].StatusCode), Is.True);
var references = new List<ReferenceDescription>(response.Results[0].References.ToArray());
ByteString continuationPoint = response.Results[0].ContinuationPoint;
while (!continuationPoint.IsEmpty)
{
BrowseNextResponse next = await m_session
.BrowseNextAsync(null, false, [continuationPoint], CancellationToken.None)
.ConfigureAwait(false);
Assert.That(StatusCode.IsGood(next.Results[0].StatusCode), Is.True,
next.Results[0].StatusCode.ToString());
references.AddRange(next.Results[0].References.ToArray());
continuationPoint = next.Results[0].ContinuationPoint;
}
return references;
}
private Task<ISession> ConnectAsync(
string securityPolicyUri,
MessageSecurityMode securityMode,
IUserIdentity identity)
{
return ConnectAsync(m_clientFixture, securityPolicyUri, securityMode, identity);
}
private async Task<ISession> ConnectAsync(
ClientFixture clientFixture,
string securityPolicyUri,
MessageSecurityMode securityMode,
IUserIdentity identity)
{
EndpointDescription description = m_endpoints.ToArray().FirstOrDefault(e =>
e.SecurityPolicyUri == securityPolicyUri && e.SecurityMode == securityMode);
Assert.That(description, Is.Not.Null,
$"The 1.5.378 server offers no {securityPolicyUri}/{securityMode} endpoint.");
var configuration = EndpointConfiguration.Create(clientFixture.Config);
var endpoint = new ConfiguredEndpoint(null, description, configuration);
return await clientFixture.ConnectAsync(endpoint, identity).ConfigureAwait(false);
}
private async Task<DataValue[]> ReadValuesAsync(params string[] names)
{
ArrayOf<ReadValueId> nodesToRead =
[
.. names.Select(name => new ReadValueId
{
NodeId = new NodeId(name, m_ns),
AttributeId = Attributes.Value
})
];
ReadResponse response = await m_session
.ReadAsync(null, 0, TimestampsToReturn.Both, nodesToRead, CancellationToken.None)
.ConfigureAwait(false);
DataValue[] values = response.Results.ToArray();
Assert.That(values, Has.Length.EqualTo(names.Length));
for (int ii = 0; ii < names.Length; ii++)
{
Assert.That(StatusCode.IsGood(values[ii].StatusCode), Is.True,
$"{names[ii]}: {values[ii].StatusCode}");
}
return values;
}
private WriteValue Write(string name, Variant value)
{
return new WriteValue
{
NodeId = new NodeId(name, m_ns),
AttributeId = Attributes.Value,
Value = new DataValue(value)
};
}
}
}
@@ -0,0 +1,88 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Interop tests between this (2.0) stack and the released 1.5.378 stack:
- 2.0 client -> 1.5.378 server
- 1.5.378 client -> 2.0 server
The 1.5.378 side lives in ..\Opc.Ua.Interop.LegacyPeer, built from the NuGet
packages and started as a separate process. It is built and copied to
$(OutDir)legacy-peer by the BuildLegacyInteropPeer target below.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>$(TestsTargetFrameworks)</TargetFrameworks>
<RootNamespace>Opc.Ua.Interop.Tests</RootNamespace>
<GenerateProgramFile>false</GenerateProgramFile>
<Nullable>annotations</Nullable>
<NoWarn>$(NoWarn);RCS0056;RCS1166;RCS1174;RCS1229;RCS1047;RCS1077;NUnit2023;NUnit2010;NUnit2045</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(NetStandardTests)' == 'true'">
<DefineConstants>$(DefineConstants);NET_STANDARD_TESTS</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Console" />
<PackageReference Include="NUnit.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Compile Include="..\Common\Main.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Opc.Ua.Client\Opc.Ua.Client.csproj" />
<ProjectReference Include="..\..\src\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
<ProjectReference Include="..\..\src\Opc.Ua.Server\Opc.Ua.Server.csproj" />
<ProjectReference Include="..\..\src\Opc.Ua.Client.ComplexTypes\Opc.Ua.Client.ComplexTypes.csproj" />
<ProjectReference Include="..\..\samples\Quickstarts.Servers\Quickstarts.Servers.csproj" />
<ProjectReference Include="..\Opc.Ua.Client.TestFramework\Opc.Ua.Client.TestFramework.csproj" />
<ProjectReference Include="..\Opc.Ua.Server.TestFramework\Opc.Ua.Server.TestFramework.csproj" />
<ProjectReference Include="..\Opc.Ua.Sessions.Tests\Opc.Ua.Sessions.Tests.csproj" />
<ProjectReference Include="..\Opc.Ua.Test.Common\Opc.Ua.Test.Common.csproj" />
</ItemGroup>
<!--
Builds the 1.5.378 peer and copies it next to the tests. The peer does not
import the repository build settings (see its Directory.Build.props), so it
is built with only the properties given here: the global properties of this
build (TargetFramework, CustomTestTarget, ...) are removed. The MSBuild task
builds a project once per distinct property set, so the inner builds of a
multi-targeted build share one peer restore and build instead of racing on its
obj folder. The restore runs with an extra property so the build evaluates the
project again and sees the restored package imports.
Override the stack version with -p:LegacyStackVersion=<version>.
-->
<PropertyGroup>
<LegacyPeerProject>$(MSBuildThisFileDirectory)..\Opc.Ua.Interop.LegacyPeer\Opc.Ua.Interop.LegacyPeer.csproj</LegacyPeerProject>
<LegacyPeerProperties>Configuration=$(Configuration)</LegacyPeerProperties>
<LegacyPeerProperties Condition="'$(LegacyStackVersion)' != ''">$(LegacyPeerProperties);LegacyStackVersion=$(LegacyStackVersion)</LegacyPeerProperties>
<LegacyPeerRemovedProperties>TargetFramework;TargetFrameworks;CustomTestTarget;RuntimeIdentifier;OutDir;OutputPath;CollectCoverage</LegacyPeerRemovedProperties>
</PropertyGroup>
<Target Name="BuildLegacyInteropPeer" AfterTargets="Build" Condition="'$(TargetFramework)' != ''">
<MSBuild Projects="$(LegacyPeerProject)"
Targets="Restore"
Properties="$(LegacyPeerProperties);_LegacyPeerRestore=true"
RemoveProperties="$(LegacyPeerRemovedProperties)" />
<MSBuild Projects="$(LegacyPeerProject)"
Targets="Build"
Properties="$(LegacyPeerProperties)"
RemoveProperties="$(LegacyPeerRemovedProperties)">
<Output TaskParameter="TargetOutputs" ItemName="_LegacyPeerAssembly" />
</MSBuild>
<ItemGroup>
<_LegacyPeerFile Include="%(_LegacyPeerAssembly.RootDir)%(_LegacyPeerAssembly.Directory)**\*" />
</ItemGroup>
<Copy SourceFiles="@(_LegacyPeerFile)"
DestinationFiles="@(_LegacyPeerFile->'$(OutDir)legacy-peer\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -0,0 +1,83 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using NUnit.Framework;
namespace Opc.Ua.Interop.Tests
{
/// <summary>
/// One "RESULT {json}" line of the legacy peer in client mode.
/// </summary>
public sealed class PeerCheckResult
{
public string Check { get; private set; }
public bool Passed { get; private set; }
public string Message { get; private set; }
public long Milliseconds { get; private set; }
public static PeerCheckResult Parse(string json)
{
using var document = JsonDocument.Parse(json);
JsonElement root = document.RootElement;
return new PeerCheckResult
{
Check = root.GetProperty("check").GetString(),
Passed = root.GetProperty("outcome").GetString() == "Passed",
Message = root.GetProperty("message").GetString(),
Milliseconds = root.GetProperty("milliseconds").GetInt64()
};
}
/// <summary>
/// Asserts that the named check ran and passed; the failure message
/// carries the peer's reason and its full output.
/// </summary>
public static void AssertPassed(
IReadOnlyList<PeerCheckResult> results,
string check,
string peerOutput)
{
PeerCheckResult result = results.FirstOrDefault(r => r.Check == check);
if (result == null)
{
Assert.Fail(
$"The 1.5 client did not run '{check}'." + Environment.NewLine + peerOutput);
return;
}
Assert.That(
result.Passed,
Is.True,
$"1.5 client check '{check}' failed: {result.Message}" + Environment.NewLine + peerOutput);
}
}
}
@@ -0,0 +1,32 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
[assembly: CLSCompliant(false)]
@@ -271,7 +271,11 @@ namespace Opc.Ua.Types.Tests.State
var node = new BaseObjectState(null);
var value = new DataValue();
ServiceResult result = node.ReadAttribute(context, attribute, default, default, ref value);
Assert.That(ServiceResult.IsGood(result), Is.True);
// An optional attribute without a value is not supported by the
// node (Part 4, Read: Bad_AttributeIdInvalid). Good with a null
// value is not a valid RolePermissions/AccessRestrictions value and
// makes 1.5 clients fail to fetch the node (see Opc.Ua.Interop.Tests).
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadAttributeIdInvalid));
Assert.That(value.WrappedValue.IsNull, Is.True);
Assert.That(s_securityField.GetValue(node), Is.Null);