mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
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:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 <port> --pki <dir> [--kind interop|reference]
|
||||
/// [--autoaccept true|false] [--ecc true|false] [--init-only true]
|
||||
/// Starts a 1.5.x server, prints "LEGACY-SERVER-READY <url>" 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 <url> --pki <dir> [--policy <uri>] [--mode <mode>]
|
||||
/// [--user <name> --password <password>] [--checks a,b,c]
|
||||
/// [--autoaccept true|false] [--ecc true|false]
|
||||
/// [--expect-connect-error <StatusCode name,...>]
|
||||
/// [--token-lifetime <ms>] [--token-test-seconds <s>]
|
||||
/// [--timeout-seconds <s>]
|
||||
/// 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 <application uri>" 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// "<CN> [<thumbprint>].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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user