mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 00:31:32 +08:00
0000513cde
# Description Introduces a pluggable user-identity provider model covering all OPC UA Part 6 §6.5 mechanisms (Anonymous, UserName, X509, IssuedToken/JWT) on both client and server, with full `Microsoft.Extensions.DependencyInjection` integration, plus the Part 12 §9 `AuthorizationService` two-phase token-request flow **including refresh-token rotation**, the Part 12 §8 KeyCredential Push model, and a clean provider model for OPC 10000-12 §7.2 `ApplicationSelfAdmin`. The work is split across phase-by-phase commits so it can be reviewed independently; nothing here changes wire-level behaviour or forces existing applications to migrate. ### Documentation All under `Docs/`: - `IdentityProviders.md` — full developer guide: three-layer model, recipes, configuration reference, "what gets auto-wired" matrix, JWS algorithm table, identity-augmenters section, **"Implementing your own provider"** how-to subsections for Entra / OIDC / Windows / AspNetCore (sibling files merged in per review feedback). - `DependencyInjection.md` — `Identity` sections with `appsettings.json` examples, identity-extension matrix, `WithAuthorizationService` + `WithKeyCredentialPush` + `AddIdentityAugmenter` extensions documented. - `RoleBasedUserManagement.md` — claim-criteria callout describing `IIdentityClaims` probe surface. - `AuthorizationService.md` — Architecture diagram + `IAccessTokenProvider` conceptual narrative restored from master; modern `StartRequestToken` / `FinishRequestToken` flow leads; refresh-token recipe added. - `KeyCredentialService.md` — Push model + Pull vs Push vs Bridge decision matrix + Hybrid (Push + Pull) section; explicit experimental warning for the bridge. - `GDS.md` — KeyCredential Push, `StartRequestToken`, `GdsApplicationSelfAdminProvider`, **shipped Refresh-token support with rotation policy and audit semantics** all documented. - `MigrationGuide.md` — User Identity Providers section listing `[Obsolete]` markers + mechanical recipes; SelfAdmin migration via `IIdentityAugmenter`; `EnableRefreshTokens` default note. - `README.md` index entries. ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [x] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
2239 lines
102 KiB
C#
2239 lines
102 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.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 ITelemetryContext m_telemetry;
|
|
private ServerFixture<ReferenceServer> m_fixture;
|
|
private ReferenceServer m_server;
|
|
private RequestHeader m_requestHeader;
|
|
private SecureChannelContext m_secureChannelContext;
|
|
private OperationLimits m_operationLimits;
|
|
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
|
|
};
|
|
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).ConfigureAwait(false);
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
m_requestHeader.TimeoutHint = kTimeoutHint;
|
|
m_random = new RandomSource();
|
|
m_generator = new DataGenerator(m_random, m_telemetry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down the test session.
|
|
/// </summary>
|
|
[TearDown]
|
|
public async Task TearDownAsync()
|
|
{
|
|
if (!m_sessionClosed)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, CancellationToken.None).ConfigureAwait(false);
|
|
m_requestHeader = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set up a Reference Server a session
|
|
/// </summary>
|
|
[GlobalSetup]
|
|
public async Task GlobalSetupAsync()
|
|
{
|
|
// start Ref server
|
|
m_fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = true
|
|
};
|
|
m_server = await m_fixture.StartAsync(null).ConfigureAwait(false);
|
|
(m_requestHeader, m_secureChannelContext) = await m_server.CreateAndActivateSessionAsync("Bench").ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down Server and the close the session.
|
|
/// </summary>
|
|
[GlobalCleanup]
|
|
public async Task GlobalCleanupAsync()
|
|
{
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
await m_fixture.StopAsync().ConfigureAwait(false);
|
|
Thread.Sleep(1000);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test for expected exceptions.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task NoInvalidTimestampExceptionAsync()
|
|
{
|
|
// test that the server accepts an invalid timestamp
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now - TimeSpan.FromDays(30);
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, false, RequestLifetime.None).ConfigureAwait(false);
|
|
m_sessionClosed = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Endpoints.
|
|
/// </summary>
|
|
[Test]
|
|
public void GetEndpoints()
|
|
{
|
|
ArrayOf<EndpointDescription> endpoints = m_server.GetEndpoints();
|
|
Assert.That(endpoints.IsNull, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Operation limits.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(100)]
|
|
public async Task GetOperationLimitsAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId =
|
|
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall
|
|
}
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIdCollection,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
|
|
ArrayOf<DataValue> results = readResponse.Results;
|
|
Assert.That(results.IsNull, Is.False);
|
|
Assert.That(results.Count, Is.EqualTo(readIdCollection.Count));
|
|
|
|
m_operationLimits = new OperationLimits
|
|
{
|
|
MaxNodesPerRead = (uint)results[0].WrappedValue,
|
|
MaxNodesPerHistoryReadData = (uint)results[1].WrappedValue,
|
|
MaxNodesPerHistoryReadEvents = (uint)results[2].WrappedValue,
|
|
MaxNodesPerWrite = (uint)results[3].WrappedValue,
|
|
MaxNodesPerHistoryUpdateData = (uint)results[4].WrappedValue,
|
|
MaxNodesPerHistoryUpdateEvents = (uint)results[5].WrappedValue,
|
|
MaxNodesPerBrowse = (uint)results[6].WrappedValue,
|
|
MaxMonitoredItemsPerCall = (uint)results[7].WrappedValue,
|
|
MaxNodesPerNodeManagement = (uint)results[8].WrappedValue,
|
|
MaxNodesPerRegisterNodes = (uint)results[9].WrappedValue,
|
|
MaxNodesPerTranslateBrowsePathsToNodeIds = (uint)results[10].WrappedValue,
|
|
MaxNodesPerMethodCall = (uint)results[11].WrappedValue
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read node.
|
|
/// </summary>
|
|
[Test]
|
|
[Benchmark]
|
|
public async Task ReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = new NodeId("Scalar_Simulation_Int32", 2);
|
|
var nodesToRead = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new ReadValueId { NodeId = nodeId, AttributeId = attributeId })
|
|
.ToArrayOf();
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read all nodes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadAllNodesAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
if (m_referenceDescriptions.IsNull)
|
|
{
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
// Read all variables
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
foreach (ReferenceDescription reference in m_referenceDescriptions.ToList())
|
|
{
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = ExpandedNodeId.ToNodeId(
|
|
reference.NodeId,
|
|
m_server.CurrentInstance.NamespaceUris);
|
|
var nodesToRead = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new ReadValueId { NodeId = nodeId, AttributeId = attributeId })
|
|
.ToArrayOf();
|
|
TestContext.Out.WriteLine("NodeId {0} {1}", reference.NodeId, reference.BrowseName);
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
|
|
foreach (DataValue dataValue in readResponse.Results)
|
|
{
|
|
TestContext.Out.WriteLine(" {0}", dataValue.ToString());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Write Node.
|
|
/// </summary>
|
|
[Test]
|
|
[Benchmark]
|
|
public async Task WriteAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Write
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = new NodeId("Scalar_Simulation_Int32", 2);
|
|
var nodesToWrite = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(1234)
|
|
})
|
|
.ToArrayOf();
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
writeResponse.DiagnosticInfos,
|
|
writeResponse.Results,
|
|
writeResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ReferenceNodeManager variables update their SourceTimestamp on read.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(340)]
|
|
public async Task ReferenceNodeManagerVariablesUpdateTimestampOnReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read a variable from the ReferenceNodeManager (namespace index 2)
|
|
var nodeId = new NodeId("Scalar_Static_Byte", 2);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// First read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeFirstRead = DateTimeUtc.Now;
|
|
ReadResponse firstReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(firstReadResponse, Is.Not.Null);
|
|
Assert.That(firstReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(firstReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue firstValue = firstReadResponse.Results[0];
|
|
Assert.That(firstValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(firstValue.SourceTimestamp.IsNull, Is.False);
|
|
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);
|
|
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 ReferenceNodeManager array variables update their SourceTimestamp on read.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerArrayVariablesUpdateTimestampOnReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read an array variable from the ReferenceNodeManager (namespace index 2)
|
|
var nodeId = new NodeId("Scalar_Static_Arrays_Byte", 2);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// First read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeFirstRead = DateTimeUtc.Now;
|
|
ReadResponse firstReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(firstReadResponse, Is.Not.Null);
|
|
Assert.That(firstReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(firstReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue firstValue = firstReadResponse.Results[0];
|
|
Assert.That(firstValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(firstValue.SourceTimestamp.IsNull, Is.False);
|
|
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);
|
|
logger.LogInformation("Array Second read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
secondValue.SourceTimestamp, secondValue.ServerTimestamp);
|
|
|
|
// Verify the second timestamp is more recent than the first
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThan((long)firstValue.SourceTimestamp),
|
|
"Array SourceTimestamp should be updated on each read");
|
|
|
|
// Verify the second timestamp is recent
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeSecondRead.SubtractMilliseconds(1000)),
|
|
"Array SourceTimestamp should be close to the second read time");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update static Nodes, read modify write.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(350)]
|
|
public async Task ReadWriteUpdateNodesAsync()
|
|
{
|
|
// Nodes
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Browse full address space.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(400)]
|
|
[Benchmark]
|
|
public async Task BrowseFullAddressSpaceAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Translate references.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(500)]
|
|
[Benchmark]
|
|
public async Task TranslateBrowsePathAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
if (m_referenceDescriptions.IsNull)
|
|
{
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
_ = await CommonTestWorkers.TranslateBrowsePathWorkerAsync(
|
|
serverTestServices,
|
|
m_referenceDescriptions,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Read a few notifications with Publish.
|
|
/// Delete the monitored item and subscription.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SubscriptionAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
await CommonTestWorkers.SubscriptionTestAsync(serverTestServices, m_requestHeader).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a single session + subscription and raise a single event on the server node.
|
|
/// Validates all the event fields are properly received.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerEventSubscribeTestAsync()
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
|
|
CreateSubscriptionResponse createSubscriptionResponse = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
|
|
uint subscriptionId = createSubscriptionResponse.SubscriptionId;
|
|
|
|
// Build an event filter for the base event type.
|
|
var eventFilter = new EventFilter();
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.EventId));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.EventType));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.SourceNode));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.SourceName));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Time));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Message));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Severity));
|
|
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems = [
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 100,
|
|
DiscardOldest = true,
|
|
Filter = new ExtensionObject(eventFilter)
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse createItemsResponse = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(createItemsResponse.ResponseHeader, createItemsResponse.Results, monitoredItems);
|
|
Assert.AreEqual(1, createItemsResponse.Results.Count);
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[0].StatusCode));
|
|
|
|
// Generate event directly on the server
|
|
IServerInternal serverInternal = m_server.CurrentInstance;
|
|
ISystemContext serverContext = serverInternal.DefaultSystemContext;
|
|
var e = new BaseEventState(null);
|
|
const string eventMessage = "Integration Test Event";
|
|
e.Initialize(
|
|
serverContext,
|
|
serverInternal.ServerObject,
|
|
EventSeverity.Medium,
|
|
new LocalizedText(eventMessage));
|
|
serverInternal.ReportEvent(serverContext, e);
|
|
|
|
// Wait for subscription to deliver the event
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
|
|
// Publish request to get the event notification
|
|
var acknowledgements = new ArrayOf<SubscriptionAcknowledgement>();
|
|
PublishResponse publishResponse = await services.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.IsNotNull(publishResponse.NotificationMessage);
|
|
Assert.IsNotNull(publishResponse.NotificationMessage.NotificationData);
|
|
Assert.IsTrue(publishResponse.NotificationMessage.NotificationData.Count > 0);
|
|
|
|
publishResponse.NotificationMessage.NotificationData[0].TryGetValue(out EventNotificationList eventNotification);
|
|
Assert.IsNotNull(eventNotification);
|
|
Assert.IsTrue(eventNotification.Events.Count > 0);
|
|
|
|
EventFieldList targetEvent = eventNotification.Events.ToList().FirstOrDefault(
|
|
x => x.EventFields[5].TryGetValue(out LocalizedText lt) && lt.Text == eventMessage);
|
|
Assert.IsNotNull(targetEvent, "Did not receive the target event.");
|
|
|
|
ArrayOf<Variant> eventFields = targetEvent.EventFields;
|
|
Assert.AreEqual(7, eventFields.Count); // we requested 7 fields in select clauses
|
|
|
|
Assert.IsFalse(eventFields[0].IsNull); // EventId
|
|
Assert.IsFalse(eventFields[1].IsNull); // EventType
|
|
Assert.IsFalse(eventFields[2].IsNull); // SourceNode
|
|
Assert.IsFalse(eventFields[3].IsNull); // SourceName
|
|
Assert.IsFalse(eventFields[4].IsNull); // Time
|
|
Assert.That(eventFields[5].TryGetValue(out LocalizedText receivedMessage), Is.True); // Message
|
|
Assert.IsFalse(receivedMessage.IsNull);
|
|
Assert.AreEqual(eventMessage, receivedMessage.Text);
|
|
Assert.That(eventFields[6].TryGetValue(out ushort receiveSeverity), Is.True);
|
|
Assert.AreEqual((ushort)EventSeverity.Medium, receiveSeverity); // Severity
|
|
|
|
// Delete subscription
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscriptionId]).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create multiple sessions, each with a subscription.
|
|
/// Close all sessions without deleting subscriptions (abandoning them).
|
|
/// Concurrently delete the abandoned subscriptions from the main session.
|
|
/// Verifies that the concurrent dictionary backing abandoned subscriptions
|
|
/// handles parallel access correctly (fix for issue #3612).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task DeleteAbandonedSubscriptionsConcurrentlyAsync()
|
|
{
|
|
const int sessionCount = 5;
|
|
var subscriptionIds = new List<uint>();
|
|
|
|
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);
|
|
}
|
|
|
|
// Concurrently delete all abandoned subscriptions from the main session.
|
|
var mainServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
var deleteTasks = new List<Task<DeleteSubscriptionsResponse>>();
|
|
foreach (uint id in subscriptionIds)
|
|
{
|
|
ArrayOf<uint> singleId = [id];
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
deleteTasks.Add(
|
|
mainServices.DeleteSubscriptionsAsync(m_requestHeader, singleId)
|
|
.AsTask());
|
|
}
|
|
|
|
DeleteSubscriptionsResponse[] responses = await Task.WhenAll(deleteTasks)
|
|
.ConfigureAwait(false);
|
|
|
|
// All deletions should succeed.
|
|
foreach (DeleteSubscriptionsResponse response in responses)
|
|
{
|
|
Assert.AreEqual(StatusCodes.Good, response.ResponseHeader.ServiceResult);
|
|
Assert.AreEqual(1, response.Results.Count);
|
|
Assert.AreEqual(StatusCodes.Good, (uint)response.Results[0]);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a secondary Session.
|
|
/// Create a subscription with a monitored item.
|
|
/// Close session, but do not delete subscriptions.
|
|
/// Transfer subscription from closed session to the other.
|
|
/// </summary>
|
|
[Theory]
|
|
public async Task TransferSubscriptionSessionClosedAsync(bool sendInitialData, bool useSecurity)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferContext) = await m_server.CreateAndActivateSessionAsync(
|
|
"ClosedSession",
|
|
useSecurity).ConfigureAwait(false);
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
serverTestServices.SecureChannelContext = transferContext;
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
transferRequestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(transferContext, transferRequestHeader, false, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
//restore security context, transfer abandoned subscription
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
await CommonTestWorkers.TransferSubscriptionTestAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
sendInitialData,
|
|
!useSecurity).ConfigureAwait(false);
|
|
|
|
if (useSecurity)
|
|
{
|
|
// subscription was deleted, expect 'BadNoSubscription'
|
|
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
await CommonTestWorkers.VerifySubscriptionTransferredAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
true).ConfigureAwait(false);
|
|
});
|
|
Assert.That(
|
|
sre.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadNoSubscription));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Create a secondary Session.
|
|
/// Transfer subscription with a monitored item from one session to the other.
|
|
/// </summary>
|
|
[Theory]
|
|
public async Task TransferSubscriptionAsync(bool sendInitialData, bool useSecurity)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferSecurityContext) = await m_server.CreateAndActivateSessionAsync(
|
|
"TransferSession",
|
|
useSecurity).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>
|
|
/// Create a subscription with a monitored item.
|
|
/// Call ResendData.
|
|
/// Ensure only a single value per monitored item is returned after ResendData was called.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCase(true, kQueueSize)]
|
|
[TestCase(false, kQueueSize)]
|
|
[TestCase(true, 0U)]
|
|
[TestCase(false, 0U)]
|
|
public async Task ResendDataAsync(bool updateValues, uint queueSize)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
var testSetCollection = CommonTestWorkers
|
|
.NodeIdTestSetStatic.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
.ToList();
|
|
testSetCollection.AddRange(
|
|
CommonTestWorkers.NodeIdTestDataSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris)));
|
|
NodeId[] testSet = [.. testSetCollection];
|
|
|
|
//Re-use method CreateSubscriptionForTransfer to create a subscription
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
queueSize,
|
|
0).ConfigureAwait(false);
|
|
|
|
(RequestHeader resendDataRequestHeader, SecureChannelContext resendDataSecurityContext) =
|
|
await m_server.CreateAndActivateSessionAsync("ResendData").ConfigureAwait(false);
|
|
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
// After the ResendData call there will be data to publish again
|
|
ArrayOf<CallMethodRequest> nodesToCall = await ResendDataCallAsync(
|
|
StatusCodes.Good,
|
|
subscriptionIds).ConfigureAwait(false);
|
|
|
|
Thread.Sleep(1000);
|
|
|
|
// Make sure publish queue becomes empty by consuming it
|
|
Assert.That(subscriptionIds.Count, Is.EqualTo(1));
|
|
|
|
// Issue a Publish request
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements = [];
|
|
PublishResponse publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.EqualTo(1));
|
|
|
|
// Validate nothing to publish a few times
|
|
const int timesToCallPublish = 3;
|
|
for (int i = 0; i < timesToCallPublish; i++)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
}
|
|
|
|
// Validate ResendData method call returns error from different session contexts
|
|
|
|
// call ResendData method from different session context
|
|
resendDataRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
CallResponse callResponse = await m_server.CallAsync(
|
|
resendDataSecurityContext,
|
|
resendDataRequestHeader,
|
|
nodesToCall, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
|
|
Assert.That(callResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
|
|
ServerFixtureUtils.ValidateResponse(callResponse.ResponseHeader, callResponse.Results, nodesToCall);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
callResponse.DiagnosticInfos,
|
|
nodesToCall,
|
|
callResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
|
|
// Still nothing to publish since previous ResendData call did not execute
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
|
|
if (updateValues)
|
|
{
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
|
|
// fill queues, but only a single value per resend publish shall be returned
|
|
for (int i = 1; i < queueSize; i++)
|
|
{
|
|
//If sampling groups are used, samplingInterval needs to be waited before values are queued
|
|
if (m_fixture.UseSamplingGroupsInReferenceNodeManager)
|
|
{
|
|
await Task.Delay((int)(100.0 * 1.7)).ConfigureAwait(false);
|
|
}
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
}
|
|
|
|
// Wait a bit to ensure that the server has time to queue the values
|
|
await Task.Delay(1000).ConfigureAwait(false);
|
|
}
|
|
|
|
// call ResendData method from the same session context
|
|
await ResendDataCallAsync(StatusCodes.Good, subscriptionIds).ConfigureAwait(false);
|
|
|
|
// Data should be available for publishing now
|
|
int totalNotifications = await CollectNotificationsAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
acknowledgements,
|
|
subscriptionIds[0],
|
|
testSet.Length).ConfigureAwait(false);
|
|
|
|
Assert.That(totalNotifications, Is.EqualTo(testSet.Length),
|
|
"One MonitoredItemNotification should be returned for each Node present in the TestSet");
|
|
|
|
await Task.Delay(1000).ConfigureAwait(false);
|
|
|
|
if (updateValues && queueSize > 1)
|
|
{
|
|
// remaining queue Data should be sent in this publish
|
|
int expectedCount = testSet.Length * ((int)queueSize - 1);
|
|
totalNotifications = await CollectNotificationsAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
acknowledgements,
|
|
subscriptionIds[0],
|
|
expectedCount).ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
totalNotifications,
|
|
Is.EqualTo(expectedCount).Within(testSet.Length));
|
|
}
|
|
|
|
// Call ResendData method with invalid subscription Id
|
|
await ResendDataCallAsync(StatusCodes.BadSubscriptionIdInvalid, [subscriptionIds[^1] + 20]).ConfigureAwait(false);
|
|
|
|
// Nothing to publish since previous ResendData call did not execute
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
|
|
resendDataRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(resendDataSecurityContext, resendDataRequestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<int> CollectNotificationsAsync(
|
|
ServerTestServices serverTestServices,
|
|
RequestHeader requestHeader,
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements,
|
|
uint subscriptionId,
|
|
int expectedCount)
|
|
{
|
|
int totalNotifications = 0;
|
|
int retries = 50;
|
|
while (totalNotifications < expectedCount && retries-- > 0)
|
|
{
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
PublishResponse publishResponse = await serverTestServices.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionId));
|
|
|
|
if (publishResponse.NotificationMessage.NotificationData.Count > 0)
|
|
{
|
|
// acknowledge the notification
|
|
acknowledgements =
|
|
[
|
|
new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishResponse.NotificationMessage.SequenceNumber
|
|
}
|
|
];
|
|
|
|
foreach (ExtensionObject item in publishResponse.NotificationMessage.NotificationData)
|
|
{
|
|
if (item.TryGetValue(out DataChangeNotification dcn))
|
|
{
|
|
totalNotifications += dcn.MonitoredItems.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalNotifications >= expectedCount)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (!publishResponse.MoreNotifications)
|
|
{
|
|
// Wait for potential background processing
|
|
await Task.Delay(100).ConfigureAwait(false);
|
|
}
|
|
}
|
|
return totalNotifications;
|
|
}
|
|
|
|
private async Task<ArrayOf<CallMethodRequest>> ResendDataCallAsync(
|
|
StatusCode expectedStatus,
|
|
ArrayOf<uint> subscriptionIds)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Find the ResendData method
|
|
ArrayOf<CallMethodRequest> nodesToCall = subscriptionIds
|
|
.ConvertAll(subscriptionId => new CallMethodRequest
|
|
{
|
|
ObjectId = ObjectIds.Server,
|
|
MethodId = MethodIds.Server_ResendData,
|
|
InputArguments = [new Variant(subscriptionId)]
|
|
});
|
|
|
|
//call ResendData method with subscription ids
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
CallResponse callResponse = await m_server.CallAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
nodesToCall, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(callResponse.Results[0].StatusCode, Is.EqualTo(expectedStatus));
|
|
ServerFixtureUtils.ValidateResponse(callResponse.ResponseHeader, callResponse.Results, nodesToCall);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
callResponse.DiagnosticInfos,
|
|
nodesToCall,
|
|
callResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
|
|
return nodesToCall;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read Values of NodeIds, determine types, write back new random values.
|
|
/// </summary>
|
|
/// <param name="testSet">The nodeIds to modify.</param>
|
|
private async Task UpdateValuesAsync(NodeId[] testSet)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read values
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
var nodesToRead = testSet
|
|
.Select(nodeId => new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
})
|
|
.ToArrayOf();
|
|
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(testSet.Length));
|
|
|
|
var modifiedValues = new List<DataValue>();
|
|
foreach (DataValue dataValue in readResponse.Results)
|
|
{
|
|
TypeInfo typeInfo = dataValue.WrappedValue.TypeInfo;
|
|
Assert.That(typeInfo.IsUnknown, Is.False);
|
|
Variant value = m_generator.GetRandomScalar(typeInfo.BuiltInType);
|
|
modifiedValues.Add(new DataValue(value));
|
|
}
|
|
|
|
int ii = 0;
|
|
var nodesToWrite = testSet
|
|
.Select(nodeId => new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = modifiedValues[ii++]
|
|
})
|
|
.ToArrayOf();
|
|
|
|
// Write Nodes
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
writeResponse.DiagnosticInfos,
|
|
writeResponse.Results,
|
|
writeResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SemanticChangeNotificationTestAsync()
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
|
|
// Step 1: Create a subscription
|
|
CreateSubscriptionResponse createSubscriptionResponse = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
|
|
uint subscriptionId = createSubscriptionResponse.SubscriptionId;
|
|
|
|
// We monitor the server EventNotifier for SemanticChangeEventType
|
|
var serverObject = new ReadValueId { NodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier };
|
|
var eventFilter = new EventFilter();
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.SemanticChangeEventType,
|
|
QualifiedName.From(BrowseNames.EventId));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.SemanticChangeEventType,
|
|
QualifiedName.From(BrowseNames.Changes));
|
|
|
|
// Monitor a variable from ReferenceNodeManager (AnalogItem with EngineeringUnits)
|
|
var nodeId = new NodeId("DataAccess_AnalogType_Double",
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(Quickstarts.ReferenceServer.Namespaces.ReferenceServer));
|
|
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems =
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = serverObject,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true,
|
|
Filter = new ExtensionObject(eventFilter)
|
|
}
|
|
},
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 2,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse createItemsResponse = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(createItemsResponse.ResponseHeader, createItemsResponse.Results, monitoredItems);
|
|
Assert.AreEqual(2, createItemsResponse.Results.Count);
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[0].StatusCode));
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[1].StatusCode));
|
|
|
|
// Initial publish to clear any initial data change notifications
|
|
var acknowledgements = new ArrayOf<SubscriptionAcknowledgement>();
|
|
PublishResponse publishResponse = await services.PublishAsync(requestHeader, acknowledgements).ConfigureAwait(false);
|
|
|
|
if (publishResponse.NotificationMessage.NotificationData.Count > 0)
|
|
{
|
|
acknowledgements =
|
|
[
|
|
new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishResponse.NotificationMessage.SequenceNumber
|
|
}
|
|
];
|
|
}
|
|
|
|
// Step 2: Write a new value to a semantic property (EngineeringUnits)
|
|
var euNodeId = new NodeId("DataAccess_AnalogType_DataAccess_AnalogType_Double_EngineeringUnits",
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(Quickstarts.ReferenceServer.Namespaces.ReferenceServer));
|
|
var engUnits = new EUInformation("V", "volt", "http://www.opcfoundation.org/UA/units/un/cefact")
|
|
{
|
|
UnitId = 4274026 // "V"
|
|
};
|
|
|
|
ArrayOf<WriteValue> writeValues = new WriteValue[]
|
|
{
|
|
new() {
|
|
NodeId = euNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new ExtensionObject(engUnits))
|
|
}
|
|
}.ToArrayOf();
|
|
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
writeValues, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, writeValues);
|
|
Assert.IsTrue(StatusCode.IsGood(writeResponse.Results[0]));
|
|
|
|
// Wait for subscription to deliver the event and data change notification
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
|
|
// Step 3: Issue publish to receive DataChangeNotification and SemanticChangeEvent
|
|
publishResponse = await services.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.IsNotNull(publishResponse.NotificationMessage);
|
|
Assert.IsNotNull(publishResponse.NotificationMessage.NotificationData);
|
|
Assert.IsTrue(publishResponse.NotificationMessage.NotificationData.Count == 2, "One Event and One DataChangeNotification expected.");
|
|
|
|
bool dataChangeReceived = false;
|
|
bool semanticsChangedBitSet = false;
|
|
bool eventReceived = false;
|
|
|
|
foreach (ExtensionObject data in publishResponse.NotificationMessage.NotificationData)
|
|
{
|
|
if (data.TryGetValue(out DataChangeNotification dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == 2)
|
|
{
|
|
dataChangeReceived = true;
|
|
if (item.Value.StatusCode.SemanticsChanged)
|
|
{
|
|
semanticsChangedBitSet = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (data.TryGetValue(out EventNotificationList enl))
|
|
{
|
|
foreach (EventFieldList e in enl.Events)
|
|
{
|
|
if (e.ClientHandle == 1 && e.EventFields.Count >= 2)
|
|
{
|
|
bool success = e.EventFields[1]
|
|
.TryGetStructure(out ArrayOf<SemanticChangeStructureDataType> semanticChangeEvent);
|
|
|
|
if (success && !semanticChangeEvent.IsNull && semanticChangeEvent.Count > 0)
|
|
{
|
|
foreach (SemanticChangeStructureDataType change in semanticChangeEvent)
|
|
{
|
|
if (change.Affected == nodeId)
|
|
{
|
|
eventReceived = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.IsTrue(dataChangeReceived, "Did not receive DataChangeNotification.");
|
|
Assert.IsTrue(semanticsChangedBitSet, "SemanticsChanged bit is not set.");
|
|
Assert.IsTrue(eventReceived, "Did not receive SemanticChangeEvent.");
|
|
|
|
// Delete subscription
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscriptionId]).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that Server object EventNotifier has HistoryRead bit set when history capabilities are enabled.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerEventNotifierHistoryReadBitAsync()
|
|
{
|
|
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;
|
|
|
|
logger.LogInformation("Server EventNotifier: {EventNotifier}", eventNotifier);
|
|
logger.LogInformation("AccessHistoryEventsCapability: {AccessHistoryEventsCapability}", accessHistoryEventsCapability);
|
|
logger.LogInformation("AccessHistoryDataCapability: {AccessHistoryDataCapability}", accessHistoryDataCapability);
|
|
|
|
// If either history capability is enabled, the HistoryRead bit should be set
|
|
if (accessHistoryEventsCapability || accessHistoryDataCapability)
|
|
{
|
|
Assert.That(eventNotifier & EventNotifiers.HistoryRead,
|
|
Is.Not.Zero,
|
|
"Server EventNotifier should have HistoryRead bit set when history capabilities are enabled");
|
|
}
|
|
|
|
// Verify SubscribeToEvents bit is set (Server object should always support events)
|
|
Assert.That(eventNotifier & EventNotifiers.SubscribeToEvents,
|
|
Is.Not.Zero,
|
|
"Server EventNotifier should have SubscribeToEvents bit set");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that ServerStatus children have matching SourceTimestamp and ServerTimestamp.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerStatusTimestampsMatchAsync()
|
|
{
|
|
ILogger<ReferenceServerTests> logger = m_telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read ServerStatus children (CurrentTime, StartTime, State, etc.)
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_CurrentTime, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_StartTime, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(3));
|
|
|
|
// Verify that SourceTimestamp and ServerTimestamp are equal for all ServerStatus children
|
|
for (int i = 0; i < readResponse.Results.Count; i++)
|
|
{
|
|
DataValue result = readResponse.Results[i];
|
|
logger.LogInformation(
|
|
"NodeId: {NodeId}, SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
nodesToRead[i].NodeId,
|
|
result.SourceTimestamp,
|
|
result.ServerTimestamp);
|
|
|
|
Assert.That(result.ServerTimestamp, Is.EqualTo(result.SourceTimestamp),
|
|
$"SourceTimestamp and ServerTimestamp should be equal for {nodesToRead[i].NodeId}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the Int32Value node (ns=3;i=2808) allows historical data access.
|
|
/// Verifies the fix for issue #2520 where the node was marked as historizing
|
|
/// but history read operations returned BadHistoryOperationUnsupported.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoryReadInt32ValueNodeAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Get the NodeId for Data_Dynamic_Scalar_Int32Value
|
|
var int32ValueNodeId = new NodeId(
|
|
TestData.Variables.Data_Dynamic_Scalar_Int32Value,
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(TestData.Namespaces.TestData));
|
|
|
|
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;
|
|
|
|
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];
|
|
|
|
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))
|
|
{
|
|
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 provisioning mode - server should start with limited namespace.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ProvisioningModeTestAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
// start Ref server in provisioning mode
|
|
var fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = false,
|
|
OperationLimits = false,
|
|
DurableSubscriptionsEnabled = false,
|
|
AutoAccept = true,
|
|
ProvisioningMode = true
|
|
};
|
|
|
|
ReferenceServer server = await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
// Verify provisioning mode is enabled
|
|
Assert.That(server.ProvisioningMode, Is.True, "Server should be in provisioning mode");
|
|
|
|
// Get endpoints - in provisioning mode, anonymous authentication should not be allowed
|
|
ArrayOf<EndpointDescription> endpoints = server.GetEndpoints();
|
|
Assert.That(endpoints.IsNull, Is.False);
|
|
Assert.That(endpoints.Count, Is.GreaterThan(0), "Server should have endpoints");
|
|
|
|
// Check that anonymous token policy is not present for at least one endpoint
|
|
bool hasEndpointWithoutAnonymous = false;
|
|
foreach (EndpointDescription endpoint in endpoints)
|
|
{
|
|
bool hasAnonymous = endpoint.UserIdentityTokens.Contains(
|
|
policy => policy.TokenType == UserTokenType.Anonymous);
|
|
if (!hasAnonymous)
|
|
{
|
|
hasEndpointWithoutAnonymous = true;
|
|
break;
|
|
}
|
|
}
|
|
Assert.That(hasEndpointWithoutAnonymous,
|
|
Is.True,
|
|
"At least one endpoint should not allow anonymous authentication in provisioning mode");
|
|
|
|
// Clean up
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ArrayItemType sub-type nodes are accessible and readable.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ArrayItemTypeNodesExistAndReadableAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "YArray", new NodeId("DataAccess_ArrayItemType_YArray", 2) },
|
|
{ "XYArray", new NodeId("DataAccess_ArrayItemType_XYArray", 2) },
|
|
{ "Image", new NodeId("DataAccess_ArrayItemType_Image", 2) },
|
|
{ "Cube", new NodeId("DataAccess_ArrayItemType_Cube", 2) },
|
|
{ "NDimension", new NodeId("DataAccess_ArrayItemType_NDimension", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ValueRank }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Value read of {name} should succeed");
|
|
Assert.That(readResponse.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"DataType read of {name} should succeed");
|
|
Assert.That(readResponse.Results[2].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"ValueRank read of {name} should succeed");
|
|
|
|
TestContext.Out.WriteLine("{0}: DataType={1}, ValueRank={2}",
|
|
name,
|
|
readResponse.Results[1].WrappedValue,
|
|
readResponse.Results[2].WrappedValue);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that TriggerNode01 and TriggerNode02 exist, are readable and writable,
|
|
/// and that writing to them fires a BaseEvent.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TriggerNodesFiringEventsOnWriteAsync()
|
|
{
|
|
var triggerNode01 = new NodeId("NodeIds_Events_TriggerNode01", 2);
|
|
var triggerNode02 = new NodeId("NodeIds_Events_TriggerNode02", 2);
|
|
|
|
// 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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the Variant arrays (1D and 2D) are accessible with proper NodeIds.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task VariantArrayNodesExistAndReadableAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "Scalar_Static_Arrays_Variant", new NodeId("Scalar_Static_Arrays_Variant", 2) },
|
|
{ "Scalar_Static_Arrays2D_Variant", new NodeId("Scalar_Static_Arrays2D_Variant", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Read of {name} should succeed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that Enumeration and Image type scalar nodes are accessible.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ScalarStaticEnumerationAndImageNodesExistAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "Enumeration", new NodeId("Scalar_Static_Enumeration", 2) },
|
|
{ "Image", new NodeId("Scalar_Static_Image", 2) },
|
|
{ "ImageBMP", new NodeId("Scalar_Static_ImageBMP", 2) },
|
|
{ "ImageGIF", new NodeId("Scalar_Static_ImageGIF", 2) },
|
|
{ "ImageJPG", new NodeId("Scalar_Static_ImageJPG", 2) },
|
|
{ "ImagePNG", new NodeId("Scalar_Static_ImagePNG", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Value read of {name} should succeed");
|
|
Assert.That(readResponse.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"DataType read of {name} should succeed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the HasReferenceTypeAndSubType node exists and has the expected references.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferencesHasReferenceTypeAndSubTypeNodeExistsAsync()
|
|
{
|
|
var nodeId = new NodeId("References_HasReferenceTypeAndSubType", 2);
|
|
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");
|
|
}
|
|
|
|
/// <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>
|
|
[Test]
|
|
public async Task PermissionsRevalidatedAfterTransferSubscriptionsAndReactivationAsync()
|
|
{
|
|
// The ReferenceServer registers its nodes under namespace index 2.
|
|
// "AccessRights_RolePermissions_AuthenticatedUser" allows Browse/Read/Write only
|
|
// for WellKnownRole_AuthenticatedUser; anonymous sessions cannot read it.
|
|
var restrictedNodeId = new NodeId("AccessRights_RolePermissions_AuthenticatedUser", 2);
|
|
|
|
// Username token for user1 (AuthenticatedUser role). PolicyId "1" is the first
|
|
// user-token policy registered by the ReferenceServer (username with Basic256Sha256).
|
|
var usernameToken = new UserNameIdentityToken
|
|
{
|
|
UserName = "user1",
|
|
Password = System.Text.Encoding.UTF8.GetBytes("password").ToByteString(),
|
|
PolicyId = "1"
|
|
};
|
|
|
|
// 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, no DataChangeNotification for client handle 1 should
|
|
// appear in the response.
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
PublishResponse publishB2 = await servicesB.PublishAsync(
|
|
headerB,
|
|
[new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishB1.NotificationMessage.SequenceNumber
|
|
}]).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(publishB2.ResponseHeader);
|
|
|
|
Assert.That(
|
|
HasDataChangeNotificationForClientHandle(publishB2.NotificationMessage, 1),
|
|
Is.False,
|
|
"After re-activation as anonymous, session B must NOT receive notifications " +
|
|
"for the AuthenticatedUser-restricted node.");
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|