Files
Marc Schier 2836da7ec8 Fix sessionless ResultTransfer ownership bypass and other issues. (#4834)
# Description

Correct ownership, endpoint validation, policy propagation, subscription
delivery, file handling and generated-model behavior across the stack,
tools and samples. Add focused regression tests and update the
corresponding guides and sample documentation.

### Previously included corrections

- Require an active, matching session to generate or access transient
ResultTransfer file handles. Cover both sessionless generation and reads
against a handle owned by an active session, using live session owners
in the Machinery tests.
- Keep physical filesystem operations within their configured mount and
apply the same confinement checks throughout file operations.
- Validate LDS semaphore paths consistently during server registration
and registration-store maintenance.

### Individual issues addressed

| # | Component | Issue and correction |
|---|---|---|
| 1 | AI jobs and transfers | Jobs and transfers did not consistently
retain their creator's identity and session. Apply ownership checks to
descendants, file handles, subscriptions and execution, and recheck
deployment permissions before dispatch. |
| 2 | Password-management client | Password-bearing calls could use
signing-only channels or follow a replacement connection. Capture an
encrypted transport generation and keep dispatch bound to it; preserve
ordinary non-password edits. |
| 3 | GDS and certificate-push clients | Connections omitted hostname
verification. Validate the original configured DNS name or IP address
before session activation. |
| 4 | Certificate push | Private-key uploads could use signing-only
calls. Require an encrypted, generation-bound dispatch while preserving
certificate-only updates and restoring the caller's identity afterward.
|
| 5 | MQTT client | The .NET Framework custom-trust callback could
discard TLS hostname, certificate-availability and usage errors. Retain
those checks while supporting configured issuer trust. |
| 6 | DTLS handshake | Certificate trust was not tied to the configured
peer address. Match the certificate's DNS or IP identity to the intended
endpoint. |
| 7 | Replicated address spaces | Serialized nodes lost per-node and
descendant permission metadata. Preserve role permissions, user-role
permissions and access restrictions, including updates to existing
replica nodes. |
| 8 | MCP PubSub imports | Imports without groups skipped checks on the
effective broker destination. Check the actual endpoint before resolving
credentials or activating the connection. |
| 9 | Reference-server accounts | A disabled username account could
authenticate again with its previous roles. Reject disabled accounts
during fresh authentication. |
| 10 | Console reference client | The sample disabled hostname checks
when connecting to an otherwise trusted peer. Retain the original
requested host and validate it before activation. |
| 11 | Capture replay and export | Replay and raw export could accept
non-capture files or include key material. Require complete PCAP/PCAPNG
containers and exclude key sidecars and decryption-secret blocks. |
| 12 | PubSub adapter fallback | The fallback client configuration
automatically accepted peer certificates. Apply normal trust and
hostname validation before activation or reads. |
| 13 | Kafka transport | Explicit TLS enablement was checked but did not
reach both final Confluent configurations. Use the same effective
protocol for validation, producers and consumers. |
| 14 | PubSub actions | Configuration rebuilds could widen a
connection-local action handler's scope. Preserve its original
connection association and reject mismatched dispatch. |
| 15 | PubSub readers | Reader-level message-protection settings were
accepted but ignored. Apply the effective group and reader settings
before accepting and publishing received values. |
| 16 | PubSub key groups | Different `SecurityGroupId` values could
share one connection-wide key context. Reject mixed groups on a
connection and require separate connections for distinct groups. |
| 17 | MCP session registrations | Session-name replacement or reuse
could retain another registration's credential bindings. Bind access to
the registration, caller and authenticated endpoint, and preserve that
identity when borrowing sessions. |
| 18 | Viewer HTTP host | Requests reached the viewer's OPC UA session
without consistent Host, Origin and port checks. Validate the request
destination and browser origin before using the session, while retaining
supported local MCP clients. |
| 19 | Vision file operations | File operations did not consistently
apply protected-file checks, and remote handles lacked caller and
connection context. Apply the shared checks and use host-side opaque
handles bound to caller, target and generation. |
| 20 | DTLS record integrity | The integrity-only MAC used a truncated
sequence number. Authenticate the full reconstructed sequence number and
epoch so records remain distinct across rollover. |
| 21 | AMB client sample | Connection setup omitted hostname validation.
Preserve the originally requested host and verify the peer identity
before activation. |
| 22 | Robot sample writes | The R2 `SpeedOverride` callback could
target R1 state. Bind each callback to its own robot and apply that
robot's write policy. |
| 23 | AI result properties | Optional result children inherited
remotely writable defaults. Preserve their declaration restrictions and
keep server-produced results read-only. |
| 24 | WSS peer-trust updates | Existing channels did not consistently
respond to peer-trust changes. Revalidate complete chains for forward
and reverse WSS channels and close only those that no longer validate. |
| 25 | GDS revocation checks | Caller-provided certificates could cause
issuer retrieval from embedded URLs. Resolve validation from configured
local trust, issuers and CRLs without fetching caller-provided
locations. |
| 26 | GMS observations | Published correction observations retained
writable model defaults. Reject remote writes while preserving
publisher-side updates. |
| 27 | Production-dataset monitoring | Subscription admission and
delivery could use the reporter's context instead of each subscriber's
permissions. Evaluate the subscriber at admission and on every delivery,
including cancellation and disposal paths. |
| 28 | WoT MQTT destinations | Hostless MQTT forms could imply a
localhost destination before loopback checks. Reject them before
credential lookup or connection attempts and validate the effective
endpoint. |
| 29 | XRegistry opaque reads | Content-ID reads and notifications did
not consistently honor encrypted-read requirements or the currently
accepted transport. Recheck the bound endpoint and reject transport
downgrades before rebinding, including when protected values are already
queued. |
| 30 | PubSub capture dissection | Key loading did not consistently
check the configured capture-root boundary. Check each actual key path
immediately before opening it. |
| 31 | PubSub publication queues | Imported WriterGroup queues were not
fully checked against publish-topic grants. Check the destination
actually used by each group. |
| 32 | MCP PubSub mutations | A caller-supplied mode could select
subscribe grants for an operation that publishes. Derive the required
direction and parent context from the actual mutation. |
| 33 | Generated write masks | Model import and generation could widen
an explicit `UserWriteMask`. Preserve it through import, copy, merge,
inheritance and dependency metadata. |
| 34 | Generated method flags | An explicit `UserExecutable=false` could
be lost. Preserve its specified state and resulting method behavior
through the same model paths. |
| 35 | Generated role identifiers | Unresolved role identifiers could
cause permission entries to be omitted. Preserve namespace-qualified
role IDs and report a diagnostic when a policy cannot be represented. |
| 36 | Aggregation-server defaults | The `localhost` default did not
reliably constrain listener binding. Use numeric loopback for the
default endpoint and command-line fallback. |
| 37 | Certificate-validation cache | Cached results could skip current
leaf or issuer validity checks. Recheck validity windows on cache hits
and keep hostname validation independent of cached chain trust. |
| 38 | Software-update folders | Revision names and marker contents
containing dot segments could select paths outside the software root.
Normalize and check every derived path against that root. |
| 39 | USDA arrays | Array elements were not encoded for their string or
asset context. Escape them as data and preserve valid composition and
round-trip behavior. |
| 40 | Adapter hot reload | A changed dataset definition with the same
name could keep old fields, subscriptions or cached values active.
Compare complete immutable definitions and retire old state before
replacement. |
| 41 | Adapter index ranges | Subscription publication could ignore
`IndexRange` and return the complete value. Preserve the requested
projection and its status and timestamps; reject unsupported range
providers. |
| 42 | PubSub metadata and targets | Metadata from one connection could
change another connection's target mapping. Scope metadata to its
connection and provenance, and resolve targets using actual typed field
IDs rather than field names or unintended positional fallback. |
| 43 | Scales product locks | Product locks were not applied across
every remote mutation path. Apply them consistently to value writes,
filling calculations and related recipe/controller operations while
retaining permitted updates. |
| 44 | Event permission caching | Cached event decisions could outlive
identity, role or node-policy changes. Revalidate the current
subscriber, event type and source permissions instead of reusing a stale
decision. |
| 45 | WoT Modbus destinations | Hostless Modbus forms could imply
localhost before loopback checks. Reject them before connecting and
validate the effective endpoint. |
| 46 | MCP local PKI tools | Peer-trust mutations did not consistently
require the caller's `CertificateAdministration` grant. Check the
capability before trust and untrust operations. |
| 47 | Robot-control HTTP bridge | The bridge lacked consistent Host,
Origin and port validation and caller-specific session grants. Apply
those checks before borrowing the OPC UA session and retain supported
local clients. |
| 48 | Subscription transfers | Transferred subscriptions could retain
cached notifications after the destination's permissions changed.
Require authorization continuity between source and destination
identities and roles, invalidate stale retransmission data when
continuity fails, and preserve retransmission behavior for compatible
transfers. |

### Compatibility notes

- Replication uses the versioned NSRP v1 policy envelope. Upgrade peers
together and re-encode or reseed legacy topology from the authoritative
model; missing permission metadata is not inferred from old records.
- Integrity-only DTLS peers must be upgraded together for the full
sequence/epoch MAC. AEAD behavior is unchanged.
- Distinct PubSub key groups require separate connections.
- Custom sessions that cannot capture the actual encrypted transport,
and subscription providers that cannot honor a requested range, are
rejected rather than silently changing the requested behavior.
- Subscription transfers retain retransmission data only when the
destination authorization remains compatible with the source snapshot;
otherwise the old sequence numbers and cached data are invalidated.

## Related Issues

No separate tracking issue is linked. The individual corrections are
listed above.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [ ] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-21 05:37:29 +02:00

3693 lines
172 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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
public void GetEndpoints()
{
ArrayOf<EndpointDescription> endpoints = m_server.GetEndpoints();
Assert.That(endpoints.IsNull, Is.False);
}
/// <summary>
/// Get Operation limits.
/// </summary>
[Test]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
public async Task FailedReadThrowsTheServiceResult()
{
var requestHeader = (RequestHeader)m_requestHeader.Clone();
requestHeader.ReturnDiagnostics = (uint)DiagnosticsMasks.All;
ServiceResultException? e = await 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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(GetOperationLimitsAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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")]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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)]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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")]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(ReferenceNodeManagerVariablesUpdateTimestampOnReadAsync), AllowFailure = true)]
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]
[Benchmark]
[DependsOnTest(nameof(ReadWriteUpdateNodesAsync), AllowFailure = true)]
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]
[Benchmark]
[DependsOnTest(nameof(BrowseFullAddressSpaceAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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,
expectAuthorizationInvalidation: useSecurity).ConfigureAwait(false);
if (useSecurity)
{
// subscription was deleted, expect 'BadNoSubscription'
ServiceResultException sre = await 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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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)]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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]
[DependsOnTest(nameof(TranslateBrowsePathAsync), AllowFailure = true)]
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;
}
}
}