mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 06:31:35 +08:00
68f062eecc
## Description
Six small changes to the per-request path from the o6 benchmark gap
report (S1–S6), chosen from the allocation profile of the o6 read
benchmark (`AsyncCustomNodeManager`, Server GC, 10 clients × 100
single-value Reads in flight). Each change is its own commit. The
receive loop, its threading and its dispatch are not touched; there are
no wire, public API or generator changes.
### Results
Paired A/B runs, builds alternated per round, 5 rounds unless noted,
medians (min..max). Baseline: master d40843c23 with #4787, #4788 and
#4805 (all three merged since; this branch is rebased on master). Each
item was measured on its own on the baseline, and all six together. The
machine is shared with other sessions: a run during which another test
host or load harness ran was discarded and repeated. Allocated bytes per
request are deterministic; CPU per request is best read from the runs
pinned to 4 logical processors (`--server-affinity 0xF`, the 4 Zen 5
cores).
**Allocated bytes per request** (o6read 10 × 100, pinned):
| Node manager, security | baseline | S1 | S2 | S3 | S4 | S5 | S6 | all
|
|---|---|---|---|---|---|---|---|---|
| async, plain | 4,219 B | 4,114 (−2.5 %) | 3,631 (−13.9 %) | 4,057
(−3.8 %) | 4,123 (−2.3 %) | 4,099 (−2.8 %) | 3,511 (−16.8 %) | **2,411
(−42.9 %)** |
| async, encrypted | 4,267 B | −2.6 % | −13.8 % | −3.7 % | −2.4 % | −3.1
% | −16.5 % | **2,465 (−42.2 %)** |
| `CustomNodeManager2`, plain | 5,407 B | −2.1 % | −5.1 % | −3.0 % |
−1.8 % | −2.5 % | −12.9 % | **3,910 (−27.6 %)** |
| `CustomNodeManager2`, encrypted | 5,457 B | −2.0 % | −5.1 % | −2.9 % |
−1.9 % | −2.4 % | −12.9 % | **3,963 (−27.4 %)** |
**CPU per request, pinned to 4 logical processors:**
| Node manager, security | baseline | all six | each item alone |
|---|---|---|---|
| async, plain | 28.3 µs (27.8..29.8) | **27.1 µs (25.6..27.7), −4.2 %**
| −2.7 .. +1.9 % |
| async, encrypted | 31.5 µs (30.8..32.9) | 30.6 µs (30.1..40.1), −2.8 %
| −2.8 .. +0.8 % |
| `CustomNodeManager2`, plain | 34.0 µs (33.1..36.6) | **31.2 µs
(30.5..34.1), −8.1 %** | −5.0 .. +2.5 % |
| `CustomNodeManager2`, encrypted | 35.8 µs (35.0..38.9) | **34.3 µs
(33.5..35.2), −4.1 %** | −1.0 .. +3.8 % |
Garbage collections in the pinned plain run (300,000 requests): gen0 19
→ 12, pause 85 → 50 ms.
**After review (S6 with immutable codecs):** the tables above were
measured with a decoder and an encoder reset per message. Keeping
`BinaryDecoder` and `BinaryEncoder` immutable, so every message gets new
ones, costs about 214 bytes per Read: all six together allocate 2,625 B
per Read instead of 2,411 B in the pinned plain run (2 runs), −37.8 %
against the baseline.
**24 cores and the managed client** (all six against the baseline; the
managed scenarios 3 rounds):
| Scenario | baseline | all six |
|---|---|---|
| o6read 10 × 100, 24 cores: req/s | 296.3k (283.4..305.4k) | 302.2k
(245.0..323.0k) |
| same: allocated / request | 4,169 B | 2,349 B (−43.7 %) |
| o6read 10 × 1000, 24 cores: req/s | 297.4k (284.2..309.8k) | 295.6k
(269.6..311.5k) |
| same: allocated / request | 4,196 B | 2,373 B (−43.4 %) |
| `read --nodes 1`, 20 sessions: req/s, CPU / request | 259.3k, 40.2 µs
| 271.4k, 38.9 µs (−3.2 %) |
| same: allocated / request | 4,172 B | 2,444 B (−41.4 %) |
| `read --nodes 1`, 200 sessions: req/s, CPU / request | 257.1k, 36.7 µs
| 283.6k, **33.1 µs (−9.8 %)** |
| same: allocated / request | 4,041 B | 2,354 B (−41.7 %) |
| `read --nodes 100`: CPU / request | 226.1 µs | 214.9 µs (−5.0 %) |
| same: allocated / request | 72,379 B | 52,046 B (−28.1 %; S2 alone
−26.3 %) |
| `browse`: CPU / request (6 rounds) | 147.9 µs | 148.8 µs (+0.6 %) |
| same: allocated / request | 38,234 B | 32,603 B (−14.7 %) |
| `sub`: allocated / notification | 126 B | 121 B (−4.0 %) |
**Reading the CPU numbers.** Every item lowers the allocation per
request by a deterministic amount, and the savings add up (the six items
alone sum to −42.1 % against −42.9 % together). CPU per request follows
only partly: each item alone changes it within the run-to-run noise (±3
%); all six together lower it by 3–10 %, with the min..max ranges
separated in the pinned plain async and the `CustomNodeManager2`
encrypted runs and in `read --nodes 1` with 200 sessions. With Server GC
on 4 cores, garbage collection is a small share of the CPU, so 40 %
fewer bytes buy a few percent of CPU. At 24 cores throughput and CPU per
request are dominated by where the scheduler places the threads (Zen 5
and Zen 5c cores) and do not resolve a change of this size.
Allocation trace (o6read 10 × 100, first `Opc.Ua` frame): gone are the
`ResponseHeader` from `ServerBase.CreateResponse`, `Dictionary<NodeId,
NodeState>` (`AsyncCustomNodeManager.ReadAsync`), one of two
`NodeHandle`s, `Variant[]`/`uint[]` (permission attributes),
`List<DataValue>`/`List<DiagnosticInfo>`/`DiagnosticInfo[]`,
`ArrayOf<ExtensionObject>` (`Variant.Copy`), `RolePermissionType[]`,
`SecureChannelContext`, `PendingAdmission`, `System.Action`,
`RequestExecutionScope`, the `DequeueAsync` state machine box,
`MemoryStream`/`BinaryReader` of the decoder, `ArraySegmentStream`,
`BinaryWriter` and one `BufferCollection`.
## Changes
**S1: one `ResponseHeader` per response.** The generated response
constructors already create a default `ResponseHeader`; the services
replaced it with the one `ServerBase.CreateResponse` allocated. The
services that construct their response now offer its default header, and
the base `CreateResponse(RequestHeader, StringTable)` fills it. The
offer goes through a field of the thread, so the protected virtual keeps
its signature: an override that does not call the base returns its own
header as before, and a re-entrant call never gets the outer offer. The
services that receive their response from the subscription manager keep
the previous path. The third `ResponseHeader` in the profile is the
generated `Reuse()` resetting the response after it was sent. It stays:
`LeasedPublishResponse` releases its notification lease there, and the
reset is generated code.
**S2: a single-node Read without collections.**
- The dispatcher fills arrays instead of lists that were copied into the
response (the node managers fill them by index), and allocates the
operation diagnostics only for the first one.
- `AsyncCustomNodeManager.ReadAsync` creates its per-request node cache
only for a request with several nodes, or when a node must be validated.
- The permission check already resolves every node through
`GetManagerHandleAsync`, whose handle "does not require the NodeId to be
validated again". The Read service passes that handle on in
`ReadValueId.Handle` when it is an in-memory node of an
`AsyncCustomNodeManager` that was validated when it was returned, and
the node manager reads through it instead of looking the node up and
allocating a second handle. A handle passed on for an earlier read of
the same `ReadValueId` is cleared.
- The permission check without an attribute cache reads the three
permission attributes into arrays of the thread.
**S3: no copy of empty arrays.** `Variant.Copy` deep-copies arrays of
ExtensionObjects, DataValues and Variants because the caller can change
their elements. An empty array has no element to change but was copied
into a new boxed empty array. The Read service copies the namespace's
DefaultRolePermissions and DefaultUserRolePermissions on every
permission check (the variables' `CopyOnRead` policy); they are empty
unless roles are configured. `TryGetStructure` of an empty array returns
the empty array instead of a new one. Non-empty arrays, scalars and
matrices are copied as before.
**S4: one `SecureChannelContext` per channel token.**
`TcpTransportListener.OnRequestReceived` created a context per request,
although it only holds values that stay the same for the life of a
token. The channel keeps the last context with the values it was created
from and returns it while they are the same references; a renewed token
(new thumbprint, rotated certificate) or a transport reconnected to the
channel gets a new one. The isolation provider (#4787) and the session
binding check compare contexts by value.
**S5: fewer bookkeeping objects per request.**
- A request inside its service dispatch scope, which is every request
the server dispatches, needs no `RequestExecutionScope`: its
`OperationContext` completes the request itself, exactly once, when it
is disposed.
- `FairRequestQueue.TryEnqueue` owns the unpublished entry in a local
instead of a `PendingAdmission` wrapper, and registers the entry's
cancellation with a static callback instead of a delegate per request.
- `DequeueAsync` pools its state machine on .NET.
**S6: fewer codec objects per message.** Encoding a message is
synchronous, so a thread encodes one message at a time. The decoder and
the encoder stay immutable: every message gets new ones (see the
review).
- `EncodeSymmetricMessage` writes the body to the `ArraySegmentStream`
of the thread, reset per message, through a new `BinaryEncoder` that
borrows the `BinaryWriter` the thread keeps over that stream (closing or
disposing such an encoder flushes the writer but leaves it and the
stream open). The stream is given back once it handed its buffers over;
a body that fails to encode releases the stream's buffers, and the
stream is not reused. The stream is per thread, not per channel: since
#4782 the producers encode the responses of a channel concurrently and
only sealing runs in the writer.
- A response (client) in one chunk is decoded from that chunk's buffer,
as the server decodes requests; the client used a stream over the chunk
list for every response.
- A `BinaryDecoder` over a buffer creates its `MemoryStream` and
`BinaryReader` only when `BaseStream` exposes the stream, and reports
`Position` without exposing it. `DecodeMessage` reads `Position`, which
exposed the stream for every message and made every later read
synchronize its position with it.
- `ArraySegmentStream.GetBuffers` hands over the stream's own collection
instead of copying it; a message in one chunk is reassembled into a
collection sized for one.
### Not done
- **Skipping the reset of a sent response** (the third
`ResponseHeader`): `LeasedPublishResponse` releases its notification
lease in `Reuse()`.
- **Pooling `RequestLifetime`, `OperationContext` and
`EndpointIncomingRequest`:** they leave the stack. `RequestLifetime` is
public and handed to every service handler, and its token to node
managers. `EndpointIncomingRequest` is handed to the overridable
`ScheduleIncomingRequest`.
- **Pooling the fair queue `Entry`:** its cancellation registration and
the stop path can still reach it after it was released.
- **A struct token for the isolation lease:** leases are returned as
`IDisposable` through `IServerResourceIsolationProvider.TryAcquire`.
- **Reading the permission defaults without a copy when roles are
configured:** not measurable with the o6 server, which configures none.
## Behaviour notes
- **S1:** an override of `ServerBase.CreateResponse(RequestHeader,
StringTable)` that calls the base gets the response's default header
instead of a new one, the header the response carried anyway.
- **S2:**
- The node managers receive the Read result lists as arrays (`IList<T>`,
fixed size). The lists were pre-filled per item; adding to them was
never part of the contract.
- `ReadValueId.Handle` of a Read request holds the passed-on
`NodeHandle` during the call.
- **S4:** the requests of one token share one `SecureChannelContext`.
`UpstreamIdentity` has a public setter; the TCP transport never sets it,
but application code that set it on the context of a TCP request would
now change it for the token's later requests as well.
- **S6:**
- Each thread that encodes channel messages keeps one stream and writer
(a few hundred bytes), with a reference to the buffer manager of the
last channel it served.
- `BinaryDecoder.Position` of a buffer decoder no longer exposes
`BaseStream`.
## Testing
- **New tests:**
- S1, `StandardServerResponseStringTableTests` and
`ReferenceServerTests`:
- the offered header is filled with every field (timestamp, request
handle, service result, no diagnostics, string table);
- an override without the base keeps its own header;
- an override with the base fills the offered one;
- a re-entrant response never shares it;
- Read, Write and Browse responses keep every header field;
- a failed Read throws its status.
- S2, `AsyncCustomNodeManagerTests.ReadRouting` (all three node manager
fixtures):
- a single-node read uses the handle of the validation and returns what
a multi-node read returns;
- a handle passed on for an earlier read is not reused (another node,
removed node, no session);
- operation diagnostics keep one entry per node and are absent when not
requested.
- S3, `VariantCoverageTests`: empty arrays of each kind are shared; a
null array stays null; `TryGetStructure` of an empty, a null and a
one-element array.
- S4, `TcpListenerChannelTests`: the context is shared until the
thumbprint or the transport changes.
- S5, `RequestManagerTests`:
- an attached request completes once, however often its context is
disposed;
- the ambient request id is restored outside a dispatch scope;
- a stress test of attach, double dispose from two threads, cancellation
and drains.
- S6:
- `BinaryEncoderTests`: encoders borrowing one writer encode like new
encoders, and leave the writer and the stream open; null arguments;
- `BinaryDecoderTests`: `BaseStream` and `Position` of a buffer decoder;
range checks;
- `ArraySegmentStreamTests`: `GetBuffers` hands over and the stream
continues; a read stream still returns a copy; `Reset` and
`ReleaseBuffers`.
- **Run locally (net10.0, all pass):** Opc.Ua.Types.Tests,
Opc.Ua.Core.Tests, Opc.Ua.Core.Encoders.Tests, Opc.Ua.Server.Tests,
Opc.Ua.Client.Tests, Opc.Ua.Sessions.Tests, Opc.Ua.Subscriptions.Tests,
Opc.Ua.Redundancy.Server.Tests, Opc.Ua.Redundancy.Client.Tests,
Opc.Ua.Stress.Tests Contract + Integration. net48 builds.
- **Measured with:** `perf/ServerLoadHarness` scenarios `o6read` (o6
harness, not part of this PR), `read`, `browse`, `sub`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3652 lines
169 KiB
C#
3652 lines
169 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using BenchmarkDotNet.Attributes;
|
|
using Microsoft.Extensions.Logging;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Server.TestFramework;
|
|
using Opc.Ua.Test;
|
|
using Opc.Ua.Tests;
|
|
using Quickstarts.ReferenceServer;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Test Reference Server.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Server")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[NonParallelizable]
|
|
[MemoryDiagnoser]
|
|
[DisassemblyDiagnoser]
|
|
public class ReferenceServerTests
|
|
{
|
|
private const double kMaxAge = 10000;
|
|
private const uint kTimeoutHint = 10000;
|
|
private const uint kQueueSize = 5;
|
|
|
|
private const string kClientApplicationUri =
|
|
"urn:localhost:opcfoundation.org:ReferenceServerTests";
|
|
|
|
private static readonly int[] s_staticMatrixDimensions = [5, 5];
|
|
|
|
private ITelemetryContext m_telemetry;
|
|
private ServerFixture<ReferenceServer> m_fixture;
|
|
private ReferenceServer m_server;
|
|
private RequestHeader m_requestHeader;
|
|
private SecureChannelContext m_secureChannelContext;
|
|
private OperationLimits m_operationLimits = null!;
|
|
private ArrayOf<ReferenceDescription> m_referenceDescriptions;
|
|
private RandomSource m_random;
|
|
private DataGenerator m_generator;
|
|
private bool m_sessionClosed;
|
|
|
|
/// <summary>
|
|
/// Set up a Server fixture.
|
|
/// </summary>
|
|
[OneTimeSetUp]
|
|
public async Task OneTimeSetUpAsync()
|
|
{
|
|
m_telemetry = NUnitTelemetryContext.Create();
|
|
// start Ref server
|
|
m_fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = true,
|
|
OperationLimits = true,
|
|
DurableSubscriptionsEnabled = false,
|
|
UseSamplingGroupsInReferenceNodeManager = false,
|
|
AutoAccept = true
|
|
};
|
|
await m_fixture.LoadConfigurationAsync().ConfigureAwait(false);
|
|
m_fixture.Config.ServerConfiguration!.UserTokenPolicies =
|
|
[
|
|
new UserTokenPolicy(UserTokenType.Anonymous),
|
|
new UserTokenPolicy(UserTokenType.UserName)
|
|
];
|
|
m_server = await m_fixture.StartAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down the server fixture.
|
|
/// </summary>
|
|
[OneTimeTearDown]
|
|
public async Task OneTimeTearDownAsync()
|
|
{
|
|
await m_fixture.StopAsync().ConfigureAwait(false);
|
|
Thread.Sleep(1000);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a session for a test.
|
|
/// </summary>
|
|
[SetUp]
|
|
public async Task SetUpAsync()
|
|
{
|
|
(m_requestHeader, m_secureChannelContext) = await m_server.CreateAndActivateSessionAsync(
|
|
TestContext.CurrentContext.Test.Name,
|
|
clientApplicationUri: kClientApplicationUri).ConfigureAwait(false);
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
m_requestHeader.TimeoutHint = kTimeoutHint;
|
|
m_random = new RandomSource();
|
|
m_generator = new DataGenerator(m_random, m_telemetry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down the test session.
|
|
/// </summary>
|
|
[TearDown]
|
|
public async Task TearDownAsync()
|
|
{
|
|
if (!m_sessionClosed)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, CancellationToken.None).ConfigureAwait(false);
|
|
m_requestHeader = null!;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set up a Reference Server a session
|
|
/// </summary>
|
|
[GlobalSetup]
|
|
public async Task GlobalSetupAsync()
|
|
{
|
|
// start Ref server
|
|
m_fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = true
|
|
};
|
|
m_server = await m_fixture.StartAsync(null!).ConfigureAwait(false);
|
|
(m_requestHeader, m_secureChannelContext) = await m_server.CreateAndActivateSessionAsync("Bench").ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down Server and the close the session.
|
|
/// </summary>
|
|
[GlobalCleanup]
|
|
public async Task GlobalCleanupAsync()
|
|
{
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
await m_fixture.StopAsync().ConfigureAwait(false);
|
|
Thread.Sleep(1000);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test for expected exceptions.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task NoInvalidTimestampExceptionAsync()
|
|
{
|
|
// test that the server accepts an invalid timestamp
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now - TimeSpan.FromDays(30);
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, false, RequestLifetime.None).ConfigureAwait(false);
|
|
m_sessionClosed = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Endpoints.
|
|
/// </summary>
|
|
[Test]
|
|
public void GetEndpoints()
|
|
{
|
|
ArrayOf<EndpointDescription> endpoints = m_server.GetEndpoints();
|
|
Assert.That(endpoints.IsNull, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Operation limits.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(100)]
|
|
public async Task GetOperationLimitsAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId =
|
|
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall
|
|
}
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIdCollection,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
|
|
ArrayOf<DataValue> results = readResponse.Results;
|
|
Assert.That(results.IsNull, Is.False);
|
|
Assert.That(results.Count, Is.EqualTo(readIdCollection.Count));
|
|
|
|
m_operationLimits = new OperationLimits
|
|
{
|
|
MaxNodesPerRead = (uint)results[0].WrappedValue,
|
|
MaxNodesPerHistoryReadData = (uint)results[1].WrappedValue,
|
|
MaxNodesPerHistoryReadEvents = (uint)results[2].WrappedValue,
|
|
MaxNodesPerWrite = (uint)results[3].WrappedValue,
|
|
MaxNodesPerHistoryUpdateData = (uint)results[4].WrappedValue,
|
|
MaxNodesPerHistoryUpdateEvents = (uint)results[5].WrappedValue,
|
|
MaxNodesPerBrowse = (uint)results[6].WrappedValue,
|
|
MaxMonitoredItemsPerCall = (uint)results[7].WrappedValue,
|
|
MaxNodesPerNodeManagement = (uint)results[8].WrappedValue,
|
|
MaxNodesPerRegisterNodes = (uint)results[9].WrappedValue,
|
|
MaxNodesPerTranslateBrowsePathsToNodeIds = (uint)results[10].WrappedValue,
|
|
MaxNodesPerMethodCall = (uint)results[11].WrappedValue
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read node.
|
|
/// </summary>
|
|
[Test]
|
|
[Benchmark]
|
|
public async Task ReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = new NodeId("Scalar_Simulation_Int32", 2);
|
|
var nodesToRead = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new ReadValueId { NodeId = nodeId, AttributeId = attributeId })
|
|
.ToArrayOf();
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A response carries an empty string table when the request asked for no
|
|
/// diagnostics, the diagnostic strings when it did, and an empty one again for the
|
|
/// next request without diagnostics.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ResponseStringTableHoldsOnlyTheRequestsDiagnosticsAsync()
|
|
{
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = new NodeId("Scalar_Simulation_Int32", 2), AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = new NodeId("DoesNotExist", 2), AttributeId = Attributes.Value }
|
|
];
|
|
|
|
var plain = (RequestHeader)m_requestHeader.Clone();
|
|
plain.ReturnDiagnostics = (uint)DiagnosticsMasks.None;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext, plain, 0, TimestampsToReturn.Both, nodesToRead, RequestLifetime.None)
|
|
.ConfigureAwait(false);
|
|
Assert.That(response.Results[1].StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
Assert.That(response.ResponseHeader.StringTable.IsNull, Is.False);
|
|
Assert.That(response.ResponseHeader.StringTable.Count, Is.Zero);
|
|
|
|
var diagnostics = (RequestHeader)m_requestHeader.Clone();
|
|
diagnostics.ReturnDiagnostics = (uint)DiagnosticsMasks.All;
|
|
response = await m_server.ReadAsync(
|
|
m_secureChannelContext, diagnostics, 0, TimestampsToReturn.Both, nodesToRead, RequestLifetime.None)
|
|
.ConfigureAwait(false);
|
|
Assert.That(response.ResponseHeader.StringTable.Count, Is.GreaterThan(0));
|
|
|
|
response = await m_server.ReadAsync(
|
|
m_secureChannelContext, plain, 0, TimestampsToReturn.Both, nodesToRead, RequestLifetime.None)
|
|
.ConfigureAwait(false);
|
|
Assert.That(response.ResponseHeader.StringTable.IsNull, Is.False);
|
|
Assert.That(response.ResponseHeader.StringTable.Count, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The response headers of Read, Write and Browse carry the timestamp, the request
|
|
/// handle, a good service result, no service diagnostics and an empty string table,
|
|
/// and every response gets a header of its own.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ResponseHeadersKeepEveryFieldAsync()
|
|
{
|
|
var nodeId = new NodeId("Scalar_Simulation_Int32", 2);
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.RequestHandle = 4711;
|
|
DateTimeUtc before = DateTimeUtc.Now;
|
|
|
|
ReadResponse read1 = await m_server.ReadAsync(
|
|
m_secureChannelContext, requestHeader, 0, TimestampsToReturn.Both,
|
|
[new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ReadResponse read2 = await m_server.ReadAsync(
|
|
m_secureChannelContext, requestHeader, 0, TimestampsToReturn.Both,
|
|
[new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
WriteResponse write = await m_server.WriteAsync(
|
|
m_secureChannelContext, requestHeader,
|
|
[new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = read1.Results[0] }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
BrowseResponse browse = await m_server.BrowseAsync(
|
|
m_secureChannelContext, requestHeader, null!, 0,
|
|
[new BrowseDescription { NodeId = ObjectIds.ObjectsFolder, BrowseDirection = BrowseDirection.Forward }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(read2.ResponseHeader, Is.Not.SameAs(read1.ResponseHeader));
|
|
foreach (ResponseHeader header in new[]
|
|
{ read1.ResponseHeader, read2.ResponseHeader, write.ResponseHeader, browse.ResponseHeader })
|
|
{
|
|
Assert.That(header.RequestHandle, Is.EqualTo(4711u));
|
|
Assert.That(header.Timestamp, Is.GreaterThanOrEqualTo(before));
|
|
Assert.That(header.Timestamp, Is.LessThanOrEqualTo(DateTimeUtc.Now));
|
|
Assert.That(header.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(header.ServiceDiagnostics, Is.Null);
|
|
Assert.That(header.StringTable.IsNull, Is.False);
|
|
Assert.That(header.StringTable.Count, Is.Zero);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A service that fails reports its status through the exception the transport turns
|
|
/// into a service fault, not through a half-filled response header.
|
|
/// </summary>
|
|
[Test]
|
|
public void FailedReadThrowsTheServiceResult()
|
|
{
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.ReturnDiagnostics = (uint)DiagnosticsMasks.All;
|
|
|
|
ServiceResultException? e = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
await m_server.ReadAsync(
|
|
m_secureChannelContext, requestHeader, -1, TimestampsToReturn.Both,
|
|
[new ReadValueId { NodeId = new NodeId("Scalar_Simulation_Int32", 2), AttributeId = Attributes.Value }],
|
|
RequestLifetime.None).ConfigureAwait(false));
|
|
|
|
Assert.That(e!.StatusCode, Is.EqualTo(StatusCodes.BadMaxAgeInvalid));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read all nodes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadAllNodesAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
if (m_referenceDescriptions.IsNull)
|
|
{
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
// Read all variables
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
foreach (ReferenceDescription reference in m_referenceDescriptions.ToList())
|
|
{
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = ExpandedNodeId.ToNodeId(
|
|
reference.NodeId,
|
|
m_server.CurrentInstance.NamespaceUris);
|
|
var nodesToRead = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new ReadValueId { NodeId = nodeId, AttributeId = attributeId })
|
|
.ToArrayOf();
|
|
TestContext.Out.WriteLine("NodeId {0} {1}", reference.NodeId, reference.BrowseName);
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
|
|
foreach (DataValue dataValue in readResponse.Results)
|
|
{
|
|
TestContext.Out.WriteLine(" {0}", dataValue.ToString());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Write Node.
|
|
/// </summary>
|
|
[Test]
|
|
[Benchmark]
|
|
public async Task WriteAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Write
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = new NodeId("Scalar_Simulation_Int32", 2);
|
|
var nodesToWrite = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(1234)
|
|
})
|
|
.ToArrayOf();
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
writeResponse.DiagnosticInfos,
|
|
writeResponse.Results,
|
|
writeResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ReferenceNodeManager variables update their SourceTimestamp on read.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(340)]
|
|
public async Task ReferenceNodeManagerVariablesUpdateTimestampOnReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read a variable from the ReferenceNodeManager (namespace index 2)
|
|
var nodeId = new NodeId("Scalar_Static_Byte", 2);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// First read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeFirstRead = DateTimeUtc.Now;
|
|
ReadResponse firstReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(firstReadResponse, Is.Not.Null);
|
|
Assert.That(firstReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(firstReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue firstValue = firstReadResponse.Results[0];
|
|
Assert.That(firstValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(firstValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("First read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
firstValue.SourceTimestamp, firstValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the timestamp is recent (not startup time)
|
|
Assert.That((long)firstValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeFirstRead.SubtractMilliseconds(1000)),
|
|
"SourceTimestamp should be close to the read time, not the server startup time");
|
|
|
|
// Wait a bit to ensure time difference
|
|
await Task.Delay(1500).ConfigureAwait(false);
|
|
|
|
// Second read
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeSecondRead = DateTimeUtc.Now;
|
|
ReadResponse secondReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(secondReadResponse, Is.Not.Null);
|
|
Assert.That(secondReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(secondReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue secondValue = secondReadResponse.Results[0];
|
|
Assert.That(secondValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(secondValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Second read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
secondValue.SourceTimestamp, secondValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the second timestamp is more recent than the first
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThan((long)firstValue.SourceTimestamp),
|
|
"SourceTimestamp should be updated on each read");
|
|
|
|
// Verify the second timestamp is recent
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeSecondRead.SubtractMilliseconds(1000)),
|
|
"SourceTimestamp should be close to the second read time");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the ReferenceNodeManager SelectionList variable exposes its
|
|
/// value.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerSelectionListVariableAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
|
|
var valueNodeId = new NodeId("DataAccess_SelectionList_Colors", namespaceIndex);
|
|
var selectionsNodeId = new NodeId(
|
|
"DataAccess_SelectionList_Colors_Selections",
|
|
namespaceIndex);
|
|
var descriptionsNodeId = new NodeId(
|
|
"DataAccess_SelectionList_Colors_SelectionDescriptions",
|
|
namespaceIndex);
|
|
var restrictToListNodeId = new NodeId(
|
|
"DataAccess_SelectionList_Colors_RestrictToList",
|
|
namespaceIndex);
|
|
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = valueNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = valueNodeId, AttributeId = Attributes.DataType },
|
|
new ReadValueId { NodeId = selectionsNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = selectionsNodeId, AttributeId = Attributes.DataType },
|
|
new ReadValueId { NodeId = descriptionsNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = restrictToListNodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse, Is.Not.Null);
|
|
Assert.That(readResponse.Results.IsNull, Is.False);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(nodesToRead.Count));
|
|
|
|
DataValue value = readResponse.Results[0];
|
|
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(value.WrappedValue.GetString(), Is.EqualTo("Red"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[1].WrappedValue.TryGetValue(out NodeId valueDataType),
|
|
Is.True);
|
|
Assert.That(valueDataType, Is.EqualTo(DataTypeIds.String));
|
|
|
|
Assert.That(
|
|
readResponse.Results[2].WrappedValue.TryGetValue(
|
|
out ArrayOf<string> selections),
|
|
Is.True);
|
|
Assert.That(selections, Has.Count.EqualTo(3));
|
|
Assert.That(selections[0], Is.EqualTo("Red"));
|
|
Assert.That(selections[1], Is.EqualTo("Green"));
|
|
Assert.That(selections[2], Is.EqualTo("Blue"));
|
|
Assert.That(
|
|
readResponse.Results[3].WrappedValue.TryGetValue(out NodeId selectionsDataType),
|
|
Is.True);
|
|
Assert.That(selectionsDataType, Is.EqualTo(DataTypeIds.String));
|
|
|
|
Assert.That(
|
|
readResponse.Results[4].WrappedValue.TryGetValue(
|
|
out ArrayOf<LocalizedText> selectionDescriptions),
|
|
Is.True);
|
|
Assert.That(selectionDescriptions, Has.Count.EqualTo(3));
|
|
Assert.That(selectionDescriptions[0].Text, Is.EqualTo("The color red"));
|
|
Assert.That(readResponse.Results[5].WrappedValue.GetBoolean(), Is.True);
|
|
|
|
ArrayOf<WriteValue> nodesToWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = valueNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue("Green")
|
|
},
|
|
new WriteValue
|
|
{
|
|
NodeId = valueNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue("Purple")
|
|
},
|
|
new WriteValue
|
|
{
|
|
NodeId = valueNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue("Red")
|
|
}
|
|
];
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(writeResponse.Results, Has.Count.EqualTo(nodesToWrite.Count));
|
|
Assert.That(writeResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(writeResponse.Results[1], Is.EqualTo(StatusCodes.BadOutOfRange));
|
|
Assert.That(writeResponse.Results[2], Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that the discrete DataAccess node seed values (TrueState/FalseState,
|
|
/// EnumStrings, EnumValues, ValueAsText) baked into the NodeSet2 model are
|
|
/// materialized at runtime by the source generator.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerDiscreteSeedValuesAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
|
|
var twoStateTrueId = new NodeId(
|
|
"DataAccess_TwoStateDiscreteType_DataAccess_TwoStateDiscreteType_001_TrueState",
|
|
namespaceIndex);
|
|
var twoStateFalseId = new NodeId(
|
|
"DataAccess_TwoStateDiscreteType_DataAccess_TwoStateDiscreteType_001_FalseState",
|
|
namespaceIndex);
|
|
var multiStateEnumStringsId = new NodeId(
|
|
"DataAccess_MultiStateDiscreteType_DataAccess_MultiStateDiscreteType_001_EnumStrings",
|
|
namespaceIndex);
|
|
var multiStateValueEnumValuesId = new NodeId(
|
|
"DataAccess_MultiStateValueDiscreteType_DataAccess_MultiStateValueDiscreteType_001_EnumValues",
|
|
namespaceIndex);
|
|
var multiStateValueAsTextId = new NodeId(
|
|
"DataAccess_MultiStateValueDiscreteType_DataAccess_MultiStateValueDiscreteType_001_ValueAsText",
|
|
namespaceIndex);
|
|
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = twoStateTrueId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = twoStateFalseId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = multiStateEnumStringsId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = multiStateValueEnumValuesId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = multiStateValueAsTextId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse, Is.Not.Null);
|
|
Assert.That(readResponse.Results.IsNull, Is.False);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(nodesToRead.Count));
|
|
|
|
foreach (DataValue result in readResponse.Results)
|
|
{
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
Assert.That(
|
|
readResponse.Results[0].WrappedValue.TryGetValue(out LocalizedText trueState),
|
|
Is.True);
|
|
Assert.That(trueState.Text, Is.EqualTo("red"));
|
|
Assert.That(
|
|
readResponse.Results[1].WrappedValue.TryGetValue(out LocalizedText falseState),
|
|
Is.True);
|
|
Assert.That(falseState.Text, Is.EqualTo("blue"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[2].WrappedValue.TryGetValue(
|
|
out ArrayOf<LocalizedText> enumStrings),
|
|
Is.True);
|
|
Assert.That(enumStrings, Has.Count.EqualTo(3));
|
|
Assert.That(enumStrings[0].Text, Is.EqualTo("open"));
|
|
Assert.That(enumStrings[1].Text, Is.EqualTo("closed"));
|
|
Assert.That(enumStrings[2].Text, Is.EqualTo("jammed"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[3].WrappedValue.TryGetValue(
|
|
out ArrayOf<ExtensionObject> enumValues),
|
|
Is.True);
|
|
Assert.That(enumValues, Has.Count.EqualTo(3));
|
|
Assert.That(
|
|
enumValues[0].TryGetValue(out EnumValueType? firstEnumValue),
|
|
Is.True);
|
|
Assert.That(firstEnumValue!.Value, Is.Zero);
|
|
Assert.That(firstEnumValue.DisplayName.Text, Is.EqualTo("open"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[4].WrappedValue.TryGetValue(out LocalizedText valueAsText),
|
|
Is.True);
|
|
Assert.That(valueAsText.Text, Is.EqualTo("open"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that AccessLevelEx retains the same base access bits as AccessLevel.
|
|
/// </summary>
|
|
[TestCase("Scalar_Static_NonatomicReadWrite")]
|
|
[TestCase("AccessRights_AccessAll_NonatomicReadWrite")]
|
|
public async Task ReferenceNodeManagerNonatomicAccessLevelsAreConsistentAsync(string identifier)
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId(identifier, namespaceIndex);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevel },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.UserAccessLevel },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevelEx }
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
// the reference server grants StatusWrite and TimestampWrite on every
|
|
// writable variable (OPC 10000-3 8.57).
|
|
const byte expectedAccessLevel = (byte)(
|
|
AccessLevels.CurrentReadOrWrite |
|
|
AccessLevels.StatusWrite |
|
|
AccessLevels.TimestampWrite);
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
Assert.That(response.Results[0].WrappedValue.GetByte(),
|
|
Is.EqualTo(expectedAccessLevel));
|
|
Assert.That(response.Results[1].WrappedValue.GetByte(),
|
|
Is.EqualTo(expectedAccessLevel));
|
|
|
|
uint accessLevelEx = response.Results[2].WrappedValue.GetUInt32();
|
|
Assert.That(
|
|
accessLevelEx & 0xff,
|
|
Is.EqualTo((uint)expectedAccessLevel));
|
|
Assert.That(
|
|
accessLevelEx & (uint)AccessLevelExType.NonatomicRead,
|
|
Is.EqualTo((uint)AccessLevelExType.NonatomicRead));
|
|
Assert.That(
|
|
accessLevelEx & (uint)AccessLevelExType.NonatomicWrite,
|
|
Is.EqualTo((uint)AccessLevelExType.NonatomicWrite));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The ArrayItemType engineering units and axis definitions carry a UNECE unit, and the
|
|
/// Float AnalogItemType has a non-empty EURange, so the CTT Engineering Units and
|
|
/// PercentDeadband test cases do not skip the nodes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerDataAccessUnitsAndRangesAreConfiguredAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
string[] arrayItems = ["YArray", "XYArray", "Image", "Cube", "NDimension"];
|
|
var nodesToRead = new List<ReadValueId>();
|
|
foreach (string item in arrayItems)
|
|
{
|
|
nodesToRead.Add(new ReadValueId
|
|
{
|
|
NodeId = new NodeId(
|
|
$"DataAccess_ArrayItemType_DataAccess_ArrayItemType_{item}_EngineeringUnits",
|
|
namespaceIndex),
|
|
AttributeId = Attributes.Value
|
|
});
|
|
}
|
|
nodesToRead.Add(new ReadValueId
|
|
{
|
|
NodeId = new NodeId(
|
|
"DataAccess_ArrayItemType_DataAccess_ArrayItemType_Cube_ZAxisDefinition",
|
|
namespaceIndex),
|
|
AttributeId = Attributes.Value
|
|
});
|
|
nodesToRead.Add(new ReadValueId
|
|
{
|
|
NodeId = new NodeId(
|
|
"DataAccess_AnalogType_DataAccess_AnalogType_Float_EURange",
|
|
namespaceIndex),
|
|
AttributeId = Attributes.Value
|
|
});
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead.ToArrayOf(),
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int ii = 0; ii < arrayItems.Length; ii++)
|
|
{
|
|
Assert.That(response.Results[ii].StatusCode, Is.EqualTo(StatusCodes.Good), arrayItems[ii]);
|
|
Assert.That(response.Results[ii].WrappedValue.TryGetValue(out ExtensionObject extension), Is.True);
|
|
Assert.That(extension.TryGetValue(out EUInformation? units), Is.True, arrayItems[ii]);
|
|
Assert.That(units!.NamespaceUri, Is.EqualTo("http://www.opcfoundation.org/UA/units/un/cefact"));
|
|
Assert.That(units.UnitId, Is.EqualTo(12890), arrayItems[ii]);
|
|
Assert.That(units.DisplayName.Text, Is.EqualTo("mV"));
|
|
Assert.That(units.Description.Text, Is.EqualTo("millivolt"));
|
|
}
|
|
|
|
DataValue axis = response.Results[arrayItems.Length];
|
|
Assert.That(axis.WrappedValue.TryGetValue(out ExtensionObject axisExtension), Is.True);
|
|
Assert.That(axisExtension.TryGetValue(out AxisInformation? axisInformation), Is.True);
|
|
Assert.That(axisInformation!.EngineeringUnits.UnitId, Is.EqualTo(5457219));
|
|
Assert.That(axisInformation.EngineeringUnits.DisplayName.Text, Is.EqualTo("s"));
|
|
|
|
DataValue range = response.Results[arrayItems.Length + 1];
|
|
Assert.That(range.WrappedValue.TryGetValue(out ExtensionObject rangeExtension), Is.True);
|
|
Assert.That(rangeExtension.TryGetValue(out Range? euRange), Is.True);
|
|
Assert.That(euRange!.High, Is.GreaterThan(euRange.Low));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every static two-dimensional sample array is 5x5 so that the CTT multi-dimensional
|
|
/// IndexRange test cases (for example "0,2:4") can use it.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerStaticMatricesAreFiveByFiveAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
string[] suffixes =
|
|
[
|
|
"Boolean", "Byte", "ByteString", "DateTime", "Double", "Duration", "Float", "Guid",
|
|
"Int16", "Int32", "Int64", "Integer", "LocaleId", "LocalizedText", "NodeId", "Number",
|
|
"QualifiedName", "SByte", "String", "UInt16", "UInt32", "UInt64", "UInteger", "UtcTime",
|
|
"Variant", "XmlElement"
|
|
];
|
|
var nodesToRead = new List<ReadValueId>();
|
|
foreach (string suffix in suffixes)
|
|
{
|
|
var nodeId = new NodeId("Scalar_Static_Arrays2D_" + suffix, namespaceIndex);
|
|
nodesToRead.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ArrayDimensions });
|
|
nodesToRead.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value });
|
|
}
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead.ToArrayOf(),
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int ii = 0; ii < suffixes.Length; ii++)
|
|
{
|
|
DataValue dimensions = response.Results[2 * ii];
|
|
DataValue value = response.Results[(2 * ii) + 1];
|
|
Assert.That(dimensions.StatusCode, Is.EqualTo(StatusCodes.Good), suffixes[ii]);
|
|
Assert.That(dimensions.WrappedValue.TryGetValue(out ArrayOf<uint> arrayDimensions), Is.True, suffixes[ii]);
|
|
Assert.That(arrayDimensions.ToArray(), Is.EqualTo(s_staticMatrixDimensions), suffixes[ii]);
|
|
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good), suffixes[ii]);
|
|
Assert.That(value.WrappedValue.TypeInfo.ValueRank, Is.EqualTo(ValueRanks.TwoDimensions), suffixes[ii]);
|
|
Assert.That(value.WrappedValue.AsBoxedObject(), Is.InstanceOf<IConvertableToMatrix>(), suffixes[ii]);
|
|
var matrix = ((IConvertableToMatrix)value.WrappedValue.AsBoxedObject())
|
|
.ToMatrix(value.WrappedValue.TypeInfo.BuiltInType);
|
|
Assert.That(matrix.Dimensions, Is.EqualTo(s_staticMatrixDimensions), suffixes[ii]);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// DataAccess_DataItem_String does not provide the optional MinimumSamplingInterval attribute
|
|
/// (CTT Base Info Server Capabilities 2 002.js).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerDataItemHasNoMinimumSamplingIntervalAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId("DataAccess_DataItem_String", namespaceIndex);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.MinimumSamplingInterval },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.BadAttributeIdInvalid));
|
|
Assert.That(response.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
|
|
// the node can still be monitored; the sampling interval is revised as usual.
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
CreateSubscriptionResponse subscription = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
try
|
|
{
|
|
CreateMonitoredItemsResponse items = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscription.SubscriptionId,
|
|
TimestampsToReturn.Neither,
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
]).ConfigureAwait(false);
|
|
Assert.That(items.Results, Has.Count.EqualTo(1));
|
|
Assert.That(items.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(items.Results[0].RevisedSamplingInterval, Is.GreaterThanOrEqualTo(0));
|
|
}
|
|
finally
|
|
{
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscription.SubscriptionId]).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scalar_Static_NonHistorizing_Boolean advertises HistoryRead access but is not
|
|
/// historized; a HistoryRead returns Bad_HistoryOperationUnsupported (Part 4 §5.11.3,
|
|
/// CTT Historical Access Read Raw Err-025.js).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoryReadNonHistorizingNodeIsUnsupportedAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId("Scalar_Static_NonHistorizing_Boolean", namespaceIndex);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Historizing },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevel },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
Assert.That(readResponse.Results[0].WrappedValue.GetBoolean(), Is.False);
|
|
Assert.That(
|
|
readResponse.Results[1].WrappedValue.GetByte() & AccessLevels.HistoryRead,
|
|
Is.EqualTo(AccessLevels.HistoryRead));
|
|
Assert.That(readResponse.Results[2].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
|
|
var historyReadDetails = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = DateTimeUtc.Now.SubtractMilliseconds(60 * 60 * 1000),
|
|
EndTime = DateTimeUtc.Now,
|
|
NumValuesPerNode = 10,
|
|
IsReadModified = false,
|
|
ReturnBounds = false
|
|
};
|
|
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse historyReadResponse = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
new ExtensionObject(historyReadDetails),
|
|
TimestampsToReturn.Source,
|
|
false,
|
|
[new HistoryReadValueId { NodeId = nodeId }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(historyReadResponse.Results, Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
historyReadResponse.Results[0].StatusCode.Code,
|
|
Is.EqualTo(StatusCodes.BadHistoryOperationUnsupported));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Server object references nodes with HasComponent and with its subtype HasAddIn and
|
|
/// has more than three references, so it can back the CTT setting
|
|
/// "/Server Test/NodeIds/References/Has References of a ReferenceType and SubType"
|
|
/// (View Basic 2 005/015/016/020, View Minimum Continuation Point 01 010/011).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerObjectHasReferencesOfATypeAndItsSubtypeAsync()
|
|
{
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
null,
|
|
0,
|
|
[
|
|
new BrowseDescription
|
|
{
|
|
NodeId = ObjectIds.Server,
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
|
IncludeSubtypes = true,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(1));
|
|
ReferenceDescription[] references = response.Results[0].References.ToArray()!;
|
|
Assert.That(
|
|
references!.Count(r => r.ReferenceTypeId == ReferenceTypeIds.HasComponent),
|
|
Is.GreaterThanOrEqualTo(3));
|
|
Assert.That(
|
|
references.Count(r => r.ReferenceTypeId == ReferenceTypeIds.HasAddIn),
|
|
Is.GreaterThanOrEqualTo(1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Method and View node class sample nodes reference at least two nodes of their
|
|
/// own NodeClass (CTT View Minimum Continuation Point 01 012.js, View Basic 2 018.js).
|
|
/// </summary>
|
|
[Test]
|
|
[TestCase("Methods_Void", NodeClass.Method)]
|
|
[TestCase("Views_Operations", NodeClass.View)]
|
|
public async Task NodeClassSampleNodesReferenceTheirOwnNodeClassAsync(string identifier, NodeClass nodeClass)
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
ArrayOf<BrowseDescription> nodesToBrowse =
|
|
[
|
|
new BrowseDescription
|
|
{
|
|
NodeId = new NodeId(identifier, namespaceIndex),
|
|
BrowseDirection = BrowseDirection.Both,
|
|
ReferenceTypeId = ReferenceTypeIds.References,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
null,
|
|
0,
|
|
nodesToBrowse,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(1));
|
|
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
ArrayOf<ReferenceDescription> references = response.Results[0].References;
|
|
Assert.That(references.ToArray()!.Count(r => r.NodeClass == nodeClass), Is.GreaterThanOrEqualTo(2));
|
|
Assert.That(references.ToArray()!.Count(r => r.NodeClass != nodeClass), Is.GreaterThanOrEqualTo(1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Views can only be the source of hierarchical References (Part 3 §5.4), and the
|
|
/// CTT (Base Info Core Structure 2 001.js) also rejects Views targeted by
|
|
/// non-hierarchical References.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCase("Views_Operations")]
|
|
[TestCase("Views_Engineering")]
|
|
[TestCase("Views_Maintenance")]
|
|
public async Task SampleViewsHaveNoNonHierarchicalReferencesAsync(string identifier)
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
ArrayOf<BrowseDescription> nodesToBrowse =
|
|
[
|
|
new BrowseDescription
|
|
{
|
|
NodeId = new NodeId(identifier, namespaceIndex),
|
|
BrowseDirection = BrowseDirection.Both,
|
|
ReferenceTypeId = ReferenceTypeIds.NonHierarchicalReferences,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
null,
|
|
0,
|
|
nodesToBrowse,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(1));
|
|
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(response.Results[0].References.ToArray(), Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the ReferenceNodeManager Currency variable exposes its value
|
|
/// and its CurrencyUnit property.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerCurrencyVariableAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
|
|
var amountNodeId = new NodeId("DataAccess_Currency_Amount", namespaceIndex);
|
|
var currencyUnitNodeId = new NodeId(
|
|
"DataAccess_Currency_Amount_CurrencyUnit",
|
|
namespaceIndex);
|
|
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = amountNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = currencyUnitNodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse, Is.Not.Null);
|
|
Assert.That(readResponse.Results.IsNull, Is.False);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
DataValue amount = readResponse.Results[0];
|
|
Assert.That(amount.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(amount.WrappedValue.GetDouble(), Is.EqualTo(42.0));
|
|
|
|
DataValue currencyUnit = readResponse.Results[1];
|
|
Assert.That(currencyUnit.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(currencyUnit.WrappedValue.TryGetValue(out ExtensionObject currencyUnitObject), Is.True);
|
|
Assert.That(currencyUnitObject.TryGetValue(out CurrencyUnitType? unit), Is.True);
|
|
Assert.That(unit!.NumericCode, Is.EqualTo(978));
|
|
Assert.That(unit.Exponent, Is.EqualTo(2));
|
|
Assert.That(unit.AlphabeticCode, Is.EqualTo("EUR"));
|
|
|
|
using var stream = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(
|
|
stream,
|
|
m_server.CurrentInstance.MessageContext,
|
|
leaveOpen: true))
|
|
{
|
|
unit.Encode(encoder);
|
|
}
|
|
byte[] encoded = stream.ToArray();
|
|
Assert.That(encoded, Has.Length.GreaterThan(2));
|
|
Assert.That(encoded[0], Is.EqualTo(0xD2), "NumericCode low byte");
|
|
Assert.That(encoded[1], Is.EqualTo(0x03), "NumericCode high byte");
|
|
Assert.That(encoded[2], Is.EqualTo(0x02), "Exponent byte");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ReferenceNodeManager array variables update their SourceTimestamp on read.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerArrayVariablesUpdateTimestampOnReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read an array variable from the ReferenceNodeManager (namespace index 2)
|
|
var nodeId = new NodeId("Scalar_Static_Arrays_Byte", 2);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// First read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeFirstRead = DateTimeUtc.Now;
|
|
ReadResponse firstReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(firstReadResponse, Is.Not.Null);
|
|
Assert.That(firstReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(firstReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue firstValue = firstReadResponse.Results[0];
|
|
Assert.That(firstValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(firstValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Array First read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
firstValue.SourceTimestamp, firstValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the timestamp is recent (not startup time)
|
|
Assert.That((long)firstValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeFirstRead.SubtractMilliseconds(1000)),
|
|
"Array SourceTimestamp should be close to the read time, not the server startup time");
|
|
|
|
// Wait a bit to ensure time difference
|
|
await Task.Delay(1500).ConfigureAwait(false);
|
|
|
|
// Second read
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeSecondRead = DateTimeUtc.Now;
|
|
ReadResponse secondReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(secondReadResponse, Is.Not.Null);
|
|
Assert.That(secondReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(secondReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue secondValue = secondReadResponse.Results[0];
|
|
Assert.That(secondValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(secondValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Array Second read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
secondValue.SourceTimestamp, secondValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the second timestamp is more recent than the first
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThan((long)firstValue.SourceTimestamp),
|
|
"Array SourceTimestamp should be updated on each read");
|
|
|
|
// Verify the second timestamp is recent
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeSecondRead.SubtractMilliseconds(1000)),
|
|
"Array SourceTimestamp should be close to the second read time");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update static Nodes, read modify write.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(350)]
|
|
public async Task ReadWriteUpdateNodesAsync()
|
|
{
|
|
// Nodes
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Browse full address space.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(400)]
|
|
[Benchmark]
|
|
public async Task BrowseFullAddressSpaceAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Translate references.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(500)]
|
|
[Benchmark]
|
|
public async Task TranslateBrowsePathAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
if (m_referenceDescriptions.IsNull)
|
|
{
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
_ = await CommonTestWorkers.TranslateBrowsePathWorkerAsync(
|
|
serverTestServices,
|
|
m_referenceDescriptions,
|
|
m_requestHeader,
|
|
m_operationLimits!).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Read a few notifications with Publish.
|
|
/// Delete the monitored item and subscription.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SubscriptionAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
await CommonTestWorkers.SubscriptionTestAsync(serverTestServices, m_requestHeader).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a single session + subscription and raise a single event on the server node.
|
|
/// Validates all the event fields are properly received.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerEventSubscribeTestAsync()
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
|
|
CreateSubscriptionResponse createSubscriptionResponse = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
|
|
uint subscriptionId = createSubscriptionResponse.SubscriptionId;
|
|
|
|
// Build an event filter for the base event type.
|
|
var eventFilter = new EventFilter();
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.EventId));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.EventType));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.SourceNode));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.SourceName));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Time));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Message));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Severity));
|
|
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems = [
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 100,
|
|
DiscardOldest = true,
|
|
Filter = new ExtensionObject(eventFilter)
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse createItemsResponse = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(createItemsResponse.ResponseHeader, createItemsResponse.Results, monitoredItems);
|
|
Assert.AreEqual(1, createItemsResponse.Results.Count);
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[0].StatusCode));
|
|
|
|
// Generate event directly on the server
|
|
IServerInternal serverInternal = m_server.CurrentInstance;
|
|
ISystemContext serverContext = serverInternal.DefaultSystemContext;
|
|
var e = new BaseEventState(null);
|
|
const string eventMessage = "Integration Test Event";
|
|
e.Initialize(
|
|
serverContext,
|
|
serverInternal.ServerObject,
|
|
EventSeverity.Medium,
|
|
new LocalizedText(eventMessage));
|
|
serverInternal.ReportEvent(serverContext, e);
|
|
|
|
// Wait for subscription to deliver the event
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
|
|
// Publish request to get the event notification
|
|
var acknowledgements = new ArrayOf<SubscriptionAcknowledgement>();
|
|
PublishResponse publishResponse = await services.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.IsNotNull(publishResponse.NotificationMessage);
|
|
Assert.IsNotNull(publishResponse.NotificationMessage.NotificationData);
|
|
Assert.IsTrue(publishResponse.NotificationMessage.NotificationData.Count > 0);
|
|
|
|
publishResponse.NotificationMessage.NotificationData[0].TryGetValue(out EventNotificationList? eventNotification);
|
|
Assert.IsNotNull(eventNotification);
|
|
Assert.IsTrue(eventNotification.Events.Count > 0);
|
|
|
|
EventFieldList targetEvent = eventNotification.Events.ToList().FirstOrDefault(
|
|
x => x.EventFields[5].TryGetValue(out LocalizedText lt) && lt.Text == eventMessage)!;
|
|
Assert.IsNotNull(targetEvent, "Did not receive the target event.");
|
|
|
|
ArrayOf<Variant> eventFields = targetEvent.EventFields;
|
|
Assert.AreEqual(7, eventFields.Count); // we requested 7 fields in select clauses
|
|
|
|
Assert.IsFalse(eventFields[0].IsNull); // EventId
|
|
Assert.IsFalse(eventFields[1].IsNull); // EventType
|
|
Assert.IsFalse(eventFields[2].IsNull); // SourceNode
|
|
Assert.IsFalse(eventFields[3].IsNull); // SourceName
|
|
Assert.IsFalse(eventFields[4].IsNull); // Time
|
|
Assert.That(eventFields[5].TryGetValue(out LocalizedText receivedMessage), Is.True); // Message
|
|
Assert.IsFalse(receivedMessage.IsNull);
|
|
Assert.AreEqual(eventMessage, receivedMessage.Text);
|
|
Assert.That(eventFields[6].TryGetValue(out ushort receiveSeverity), Is.True);
|
|
Assert.AreEqual((ushort)EventSeverity.Medium, receiveSeverity); // Severity
|
|
|
|
// Delete subscription
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscriptionId]).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create multiple sessions, each with a subscription.
|
|
/// Close all sessions without deleting subscriptions (abandoning them).
|
|
/// Concurrently delete the abandoned subscriptions through server-owned cleanup.
|
|
/// Verifies that the concurrent dictionary backing abandoned subscriptions
|
|
/// handles parallel access correctly (fix for issue #3612).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task DeleteAbandonedSubscriptionsConcurrentlyAsync()
|
|
{
|
|
const int sessionCount = 5;
|
|
var subscriptionIds = new List<uint>();
|
|
var subscriptionManager = (SubscriptionManager)m_server.CurrentInstance.SubscriptionManager;
|
|
int originalSubscriptionCount = subscriptionManager.GetSubscriptions().Count;
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
|
|
// Create multiple sessions, each with a subscription, then close
|
|
// the session without deleting subscriptions so they become abandoned.
|
|
for (int i = 0; i < sessionCount; i++)
|
|
{
|
|
(RequestHeader header, SecureChannelContext context) =
|
|
await m_server.CreateAndActivateSessionAsync($"AbandonSession_{i}")
|
|
.ConfigureAwait(false);
|
|
|
|
var services = new ServerTestServices(m_server, context);
|
|
header.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<uint> ids = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
services, header, testSet, kQueueSize, -1).ConfigureAwait(false);
|
|
subscriptionIds.AddRange(ids.ToList());
|
|
|
|
// Close session without deleting subscriptions - makes them abandoned
|
|
header.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(context, header, false, RequestLifetime.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
Assert.That(subscriptionIds, Has.Count.EqualTo(sessionCount));
|
|
Assert.That(subscriptionIds, Is.Unique);
|
|
foreach (uint id in subscriptionIds)
|
|
{
|
|
Assert.That(subscriptionManager.TryGetSubscription(id, out ISubscription? subscription), Is.True);
|
|
Assert.That(subscription!.Session, Is.Null);
|
|
}
|
|
Assert.That(subscriptionManager.CaptureAbandonedPublishTimerSnapshot()
|
|
.Select(subscription => subscription.Id), Is.SupersetOf(subscriptionIds));
|
|
|
|
// A session cannot delete another session's abandoned subscription.
|
|
// Server-owned cleanup uses the same null context as ServerInternalData.
|
|
var deleteStart = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
Task<StatusCode>[] deleteTasks =
|
|
[
|
|
.. subscriptionIds.Select(async id =>
|
|
{
|
|
await deleteStart.Task.ConfigureAwait(false);
|
|
return await subscriptionManager.DeleteSubscriptionAsync(null!, id).ConfigureAwait(false);
|
|
})
|
|
];
|
|
deleteStart.SetResult(true);
|
|
StatusCode[] results = await Task.WhenAll(deleteTasks).ConfigureAwait(false);
|
|
|
|
Assert.That(results, Has.Length.EqualTo(sessionCount));
|
|
foreach (StatusCode result in results)
|
|
{
|
|
Assert.That(result, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
foreach (uint id in subscriptionIds)
|
|
{
|
|
Assert.That(subscriptionManager.TryGetSubscription(id, out _), Is.False);
|
|
}
|
|
Assert.That(subscriptionManager.GetSubscriptions(), Has.Count.EqualTo(originalSubscriptionCount));
|
|
Assert.That(subscriptionManager.CaptureAbandonedPublishTimerSnapshot()
|
|
.Select(subscription => subscription.Id).Intersect(subscriptionIds), Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a secondary Session.
|
|
/// Create a subscription with a monitored item.
|
|
/// Close session, but do not delete subscriptions.
|
|
/// Transfer subscription from closed session to the other.
|
|
/// </summary>
|
|
[Theory]
|
|
public async Task TransferSubscriptionSessionClosedAsync(bool sendInitialData, bool useSecurity)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferContext) = await m_server.CreateAndActivateSessionAsync(
|
|
"ClosedSession",
|
|
useSecurity,
|
|
clientApplicationUri: kClientApplicationUri).ConfigureAwait(false);
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
serverTestServices.SecureChannelContext = transferContext;
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
transferRequestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(transferContext, transferRequestHeader, false, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
//restore security context, transfer abandoned subscription
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
await CommonTestWorkers.TransferSubscriptionTestAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
sendInitialData,
|
|
!useSecurity).ConfigureAwait(false);
|
|
|
|
if (useSecurity)
|
|
{
|
|
// subscription was deleted, expect 'BadNoSubscription'
|
|
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
await CommonTestWorkers.VerifySubscriptionTransferredAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
true).ConfigureAwait(false);
|
|
});
|
|
Assert.That(
|
|
sre.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadNoSubscription));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Create a secondary Session.
|
|
/// Transfer subscription with a monitored item from one session to the other.
|
|
/// </summary>
|
|
[Theory]
|
|
public async Task TransferSubscriptionAsync(bool sendInitialData, bool useSecurity)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferSecurityContext) = await m_server.CreateAndActivateSessionAsync(
|
|
"TransferSession",
|
|
useSecurity,
|
|
clientApplicationUri: kClientApplicationUri).ConfigureAwait(false);
|
|
serverTestServices.SecureChannelContext = transferSecurityContext;
|
|
await CommonTestWorkers.TransferSubscriptionTestAsync(
|
|
serverTestServices,
|
|
transferRequestHeader,
|
|
subscriptionIds,
|
|
sendInitialData,
|
|
!useSecurity).ConfigureAwait(false);
|
|
|
|
if (useSecurity)
|
|
{
|
|
//restore security context
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
await CommonTestWorkers.VerifySubscriptionTransferredAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
true).ConfigureAwait(false);
|
|
}
|
|
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(transferSecurityContext, transferRequestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an anonymous Subscription cannot be transferred to a
|
|
/// Session with a different client ApplicationUri.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TransferAnonymousSubscriptionWithDifferentApplicationUriDeniedAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferSecurityContext) =
|
|
await m_server.CreateAndActivateSessionAsync(
|
|
"TransferSessionDifferentApplicationUri",
|
|
useSecurity: true,
|
|
clientApplicationUri:
|
|
"urn:localhost:opcfoundation.org:DifferentServerFixtureClient")
|
|
.ConfigureAwait(false);
|
|
try
|
|
{
|
|
serverTestServices.SecureChannelContext = transferSecurityContext;
|
|
await CommonTestWorkers.TransferSubscriptionTestAsync(
|
|
serverTestServices,
|
|
transferRequestHeader,
|
|
subscriptionIds,
|
|
sendInitialData: false,
|
|
expectAccessDenied: true).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(
|
|
transferSecurityContext,
|
|
transferRequestHeader,
|
|
true,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Call ResendData.
|
|
/// Ensure only a single value per monitored item is returned after ResendData was called.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCase(true, kQueueSize)]
|
|
[TestCase(false, kQueueSize)]
|
|
[TestCase(true, 0U)]
|
|
[TestCase(false, 0U)]
|
|
public async Task ResendDataAsync(bool updateValues, uint queueSize)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
var testSetCollection = CommonTestWorkers
|
|
.NodeIdTestSetStatic.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
.ToList();
|
|
testSetCollection.AddRange(
|
|
CommonTestWorkers.NodeIdTestDataSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris)));
|
|
NodeId[] testSet = [.. testSetCollection];
|
|
|
|
//Re-use method CreateSubscriptionForTransfer to create a subscription
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
queueSize,
|
|
0).ConfigureAwait(false);
|
|
|
|
(RequestHeader resendDataRequestHeader, SecureChannelContext resendDataSecurityContext) =
|
|
await m_server.CreateAndActivateSessionAsync("ResendData").ConfigureAwait(false);
|
|
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
// After the ResendData call there will be data to publish again
|
|
ArrayOf<CallMethodRequest> nodesToCall = await ResendDataCallAsync(
|
|
StatusCodes.Good,
|
|
subscriptionIds).ConfigureAwait(false);
|
|
|
|
Thread.Sleep(1000);
|
|
|
|
// Make sure publish queue becomes empty by consuming it
|
|
Assert.That(subscriptionIds.Count, Is.EqualTo(1));
|
|
|
|
// Issue a Publish request
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements = [];
|
|
PublishResponse publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.EqualTo(1));
|
|
|
|
// Validate nothing to publish a few times
|
|
const int timesToCallPublish = 3;
|
|
for (int i = 0; i < timesToCallPublish; i++)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
}
|
|
|
|
// Validate ResendData method call returns error from different session contexts
|
|
|
|
// call ResendData method from different session context
|
|
resendDataRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
CallResponse callResponse = await m_server.CallAsync(
|
|
resendDataSecurityContext,
|
|
resendDataRequestHeader,
|
|
nodesToCall, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
|
|
Assert.That(callResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
|
|
ServerFixtureUtils.ValidateResponse(callResponse.ResponseHeader, callResponse.Results, nodesToCall);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
callResponse.DiagnosticInfos,
|
|
nodesToCall,
|
|
callResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
|
|
// Still nothing to publish since previous ResendData call did not execute
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
|
|
if (updateValues)
|
|
{
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
|
|
// fill queues, but only a single value per resend publish shall be returned
|
|
for (int i = 1; i < queueSize; i++)
|
|
{
|
|
//If sampling groups are used, samplingInterval needs to be waited before values are queued
|
|
if (m_fixture.UseSamplingGroupsInReferenceNodeManager)
|
|
{
|
|
await Task.Delay((int)(100.0 * 1.7)).ConfigureAwait(false);
|
|
}
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
}
|
|
|
|
// Wait a bit to ensure that the server has time to queue the values
|
|
await Task.Delay(1000).ConfigureAwait(false);
|
|
}
|
|
|
|
// call ResendData method from the same session context
|
|
await ResendDataCallAsync(StatusCodes.Good, subscriptionIds).ConfigureAwait(false);
|
|
|
|
// Data should be available for publishing now
|
|
int totalNotifications = await CollectNotificationsAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
acknowledgements,
|
|
subscriptionIds[0],
|
|
testSet.Length).ConfigureAwait(false);
|
|
|
|
Assert.That(totalNotifications, Is.EqualTo(testSet.Length),
|
|
"One MonitoredItemNotification should be returned for each Node present in the TestSet");
|
|
|
|
await Task.Delay(1000).ConfigureAwait(false);
|
|
|
|
if (updateValues && queueSize > 1)
|
|
{
|
|
// remaining queue Data should be sent in this publish
|
|
int expectedCount = testSet.Length * ((int)queueSize - 1);
|
|
totalNotifications = await CollectNotificationsAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
acknowledgements,
|
|
subscriptionIds[0],
|
|
expectedCount).ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
totalNotifications,
|
|
Is.EqualTo(expectedCount).Within(testSet.Length));
|
|
}
|
|
|
|
// Call ResendData method with invalid subscription Id
|
|
await ResendDataCallAsync(StatusCodes.BadSubscriptionIdInvalid, [subscriptionIds[^1] + 20]).ConfigureAwait(false);
|
|
|
|
// Nothing to publish since previous ResendData call did not execute
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
|
|
resendDataRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(resendDataSecurityContext, resendDataRequestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<int> CollectNotificationsAsync(
|
|
ServerTestServices serverTestServices,
|
|
RequestHeader requestHeader,
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements,
|
|
uint subscriptionId,
|
|
int expectedCount)
|
|
{
|
|
int totalNotifications = 0;
|
|
int retries = 50;
|
|
while (totalNotifications < expectedCount && retries-- > 0)
|
|
{
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
PublishResponse publishResponse = await serverTestServices.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionId));
|
|
|
|
if (publishResponse.NotificationMessage.NotificationData.Count > 0)
|
|
{
|
|
// acknowledge the notification
|
|
acknowledgements =
|
|
[
|
|
new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishResponse.NotificationMessage.SequenceNumber
|
|
}
|
|
];
|
|
|
|
foreach (ExtensionObject item in publishResponse.NotificationMessage.NotificationData)
|
|
{
|
|
if (item.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
totalNotifications += dcn.MonitoredItems.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalNotifications >= expectedCount)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (!publishResponse.MoreNotifications)
|
|
{
|
|
// Wait for potential background processing
|
|
await Task.Delay(100).ConfigureAwait(false);
|
|
}
|
|
}
|
|
return totalNotifications;
|
|
}
|
|
|
|
private async Task<ArrayOf<CallMethodRequest>> ResendDataCallAsync(
|
|
StatusCode expectedStatus,
|
|
ArrayOf<uint> subscriptionIds)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Find the ResendData method
|
|
ArrayOf<CallMethodRequest> nodesToCall = subscriptionIds
|
|
.ConvertAll(subscriptionId => new CallMethodRequest
|
|
{
|
|
ObjectId = ObjectIds.Server,
|
|
MethodId = MethodIds.Server_ResendData,
|
|
InputArguments = [new Variant(subscriptionId)]
|
|
});
|
|
|
|
//call ResendData method with subscription ids
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
CallResponse callResponse = await m_server.CallAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
nodesToCall, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(callResponse.Results[0].StatusCode, Is.EqualTo(expectedStatus));
|
|
ServerFixtureUtils.ValidateResponse(callResponse.ResponseHeader, callResponse.Results, nodesToCall);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
callResponse.DiagnosticInfos,
|
|
nodesToCall,
|
|
callResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
|
|
return nodesToCall;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read Values of NodeIds, determine types, write back new random values.
|
|
/// </summary>
|
|
/// <param name="testSet">The nodeIds to modify.</param>
|
|
private async Task UpdateValuesAsync(NodeId[] testSet)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read values
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
var nodesToRead = testSet
|
|
.Select(nodeId => new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
})
|
|
.ToArrayOf();
|
|
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(testSet.Length));
|
|
|
|
var modifiedValues = new List<DataValue>();
|
|
foreach (DataValue dataValue in readResponse.Results)
|
|
{
|
|
TypeInfo typeInfo = dataValue.WrappedValue.TypeInfo;
|
|
Assert.That(typeInfo.IsUnknown, Is.False);
|
|
Variant value = m_generator.GetRandomScalar(typeInfo.BuiltInType);
|
|
modifiedValues.Add(new DataValue(value));
|
|
}
|
|
|
|
int ii = 0;
|
|
var nodesToWrite = testSet
|
|
.Select(nodeId => new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = modifiedValues[ii++]
|
|
})
|
|
.ToArrayOf();
|
|
|
|
// Write Nodes
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
writeResponse.DiagnosticInfos,
|
|
writeResponse.Results,
|
|
writeResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an engineering-unit change emits a semantic-change event and a data notification with
|
|
/// SemanticsChanged.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SemanticChangeNotificationTestAsync()
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
|
|
// Step 1: Create a subscription
|
|
CreateSubscriptionResponse createSubscriptionResponse = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
|
|
uint subscriptionId = createSubscriptionResponse.SubscriptionId;
|
|
|
|
// We monitor the server EventNotifier for SemanticChangeEventType
|
|
var serverObject = new ReadValueId { NodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier };
|
|
var eventFilter = new EventFilter();
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.SemanticChangeEventType,
|
|
QualifiedName.From(BrowseNames.EventId));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.SemanticChangeEventType,
|
|
QualifiedName.From(BrowseNames.Changes));
|
|
|
|
// Monitor a variable from ReferenceNodeManager (AnalogItem with EngineeringUnits)
|
|
var nodeId = new NodeId("DataAccess_AnalogType_Double",
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(Quickstarts.ReferenceServer.Namespaces.ReferenceServer));
|
|
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems =
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = serverObject,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true,
|
|
Filter = new ExtensionObject(eventFilter)
|
|
}
|
|
},
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 2,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse createItemsResponse = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(createItemsResponse.ResponseHeader, createItemsResponse.Results, monitoredItems);
|
|
Assert.AreEqual(2, createItemsResponse.Results.Count);
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[0].StatusCode));
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[1].StatusCode));
|
|
|
|
// Initial publish to clear any initial data change notifications
|
|
var acknowledgements = new ArrayOf<SubscriptionAcknowledgement>();
|
|
PublishResponse publishResponse = await services.PublishAsync(requestHeader, acknowledgements).ConfigureAwait(false);
|
|
|
|
if (publishResponse.NotificationMessage.NotificationData.Count > 0)
|
|
{
|
|
acknowledgements =
|
|
[
|
|
new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishResponse.NotificationMessage.SequenceNumber
|
|
}
|
|
];
|
|
}
|
|
|
|
// Step 2: Write a new value to a semantic property (EngineeringUnits)
|
|
var euNodeId = new NodeId("DataAccess_AnalogType_DataAccess_AnalogType_Double_EngineeringUnits",
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(Quickstarts.ReferenceServer.Namespaces.ReferenceServer));
|
|
var engUnits = new EUInformation("V", "volt", "http://www.opcfoundation.org/UA/units/un/cefact")
|
|
{
|
|
UnitId = 4274026 // "V"
|
|
};
|
|
|
|
ArrayOf<WriteValue> writeValues = new WriteValue[]
|
|
{
|
|
new() {
|
|
NodeId = euNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new ExtensionObject(engUnits))
|
|
}
|
|
}.ToArrayOf();
|
|
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
writeValues, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, writeValues);
|
|
Assert.IsTrue(StatusCode.IsGood(writeResponse.Results[0]));
|
|
|
|
// Wait for subscription to deliver the event and data change notification
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
|
|
// Step 3: Issue publish to receive DataChangeNotification and SemanticChangeEvent
|
|
publishResponse = await services.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.IsNotNull(publishResponse.NotificationMessage);
|
|
Assert.IsNotNull(publishResponse.NotificationMessage.NotificationData);
|
|
Assert.IsTrue(publishResponse.NotificationMessage.NotificationData.Count == 2, "One Event and One DataChangeNotification expected.");
|
|
|
|
bool dataChangeReceived = false;
|
|
bool semanticsChangedBitSet = false;
|
|
bool eventReceived = false;
|
|
|
|
foreach (ExtensionObject data in publishResponse.NotificationMessage.NotificationData)
|
|
{
|
|
if (data.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == 2)
|
|
{
|
|
dataChangeReceived = true;
|
|
if (item.Value.StatusCode.SemanticsChanged)
|
|
{
|
|
semanticsChangedBitSet = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (data.TryGetValue(out EventNotificationList? enl))
|
|
{
|
|
foreach (EventFieldList e in enl.Events)
|
|
{
|
|
if (e.ClientHandle == 1 && e.EventFields.Count >= 2)
|
|
{
|
|
bool success = e.EventFields[1]
|
|
.TryGetStructure(out ArrayOf<SemanticChangeStructureDataType> semanticChangeEvent);
|
|
|
|
if (success && !semanticChangeEvent.IsNull && semanticChangeEvent.Count > 0)
|
|
{
|
|
foreach (SemanticChangeStructureDataType change in semanticChangeEvent)
|
|
{
|
|
if (change.Affected == nodeId)
|
|
{
|
|
eventReceived = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.IsTrue(dataChangeReceived, "Did not receive DataChangeNotification.");
|
|
Assert.IsTrue(semanticsChangedBitSet, "SemanticsChanged bit is not set.");
|
|
Assert.IsTrue(eventReceived, "Did not receive SemanticChangeEvent.");
|
|
|
|
// Delete subscription
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscriptionId]).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that historical EventNotifier bits are set on the actual
|
|
/// historical notifier rather than inferred on the Server object.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoricalEventNotifierBitsAreNodeSpecificAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read Server object EventNotifier attribute
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.EventNotifier,
|
|
NodeId = ObjectIds.Server
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Both,
|
|
readIdCollection, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
|
Assert.That(readResponse.Results[0].WrappedValue.IsNull, Is.False);
|
|
|
|
byte eventNotifier = (byte)readResponse.Results[0].WrappedValue;
|
|
|
|
// Read history capabilities
|
|
ArrayOf<ReadValueId> historyCapabilitiesReadIds =
|
|
[
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Both,
|
|
historyCapabilitiesReadIds, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, historyCapabilitiesReadIds);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
bool accessHistoryEventsCapability =
|
|
!readResponse.Results[0].WrappedValue.IsNull &&
|
|
(bool)readResponse.Results[0].WrappedValue;
|
|
bool accessHistoryDataCapability =
|
|
!readResponse.Results[1].WrappedValue.IsNull &&
|
|
(bool)readResponse.Results[1].WrappedValue;
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Server EventNotifier: {EventNotifier}", eventNotifier);
|
|
logger.LogInformation("AccessHistoryEventsCapability: {AccessHistoryEventsCapability}", accessHistoryEventsCapability);
|
|
logger.LogInformation("AccessHistoryDataCapability: {AccessHistoryDataCapability}", accessHistoryDataCapability);
|
|
}
|
|
|
|
Assert.That(
|
|
eventNotifier & EventNotifiers.HistoryRead,
|
|
Is.Zero,
|
|
"The Server object is not itself configured as a historical event notifier.");
|
|
if (accessHistoryEventsCapability)
|
|
{
|
|
ArrayOf<ReadValueId> notifierRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.EventNotifier,
|
|
NodeId = new NodeId("CTT", 2)
|
|
}
|
|
];
|
|
ReadResponse notifierResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Neither,
|
|
notifierRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(
|
|
(byte)notifierResponse.Results[0].WrappedValue &
|
|
EventNotifiers.HistoryRead,
|
|
Is.Not.Zero,
|
|
"The configured CTT historical event notifier must expose HistoryRead.");
|
|
}
|
|
|
|
// Verify SubscribeToEvents bit is set (Server object should always support events)
|
|
Assert.That(eventNotifier & EventNotifiers.SubscribeToEvents,
|
|
Is.Not.Zero,
|
|
"Server EventNotifier should have SubscribeToEvents bit set");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that ServerStatus children have matching SourceTimestamp and ServerTimestamp.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerStatusTimestampsMatchAsync()
|
|
{
|
|
ILogger<ReferenceServerTests> logger = m_telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read ServerStatus children (CurrentTime, StartTime, State, etc.)
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_CurrentTime, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_StartTime, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(3));
|
|
|
|
// Verify that SourceTimestamp and ServerTimestamp are equal for all ServerStatus children
|
|
for (int i = 0; i < readResponse.Results.Count; i++)
|
|
{
|
|
DataValue result = readResponse.Results[i];
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation(
|
|
"NodeId: {NodeId}, SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
nodesToRead[i].NodeId,
|
|
result.SourceTimestamp,
|
|
result.ServerTimestamp);
|
|
}
|
|
|
|
Assert.That(result.ServerTimestamp, Is.EqualTo(result.SourceTimestamp),
|
|
$"SourceTimestamp and ServerTimestamp should be equal for {nodesToRead[i].NodeId}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the Int32Value node (ns=3;i=2808) allows historical data access.
|
|
/// Verifies the fix for issue #2520 where the node was marked as historizing
|
|
/// but history read operations returned BadHistoryOperationUnsupported.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoryReadInt32ValueNodeAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Get the NodeId for Data_Dynamic_Scalar_Int32Value
|
|
var int32ValueNodeId = new NodeId(
|
|
TestData.Variables.Data_Dynamic_Scalar_Int32Value,
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(TestData.Namespaces.TestData));
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Testing history read for Int32Value node: {NodeId}", int32ValueNodeId);
|
|
}
|
|
|
|
// Verify the node has Historizing attribute set to true
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.Historizing,
|
|
NodeId = int32ValueNodeId
|
|
},
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.AccessLevel,
|
|
NodeId = int32ValueNodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIdCollection,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
bool historizing = (bool)readResponse.Results[0].WrappedValue;
|
|
byte accessLevel = (byte)readResponse.Results[1].WrappedValue;
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Historizing: {Historizing}, AccessLevel: {AccessLevel}", historizing, accessLevel);
|
|
}
|
|
|
|
Assert.That(historizing, Is.True, "Int32Value node should have Historizing=true");
|
|
Assert.That(accessLevel & AccessLevels.HistoryRead,
|
|
Is.Not.Zero,
|
|
"Int32Value node should have HistoryRead access level");
|
|
|
|
// Perform a history read operation
|
|
var historyReadDetails = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = DateTimeUtc.Now.SubtractMilliseconds(60 * 60 * 1000),
|
|
EndTime = DateTimeUtc.Now,
|
|
NumValuesPerNode = 10,
|
|
IsReadModified = false,
|
|
ReturnBounds = false
|
|
};
|
|
|
|
ArrayOf<HistoryReadValueId> nodesToRead =
|
|
[
|
|
new HistoryReadValueId
|
|
{
|
|
NodeId = int32ValueNodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse historyReadResponse = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
new ExtensionObject(historyReadDetails),
|
|
TimestampsToReturn.Both,
|
|
false,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(historyReadResponse.ResponseHeader, historyReadResponse.Results, nodesToRead);
|
|
Assert.That(historyReadResponse.Results.Count, Is.EqualTo(1));
|
|
|
|
HistoryReadResult result = historyReadResponse.Results[0];
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("History read StatusCode: {StatusCode}", result.StatusCode);
|
|
}
|
|
|
|
// The result should be Good or GoodMoreData (if there are more values)
|
|
Assert.That(StatusCode.IsGood(result.StatusCode),
|
|
Is.True,
|
|
$"History read should succeed, but got: {result.StatusCode}");
|
|
Assert.That(result.HistoryData.IsNull, Is.False, "HistoryData should not be null");
|
|
|
|
// Verify we got HistoryData back
|
|
if (result.HistoryData.TryGetValue(out HistoryData? historyData))
|
|
{
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Retrieved {Count} history values", historyData.DataValues.Count);
|
|
}
|
|
Assert.That(historyData.DataValues.IsNull, Is.False, "DataValues should not be null");
|
|
Assert.That(historyData.DataValues.Count, Is.GreaterThan(0), "Should have at least one historical value");
|
|
|
|
// Verify the data values have proper timestamps
|
|
foreach (DataValue dataValue in historyData.DataValues)
|
|
{
|
|
Assert.That(dataValue.IsNull, Is.False, "DataValue should not be null");
|
|
Assert.That(dataValue.ServerTimestamp,
|
|
Is.Not.EqualTo(DateTimeUtc.MinValue),
|
|
"DataValue should have a valid ServerTimestamp");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Assert.Fail("HistoryData body should be of type HistoryData");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that a HistoryRead on the <c>AccessRights_AccessAll_RO_NotUser</c>
|
|
/// node is denied for the current (anonymous) user. The node advertises
|
|
/// HistoryRead in its AccessLevel but its UserAccessLevel is None, so the
|
|
/// server must return BadUserAccessDenied instead of the historical data.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoryReadUserAccessDeniedNodeAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId("AccessRights_AccessAll_RO_NotUser", namespaceIndex);
|
|
|
|
// Sanity check: the node advertises HistoryRead in AccessLevel but the
|
|
// current user is not granted HistoryRead via UserAccessLevel.
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.AccessLevel,
|
|
NodeId = nodeId
|
|
},
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.UserAccessLevel,
|
|
NodeId = nodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIdCollection,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
byte accessLevel = (byte)readResponse.Results[0].WrappedValue;
|
|
byte userAccessLevel = (byte)readResponse.Results[1].WrappedValue;
|
|
|
|
Assert.That(accessLevel & AccessLevels.HistoryRead,
|
|
Is.Not.Zero,
|
|
"Node should advertise HistoryRead in AccessLevel");
|
|
Assert.That(userAccessLevel & AccessLevels.HistoryRead,
|
|
Is.Zero,
|
|
"Node should not grant HistoryRead to the current user via UserAccessLevel");
|
|
|
|
// Perform a history read operation; it must be denied for the current user.
|
|
var historyReadDetails = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = DateTimeUtc.Now.SubtractMilliseconds(60 * 60 * 1000),
|
|
EndTime = DateTimeUtc.Now,
|
|
NumValuesPerNode = 10,
|
|
IsReadModified = false,
|
|
ReturnBounds = false
|
|
};
|
|
|
|
ArrayOf<HistoryReadValueId> nodesToRead =
|
|
[
|
|
new HistoryReadValueId
|
|
{
|
|
NodeId = nodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse historyReadResponse = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
new ExtensionObject(historyReadDetails),
|
|
TimestampsToReturn.Both,
|
|
false,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(historyReadResponse.ResponseHeader, historyReadResponse.Results, nodesToRead);
|
|
Assert.That(historyReadResponse.Results.Count, Is.EqualTo(1));
|
|
|
|
HistoryReadResult result = historyReadResponse.Results[0];
|
|
|
|
Assert.That(result.StatusCode.Code,
|
|
Is.EqualTo(StatusCodes.BadUserAccessDenied),
|
|
$"History read on a node without user HistoryRead access should be denied, but got: {result.StatusCode}");
|
|
Assert.That(result.HistoryData.IsNull,
|
|
Is.True,
|
|
"No historical data should be returned when access is denied");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test provisioning mode - server should start with limited namespace.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ProvisioningModeTestAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
// start Ref server in provisioning mode
|
|
var fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = false,
|
|
OperationLimits = false,
|
|
DurableSubscriptionsEnabled = false,
|
|
AutoAccept = true,
|
|
ProvisioningMode = true
|
|
};
|
|
|
|
ReferenceServer server = await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
// Verify provisioning mode is enabled
|
|
Assert.That(server.ProvisioningMode, Is.True, "Server should be in provisioning mode");
|
|
|
|
// OPC 10000-12 Annex G.2: the application setup state is reported
|
|
// as ServerState NoConfiguration and through InApplicationSetup.
|
|
Assert.That(server.CurrentInstance.CurrentState, Is.EqualTo(ServerState.NoConfiguration));
|
|
Assert.That(server.CurrentInstance.IsRunning, Is.True, "NoConfiguration still serves requests");
|
|
var configurationManager = (ConfigurationNodeManager)server.CurrentInstance.ConfigurationNodeManager;
|
|
ServerConfigurationState configuration = configurationManager
|
|
.FindPredefinedNode<ServerConfigurationState>(ObjectIds.ServerConfiguration);
|
|
Assert.That(configuration.InApplicationSetup, Is.Not.Null);
|
|
Assert.That(configuration.InApplicationSetup.Value, Is.True);
|
|
|
|
// Get endpoints - in provisioning mode, anonymous authentication should not be allowed
|
|
ArrayOf<EndpointDescription> endpoints = server.GetEndpoints();
|
|
Assert.That(endpoints.IsNull, Is.False);
|
|
Assert.That(endpoints.Count, Is.GreaterThan(0), "Server should have endpoints");
|
|
|
|
// Check that anonymous token policy is not present for at least one endpoint
|
|
bool hasEndpointWithoutAnonymous = false;
|
|
foreach (EndpointDescription endpoint in endpoints)
|
|
{
|
|
bool hasAnonymous = endpoint.UserIdentityTokens.Contains(
|
|
policy => policy.TokenType == UserTokenType.Anonymous);
|
|
if (!hasAnonymous)
|
|
{
|
|
hasEndpointWithoutAnonymous = true;
|
|
break;
|
|
}
|
|
}
|
|
Assert.That(hasEndpointWithoutAnonymous,
|
|
Is.True,
|
|
"At least one endpoint should not allow anonymous authentication in provisioning mode");
|
|
|
|
// Clean up
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ArrayItemType sub-type nodes are accessible and readable.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ArrayItemTypeNodesExistAndReadableAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "YArray", new NodeId("DataAccess_ArrayItemType_YArray", 2) },
|
|
{ "XYArray", new NodeId("DataAccess_ArrayItemType_XYArray", 2) },
|
|
{ "Image", new NodeId("DataAccess_ArrayItemType_Image", 2) },
|
|
{ "Cube", new NodeId("DataAccess_ArrayItemType_Cube", 2) },
|
|
{ "NDimension", new NodeId("DataAccess_ArrayItemType_NDimension", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ValueRank }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Value read of {name} should succeed");
|
|
Assert.That(readResponse.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"DataType read of {name} should succeed");
|
|
Assert.That(readResponse.Results[2].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"ValueRank read of {name} should succeed");
|
|
|
|
TestContext.Out.WriteLine("{0}: DataType={1}, ValueRank={2}",
|
|
name,
|
|
readResponse.Results[1].WrappedValue,
|
|
readResponse.Results[2].WrappedValue);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that TriggerNode01 and TriggerNode02 exist, are readable and writable,
|
|
/// and that writing to them fires a BaseEvent.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TriggerNodesFiringEventsOnWriteAsync()
|
|
{
|
|
var triggerNode01 = new NodeId("NodeIds_Events_TriggerNode01", 2);
|
|
var triggerNode02 = new NodeId("NodeIds_Events_TriggerNode02", 2);
|
|
DateTimeUtc eventWindowStart = DateTime.UtcNow.AddSeconds(-1);
|
|
|
|
// Verify both nodes exist and are readable
|
|
foreach (NodeId nodeId in new[] { triggerNode01, triggerNode02 })
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Read of trigger node {nodeId} should succeed");
|
|
}
|
|
|
|
// Verify both nodes are writable
|
|
foreach (NodeId nodeId in new[] { triggerNode01, triggerNode02 })
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<WriteValue> nodesToWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(42))
|
|
}
|
|
];
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
nodesToWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
Assert.That(writeResponse.Results[0], Is.EqualTo(StatusCodes.Good),
|
|
$"Write to trigger node {nodeId} should succeed and fire an event");
|
|
}
|
|
|
|
var filter = new EventFilter();
|
|
filter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
BrowseNames.Message,
|
|
Attributes.Value);
|
|
bool captured = false;
|
|
for (int attempt = 0; attempt < 20 && !captured; attempt++)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse response = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
new ExtensionObject(new ReadEventDetails
|
|
{
|
|
StartTime = eventWindowStart,
|
|
EndTime = DateTime.UtcNow.AddSeconds(1),
|
|
NumValuesPerNode = 100,
|
|
Filter = filter
|
|
}),
|
|
TimestampsToReturn.Neither,
|
|
false,
|
|
[new HistoryReadValueId { NodeId = new NodeId("CTT", 2) }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
if (response.Results.Count == 1 &&
|
|
StatusCode.IsGood(response.Results[0].StatusCode))
|
|
{
|
|
if (ExtensionObject.ToEncodeable(
|
|
response.Results[0].HistoryData) is not HistoryEvent historyEvent)
|
|
{
|
|
continue;
|
|
}
|
|
foreach (HistoryEventFieldList historicalEvent in
|
|
historyEvent.Events)
|
|
{
|
|
if (historicalEvent.EventFields.Count == 1 &&
|
|
historicalEvent.EventFields[0].TryGetValue(
|
|
out LocalizedText message) &&
|
|
message.Text?.Contains(
|
|
"Trigger event",
|
|
StringComparison.Ordinal) == true)
|
|
{
|
|
captured = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!captured)
|
|
{
|
|
await Task.Delay(50).ConfigureAwait(false);
|
|
}
|
|
}
|
|
Assert.That(
|
|
captured,
|
|
Is.True,
|
|
"Writing a trigger node must publish and archive the event on the CTT notifier.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the Variant arrays (1D and 2D) are accessible with proper NodeIds.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task VariantArrayNodesExistAndReadableAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "Scalar_Static_Arrays_Variant", new NodeId("Scalar_Static_Arrays_Variant", 2) },
|
|
{ "Scalar_Static_Arrays2D_Variant", new NodeId("Scalar_Static_Arrays2D_Variant", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Read of {name} should succeed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the Variant matrix can be encoded with neighboring results in a batched Read.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task VariantMatrixDoesNotPoisonBatchedReadEncodingAsync()
|
|
{
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = new NodeId("Scalar_Static_Arrays2D_Int32", 2),
|
|
AttributeId = Attributes.Value
|
|
},
|
|
new ReadValueId
|
|
{
|
|
NodeId = new NodeId("Scalar_Static_Arrays2D_Variant", 2),
|
|
AttributeId = Attributes.Value
|
|
},
|
|
new ReadValueId
|
|
{
|
|
NodeId = new NodeId("Scalar_Static_Arrays2D_UInt32", 2),
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int i = 0; i < readResponse.Results.Count; i++)
|
|
{
|
|
Assert.That(readResponse.Results[i].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
using var stream = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(
|
|
stream,
|
|
m_server.CurrentInstance.MessageContext,
|
|
leaveOpen: true))
|
|
{
|
|
encoder.WriteDataValueArray(null, readResponse.Results);
|
|
}
|
|
|
|
stream.Position = 0;
|
|
using var decoder = new BinaryDecoder(stream, m_server.CurrentInstance.MessageContext);
|
|
ArrayOf<DataValue> decodedResults = decoder.ReadDataValueArray(null);
|
|
|
|
Assert.That(decodedResults, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int i = 0; i < decodedResults.Count; i++)
|
|
{
|
|
Assert.That(decodedResults[i].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(decodedResults[i].WrappedValue, Is.EqualTo(readResponse.Results[i].WrappedValue));
|
|
}
|
|
MatrixOf<Variant> matrix = decodedResults[1].WrappedValue.GetVariantMatrix();
|
|
Assert.That(matrix.Dimensions, Has.Length.EqualTo(2));
|
|
Assert.That(matrix.Dimensions[0], Is.EqualTo(5));
|
|
Assert.That(matrix.Dimensions[1], Is.EqualTo(5));
|
|
Assert.That(matrix.Count, Is.EqualTo(25));
|
|
// CTT multi-dimensional IndexRange tests need scalar elements of a simple type.
|
|
foreach (Variant element in matrix.Span)
|
|
{
|
|
Assert.That(element.TypeInfo.ValueRank, Is.EqualTo(ValueRanks.Scalar));
|
|
Assert.That(element.TypeInfo.BuiltInType, Is.LessThan(BuiltInType.XmlElement));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies whole-matrix writes and valid multi-dimensional NumericRange reads and writes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task MatrixReadWriteAndNumericRangeAsync()
|
|
{
|
|
var nodeId = new NodeId("Scalar_Static_Arrays2D_Int32", 2);
|
|
ArrayOf<ReadValueId> wholeMatrixRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
ReadResponse originalRead = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
wholeMatrixRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(originalRead.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Variant originalValue = originalRead.Results[0].WrappedValue;
|
|
|
|
try
|
|
{
|
|
MatrixOf<int> matrix = new int[5, 5]
|
|
{
|
|
{ 1, 2, 3, 4, 5 },
|
|
{ 6, 7, 8, 9, 10 },
|
|
{ 11, 12, 13, 14, 15 },
|
|
{ 16, 17, 18, 19, 20 },
|
|
{ 21, 22, 23, 24, 25 }
|
|
};
|
|
ArrayOf<WriteValue> wholeMatrixWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(Variant.From(matrix))
|
|
}
|
|
];
|
|
|
|
WriteResponse wholeWriteResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
wholeMatrixWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(wholeWriteResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
|
|
ArrayOf<ReadValueId> rowRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
IndexRange = "1,0:2"
|
|
}
|
|
];
|
|
ReadResponse rowReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
rowRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
DataValue rowValue = rowReadResponse.Results[0];
|
|
Assert.That(rowValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(rowValue.SourceTimestamp.IsNull, Is.False);
|
|
MatrixOf<int> row = rowValue.WrappedValue.GetInt32Matrix();
|
|
Assert.That(row.Dimensions, Has.Length.EqualTo(2));
|
|
Assert.That(row.Dimensions[0], Is.EqualTo(1));
|
|
Assert.That(row.Dimensions[1], Is.EqualTo(3));
|
|
Assert.That(row.Count, Is.EqualTo(3));
|
|
Assert.That(row.Span[0], Is.EqualTo(6));
|
|
Assert.That(row.Span[1], Is.EqualTo(7));
|
|
Assert.That(row.Span[2], Is.EqualTo(8));
|
|
|
|
MatrixOf<int> replacement = new int[1, 2] { { 40, 50 } };
|
|
ArrayOf<WriteValue> rangeWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
IndexRange = "1,1:2",
|
|
Value = new DataValue(Variant.From(replacement))
|
|
}
|
|
];
|
|
WriteResponse rangeWriteResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
rangeWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(rangeWriteResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
|
|
ReadResponse updatedRowReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
rowRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
DataValue updatedRowValue = updatedRowReadResponse.Results[0];
|
|
Assert.That(updatedRowValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
MatrixOf<int> updatedRow = updatedRowValue.WrappedValue.GetInt32Matrix();
|
|
Assert.That(updatedRow.Dimensions, Has.Length.EqualTo(2));
|
|
Assert.That(updatedRow.Dimensions[0], Is.EqualTo(1));
|
|
Assert.That(updatedRow.Dimensions[1], Is.EqualTo(3));
|
|
Assert.That(updatedRow.Count, Is.EqualTo(3));
|
|
Assert.That(updatedRow.Span[0], Is.EqualTo(6));
|
|
Assert.That(updatedRow.Span[1], Is.EqualTo(40));
|
|
Assert.That(updatedRow.Span[2], Is.EqualTo(50));
|
|
}
|
|
finally
|
|
{
|
|
ArrayOf<WriteValue> restoreWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(originalValue)
|
|
}
|
|
];
|
|
WriteResponse restoreResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
restoreWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(restoreResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that Enumeration and Image type scalar nodes are accessible.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ScalarStaticEnumerationAndImageNodesExistAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "Enumeration", new NodeId("Scalar_Static_Enumeration", 2) },
|
|
{ "Image", new NodeId("Scalar_Static_Image", 2) },
|
|
{ "ImageBMP", new NodeId("Scalar_Static_ImageBMP", 2) },
|
|
{ "ImageGIF", new NodeId("Scalar_Static_ImageGIF", 2) },
|
|
{ "ImageJPG", new NodeId("Scalar_Static_ImageJPG", 2) },
|
|
{ "ImagePNG", new NodeId("Scalar_Static_ImagePNG", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Value read of {name} should succeed");
|
|
Assert.That(readResponse.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"DataType read of {name} should succeed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the HasReferenceTypeAndSubType node exists and has the expected references.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferencesHasReferenceTypeAndSubTypePreservesBrowseReferencesAsync()
|
|
{
|
|
int namespaceIndex = m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(0));
|
|
var nodeId = new NodeId("References_HasReferenceTypeAndSubType", (ushort)namespaceIndex);
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
"Read of References_HasReferenceTypeAndSubType should succeed");
|
|
|
|
var instructionsId = new NodeId("Scalar_Instructions", (ushort)namespaceIndex);
|
|
var booleanId = new NodeId("Scalar_Static_Boolean", (ushort)namespaceIndex);
|
|
ArrayOf<BrowseDescription> nodesToBrowse =
|
|
[
|
|
Describe(nodeId, ReferenceTypeIds.HasComponent, BrowseDirection.Forward),
|
|
Describe(nodeId, ReferenceTypeIds.HasComponent, BrowseDirection.Forward, includeSubtypes: true),
|
|
Describe(instructionsId, ReferenceTypeIds.HasComponent, BrowseDirection.Inverse),
|
|
Describe(booleanId, ReferenceTypeIds.HasOrderedComponent, BrowseDirection.Inverse),
|
|
Describe(nodeId, ReferenceTypeIds.References, BrowseDirection.Both, includeSubtypes: true),
|
|
Describe(instructionsId, ReferenceTypeIds.Organizes, BrowseDirection.Inverse),
|
|
Describe(booleanId, ReferenceTypeIds.Organizes, BrowseDirection.Inverse)
|
|
];
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
null,
|
|
0,
|
|
nodesToBrowse,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(response.ResponseHeader, response.Results, nodesToBrowse);
|
|
for (int index = 0; index < response.Results.Count; index++)
|
|
{
|
|
Assert.That(response.Results[index].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(response.Results[index].ContinuationPoint.IsEmpty, Is.True);
|
|
}
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(response.Results[0].References.Count, Is.EqualTo(1));
|
|
Assert.That(response.Results[1].References.Count, Is.EqualTo(2));
|
|
Assert.That(response.Results[2].References.Count, Is.EqualTo(1));
|
|
Assert.That(response.Results[3].References.Count, Is.EqualTo(1));
|
|
});
|
|
ReferenceDescription component = response.Results[0].References[0];
|
|
ReferenceDescription orderedComponent = response.Results[1].References.ToArray()!
|
|
.Single(reference => reference.ReferenceTypeId == ReferenceTypeIds.HasOrderedComponent);
|
|
ReferenceDescription[] allReferences = response.Results[4].References.ToArray()!;
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(component.ReferenceTypeId, Is.EqualTo(ReferenceTypeIds.HasComponent));
|
|
Assert.That(component.IsForward, Is.True);
|
|
Assert.That(component.NodeId, Is.EqualTo(new ExpandedNodeId(instructionsId)));
|
|
Assert.That(orderedComponent.IsForward, Is.True);
|
|
Assert.That(orderedComponent.NodeId, Is.EqualTo(new ExpandedNodeId(booleanId)));
|
|
Assert.That(response.Results[2].References[0].IsForward, Is.False);
|
|
Assert.That(response.Results[2].References[0].NodeId, Is.EqualTo(new ExpandedNodeId(nodeId)));
|
|
Assert.That(response.Results[3].References[0].IsForward, Is.False);
|
|
Assert.That(response.Results[3].References[0].NodeId, Is.EqualTo(new ExpandedNodeId(nodeId)));
|
|
Assert.That(allReferences, Has.Length.EqualTo(4));
|
|
Assert.That(allReferences!.Any(reference =>
|
|
reference.ReferenceTypeId == ReferenceTypeIds.HasTypeDefinition &&
|
|
reference.IsForward &&
|
|
reference.NodeId == VariableTypeIds.BaseDataVariableType), Is.True);
|
|
Assert.That(allReferences.Any(reference =>
|
|
reference.ReferenceTypeId == ReferenceTypeIds.Organizes &&
|
|
!reference.IsForward &&
|
|
reference.NodeId == new NodeId("References", (ushort)namespaceIndex)), Is.True);
|
|
Assert.That(response.Results[5].References.ToArray()!.Any(reference =>
|
|
!reference.IsForward &&
|
|
reference.NodeId == new NodeId("Scalar", (ushort)namespaceIndex)), Is.True);
|
|
Assert.That(response.Results[6].References.ToArray()!.Any(reference =>
|
|
!reference.IsForward &&
|
|
reference.NodeId == new NodeId("Scalar_Static", (ushort)namespaceIndex)), Is.True);
|
|
});
|
|
|
|
static BrowseDescription Describe(
|
|
NodeId id,
|
|
NodeId referenceTypeId,
|
|
BrowseDirection direction,
|
|
bool includeSubtypes = false)
|
|
{
|
|
return new BrowseDescription
|
|
{
|
|
NodeId = id,
|
|
BrowseDirection = direction,
|
|
ReferenceTypeId = referenceTypeId,
|
|
IncludeSubtypes = includeSubtypes,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that after transferring a subscription to a session that then switches its
|
|
/// identity to one that lacks access to the monitored node, permission caches are
|
|
/// invalidated and subsequent value-change notifications for that node are suppressed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Scenario:
|
|
/// 1. Session A authenticates as user1 (AuthenticatedUser role) and creates a
|
|
/// subscription monitoring <c>AccessRights_RolePermissions_AuthenticatedUser</c>
|
|
/// (a node whose RolePermissions allow only the AuthenticatedUser role to read it).
|
|
/// 2. Session B also authenticates as user1 so that TransferSubscriptions accepts the
|
|
/// transfer (identity check: same username).
|
|
/// 3. The subscription is transferred to session B with sendInitialValues=true; session B
|
|
/// publishes and receives the initial value — confirming the node is accessible.
|
|
/// 4. Session B re-activates with anonymous identity. This triggers
|
|
/// <c>SessionActivatedAsync</c> → <c>InvalidatePermissionCacheForSession</c>,
|
|
/// clearing the cached permission for the transferred item.
|
|
/// 5. Session A (still user1) writes a new value to the restricted node, triggering
|
|
/// <c>ValidateItemDataChange</c> for session B's item. With the cache cleared, the
|
|
/// permission is re-evaluated for the anonymous identity → BadUserAccessDenied →
|
|
/// the notification is suppressed.
|
|
/// 6. Session B publishes: no DataChangeNotification for the restricted node should
|
|
/// be present, proving that permissions were correctly re-evaluated.
|
|
/// </remarks>
|
|
/// <exception cref="AssertionException">
|
|
/// The fixture has no TCP or HTTPS endpoint, or the selected endpoint has no Username token policy.
|
|
/// </exception>
|
|
[Test]
|
|
public async Task PermissionsRevalidatedAfterTransferSubscriptionsAndReactivationAsync()
|
|
{
|
|
// The ReferenceServer registers its nodes under namespace index 2.
|
|
// "AccessRights_RolePermissions_AuthenticatedUser" allows Browse/Read/Write only
|
|
// for WellKnownRole_AuthenticatedUser; anonymous sessions cannot read it.
|
|
var restrictedNodeId = new NodeId("AccessRights_RolePermissions_AuthenticatedUser", 2);
|
|
|
|
EndpointDescription endpoint = m_server.GetEndpoints().Find(e =>
|
|
e.TransportProfileUri is Profiles.UaTcpTransport or Profiles.HttpsBinaryTransport)
|
|
?? throw new AssertionException("The fixture requires a TCP or HTTPS endpoint.");
|
|
UserTokenPolicy userNamePolicy = endpoint.UserIdentityTokens.Find(
|
|
policy => policy.TokenType == UserTokenType.UserName)
|
|
?? throw new AssertionException("The endpoint must advertise a Username token policy.");
|
|
|
|
var usernameToken = new UserNameIdentityToken
|
|
{
|
|
UserName = "user1",
|
|
Password = System.Text.Encoding.UTF8.GetBytes("password").ToByteString(),
|
|
PolicyId = userNamePolicy.PolicyId
|
|
};
|
|
|
|
// Session A: authenticate as user1.
|
|
(RequestHeader headerA, SecureChannelContext channelA) =
|
|
await m_server.CreateAndActivateSessionAsync(
|
|
"TransferTest_SessionA",
|
|
useSecurity: false,
|
|
identityToken: usernameToken).ConfigureAwait(false);
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
|
|
uint subscriptionId = 0;
|
|
try
|
|
{
|
|
var servicesA = new ServerTestServices(m_server, channelA);
|
|
|
|
// Create a subscription in session A.
|
|
CreateSubscriptionResponse subResp = await servicesA.CreateSubscriptionAsync(
|
|
headerA,
|
|
requestedPublishingInterval: 100,
|
|
requestedLifetimeCount: 20,
|
|
requestedMaxKeepAliveCount: 10,
|
|
maxNotificationsPerPublish: 0,
|
|
publishingEnabled: true,
|
|
priority: 0).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(subResp.ResponseHeader);
|
|
subscriptionId = subResp.SubscriptionId;
|
|
|
|
// Monitor the restricted node. Session A (user1 / AuthenticatedUser role)
|
|
// has Read permission, so creation should succeed.
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems =
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId
|
|
{
|
|
NodeId = restrictedNodeId,
|
|
AttributeId = Attributes.Value
|
|
},
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = -1,
|
|
QueueSize = 10,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse itemsResp = await servicesA.CreateMonitoredItemsAsync(
|
|
headerA,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(itemsResp.ResponseHeader, itemsResp.Results, monitoredItems);
|
|
Assert.That(StatusCode.IsGood(itemsResp.Results[0].StatusCode), Is.True,
|
|
"Monitored item creation on restricted node should succeed for AuthenticatedUser.");
|
|
|
|
// Write an initial value so the node has a known, non-default value.
|
|
// This also pre-populates the monitored item's last-value cache.
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
WriteResponse writeResp0 = await m_server.WriteAsync(
|
|
channelA,
|
|
headerA,
|
|
[new WriteValue
|
|
{
|
|
NodeId = restrictedNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant((short)10))
|
|
}],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResp0.ResponseHeader);
|
|
Assert.That(StatusCode.IsGood(writeResp0.Results[0]), Is.True,
|
|
"Initial write from user1 should succeed.");
|
|
|
|
// Consume any initial notifications queued in session A.
|
|
await Task.Delay(300).ConfigureAwait(false);
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
PublishResponse publishA = await servicesA.PublishAsync(headerA, []).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(publishA.ResponseHeader);
|
|
|
|
// Session B: authenticate as user1 as well.
|
|
// TransferSubscriptions requires the same username for the identity check to pass.
|
|
(RequestHeader headerB, SecureChannelContext channelB) =
|
|
await m_server.CreateAndActivateSessionAsync(
|
|
"TransferTest_SessionB",
|
|
useSecurity: false,
|
|
identityToken: usernameToken).ConfigureAwait(false);
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
|
|
try
|
|
{
|
|
var servicesB = new ServerTestServices(m_server, channelB);
|
|
|
|
// Transfer the subscription from session A to session B.
|
|
// sendInitialValues=true causes the server to re-queue the last known value
|
|
// for all monitored items immediately after transfer.
|
|
TransferSubscriptionsResponse transferResp =
|
|
await servicesB.TransferSubscriptionsAsync(
|
|
headerB,
|
|
[subscriptionId],
|
|
sendInitialValues: true).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(transferResp.ResponseHeader);
|
|
Assert.That(StatusCode.IsGood(transferResp.Results[0].StatusCode), Is.True,
|
|
"Subscription transfer should succeed: both sessions use user1 identity.");
|
|
|
|
// Publish from session B to receive the initial-values notification.
|
|
// At this point session B is still user1 so the read is permitted.
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
PublishResponse publishB1 = await servicesB.PublishAsync(
|
|
headerB,
|
|
publishA.NotificationMessage.SequenceNumber > 0
|
|
? [new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishA.NotificationMessage.SequenceNumber
|
|
}]
|
|
: []).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(publishB1.ResponseHeader);
|
|
|
|
Assert.That(
|
|
HasDataChangeNotificationForClientHandle(publishB1.NotificationMessage, 1),
|
|
Is.True,
|
|
"Session B (user1) must receive the initial data notification for the restricted node.");
|
|
|
|
// Re-activate session B with anonymous identity.
|
|
// The identity change triggers SessionActivatedAsync →
|
|
// InvalidatePermissionCacheForSession, clearing the cached permission for
|
|
// the transferred monitored item.
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
ActivateSessionResponse reactivateResp = await m_server.ActivateSessionAsync(
|
|
channelB,
|
|
headerB,
|
|
default,
|
|
[],
|
|
[],
|
|
default,
|
|
null,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(reactivateResp.ResponseHeader);
|
|
|
|
// Write a new value from session A (user1 still has Write permission).
|
|
// This triggers ValidateItemDataChange for session B's monitored item.
|
|
// With the permission cache cleared, anonymous cannot read the restricted
|
|
// node → notification is suppressed.
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
WriteResponse writeResp = await m_server.WriteAsync(
|
|
channelA,
|
|
headerA,
|
|
[new WriteValue
|
|
{
|
|
NodeId = restrictedNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant((short)99))
|
|
}],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResp.ResponseHeader);
|
|
Assert.That(StatusCode.IsGood(writeResp.Results[0]), Is.True,
|
|
"Session A (user1) should still be able to write to the restricted node.");
|
|
|
|
// Allow time for the value change to be processed.
|
|
await Task.Delay(300).ConfigureAwait(false);
|
|
|
|
// Publish from session B. Because the anonymous identity cannot read the
|
|
// restricted node, session B must not receive the value; the denied access
|
|
// is reported as a bad status instead (Part 4 5.13.2.1).
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
PublishResponse publishB2 = await servicesB.PublishAsync(
|
|
headerB,
|
|
[new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishB1.NotificationMessage.SequenceNumber
|
|
}]).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(publishB2.ResponseHeader);
|
|
|
|
foreach (DataValue value in GetDataChangeValuesForClientHandle(
|
|
publishB2.NotificationMessage, 1))
|
|
{
|
|
Assert.That(
|
|
value.StatusCode == StatusCodes.BadUserAccessDenied ||
|
|
value.StatusCode == StatusCodes.BadNotReadable,
|
|
Is.True,
|
|
"After re-activation as anonymous, session B must only receive an access " +
|
|
$"error for the AuthenticatedUser-restricted node, got {value.StatusCode}.");
|
|
Assert.That(value.WrappedValue.IsNull, Is.True,
|
|
"The restricted value must not be delivered to the anonymous session.");
|
|
}
|
|
|
|
// Clean up.
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
await servicesB.DeleteSubscriptionsAsync(headerB, [subscriptionId]).ConfigureAwait(false);
|
|
subscriptionId = 0;
|
|
}
|
|
finally
|
|
{
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(
|
|
channelB,
|
|
headerB,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (subscriptionId != 0)
|
|
{
|
|
var servicesA = new ServerTestServices(m_server, channelA);
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
await servicesA.DeleteSubscriptionsAsync(headerA, [subscriptionId])
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(
|
|
channelA,
|
|
headerA,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> if any <see cref="DataChangeNotification"/> in the given
|
|
/// <paramref name="message"/> contains a <see cref="MonitoredItemNotification"/>
|
|
/// with the specified <paramref name="clientHandle"/>.
|
|
/// </summary>
|
|
private static bool HasDataChangeNotificationForClientHandle(
|
|
NotificationMessage message,
|
|
uint clientHandle)
|
|
{
|
|
foreach (ExtensionObject extObj in message.NotificationData)
|
|
{
|
|
if (extObj.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == clientHandle)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the values of all <see cref="MonitoredItemNotification"/>s with the specified
|
|
/// <paramref name="clientHandle"/> in the given <paramref name="message"/>.
|
|
/// </summary>
|
|
private static List<DataValue> GetDataChangeValuesForClientHandle(
|
|
NotificationMessage message,
|
|
uint clientHandle)
|
|
{
|
|
var values = new List<DataValue>();
|
|
foreach (ExtensionObject extObj in message.NotificationData)
|
|
{
|
|
if (extObj.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == clientHandle)
|
|
{
|
|
values.Add(item.Value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
}
|
|
}
|