mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3552 lines
163 KiB
C#
3552 lines
163 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using BenchmarkDotNet.Attributes;
|
|
using Microsoft.Extensions.Logging;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Server.TestFramework;
|
|
using Opc.Ua.Test;
|
|
using Opc.Ua.Tests;
|
|
using Quickstarts.ReferenceServer;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Test Reference Server.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Server")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[NonParallelizable]
|
|
[MemoryDiagnoser]
|
|
[DisassemblyDiagnoser]
|
|
public class ReferenceServerTests
|
|
{
|
|
private const double kMaxAge = 10000;
|
|
private const uint kTimeoutHint = 10000;
|
|
private const uint kQueueSize = 5;
|
|
|
|
private const string kClientApplicationUri =
|
|
"urn:localhost:opcfoundation.org:ReferenceServerTests";
|
|
|
|
private static readonly int[] s_staticMatrixDimensions = [5, 5];
|
|
|
|
private ITelemetryContext m_telemetry;
|
|
private ServerFixture<ReferenceServer> m_fixture;
|
|
private ReferenceServer m_server;
|
|
private RequestHeader m_requestHeader;
|
|
private SecureChannelContext m_secureChannelContext;
|
|
private OperationLimits m_operationLimits = null!;
|
|
private ArrayOf<ReferenceDescription> m_referenceDescriptions;
|
|
private RandomSource m_random;
|
|
private DataGenerator m_generator;
|
|
private bool m_sessionClosed;
|
|
|
|
/// <summary>
|
|
/// Set up a Server fixture.
|
|
/// </summary>
|
|
[OneTimeSetUp]
|
|
public async Task OneTimeSetUpAsync()
|
|
{
|
|
m_telemetry = NUnitTelemetryContext.Create();
|
|
// start Ref server
|
|
m_fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = true,
|
|
OperationLimits = true,
|
|
DurableSubscriptionsEnabled = false,
|
|
UseSamplingGroupsInReferenceNodeManager = false,
|
|
AutoAccept = true
|
|
};
|
|
await m_fixture.LoadConfigurationAsync().ConfigureAwait(false);
|
|
m_fixture.Config.ServerConfiguration!.UserTokenPolicies =
|
|
[
|
|
new UserTokenPolicy(UserTokenType.Anonymous),
|
|
new UserTokenPolicy(UserTokenType.UserName)
|
|
];
|
|
m_server = await m_fixture.StartAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down the server fixture.
|
|
/// </summary>
|
|
[OneTimeTearDown]
|
|
public async Task OneTimeTearDownAsync()
|
|
{
|
|
await m_fixture.StopAsync().ConfigureAwait(false);
|
|
Thread.Sleep(1000);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a session for a test.
|
|
/// </summary>
|
|
[SetUp]
|
|
public async Task SetUpAsync()
|
|
{
|
|
(m_requestHeader, m_secureChannelContext) = await m_server.CreateAndActivateSessionAsync(
|
|
TestContext.CurrentContext.Test.Name,
|
|
clientApplicationUri: kClientApplicationUri).ConfigureAwait(false);
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
m_requestHeader.TimeoutHint = kTimeoutHint;
|
|
m_random = new RandomSource();
|
|
m_generator = new DataGenerator(m_random, m_telemetry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down the test session.
|
|
/// </summary>
|
|
[TearDown]
|
|
public async Task TearDownAsync()
|
|
{
|
|
if (!m_sessionClosed)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, CancellationToken.None).ConfigureAwait(false);
|
|
m_requestHeader = null!;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set up a Reference Server a session
|
|
/// </summary>
|
|
[GlobalSetup]
|
|
public async Task GlobalSetupAsync()
|
|
{
|
|
// start Ref server
|
|
m_fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = true
|
|
};
|
|
m_server = await m_fixture.StartAsync(null!).ConfigureAwait(false);
|
|
(m_requestHeader, m_secureChannelContext) = await m_server.CreateAndActivateSessionAsync("Bench").ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tear down Server and the close the session.
|
|
/// </summary>
|
|
[GlobalCleanup]
|
|
public async Task GlobalCleanupAsync()
|
|
{
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
await m_fixture.StopAsync().ConfigureAwait(false);
|
|
Thread.Sleep(1000);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test for expected exceptions.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task NoInvalidTimestampExceptionAsync()
|
|
{
|
|
// test that the server accepts an invalid timestamp
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now - TimeSpan.FromDays(30);
|
|
await m_server.CloseSessionAsync(m_secureChannelContext, m_requestHeader, false, RequestLifetime.None).ConfigureAwait(false);
|
|
m_sessionClosed = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Endpoints.
|
|
/// </summary>
|
|
[Test]
|
|
public void GetEndpoints()
|
|
{
|
|
ArrayOf<EndpointDescription> endpoints = m_server.GetEndpoints();
|
|
Assert.That(endpoints.IsNull, Is.False);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Operation limits.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(100)]
|
|
public async Task GetOperationLimitsAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId =
|
|
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds
|
|
.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall
|
|
}
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIdCollection,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
|
|
ArrayOf<DataValue> results = readResponse.Results;
|
|
Assert.That(results.IsNull, Is.False);
|
|
Assert.That(results.Count, Is.EqualTo(readIdCollection.Count));
|
|
|
|
m_operationLimits = new OperationLimits
|
|
{
|
|
MaxNodesPerRead = (uint)results[0].WrappedValue,
|
|
MaxNodesPerHistoryReadData = (uint)results[1].WrappedValue,
|
|
MaxNodesPerHistoryReadEvents = (uint)results[2].WrappedValue,
|
|
MaxNodesPerWrite = (uint)results[3].WrappedValue,
|
|
MaxNodesPerHistoryUpdateData = (uint)results[4].WrappedValue,
|
|
MaxNodesPerHistoryUpdateEvents = (uint)results[5].WrappedValue,
|
|
MaxNodesPerBrowse = (uint)results[6].WrappedValue,
|
|
MaxMonitoredItemsPerCall = (uint)results[7].WrappedValue,
|
|
MaxNodesPerNodeManagement = (uint)results[8].WrappedValue,
|
|
MaxNodesPerRegisterNodes = (uint)results[9].WrappedValue,
|
|
MaxNodesPerTranslateBrowsePathsToNodeIds = (uint)results[10].WrappedValue,
|
|
MaxNodesPerMethodCall = (uint)results[11].WrappedValue
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read node.
|
|
/// </summary>
|
|
[Test]
|
|
[Benchmark]
|
|
public async Task ReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = new NodeId("Scalar_Simulation_Int32", 2);
|
|
var nodesToRead = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new ReadValueId { NodeId = nodeId, AttributeId = attributeId })
|
|
.ToArrayOf();
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read all nodes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReadAllNodesAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
if (m_referenceDescriptions.IsNull)
|
|
{
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
// Read all variables
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
foreach (ReferenceDescription reference in m_referenceDescriptions.ToList())
|
|
{
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = ExpandedNodeId.ToNodeId(
|
|
reference.NodeId,
|
|
m_server.CurrentInstance.NamespaceUris);
|
|
var nodesToRead = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new ReadValueId { NodeId = nodeId, AttributeId = attributeId })
|
|
.ToArrayOf();
|
|
TestContext.Out.WriteLine("NodeId {0} {1}", reference.NodeId, reference.BrowseName);
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
|
|
foreach (DataValue dataValue in readResponse.Results)
|
|
{
|
|
TestContext.Out.WriteLine(" {0}", dataValue.ToString());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Write Node.
|
|
/// </summary>
|
|
[Test]
|
|
[Benchmark]
|
|
public async Task WriteAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Write
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
var nodeId = new NodeId("Scalar_Simulation_Int32", 2);
|
|
var nodesToWrite = ServerFixtureUtils.AttributesIds.Keys
|
|
.Select(attributeId => new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(1234)
|
|
})
|
|
.ToArrayOf();
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
writeResponse.DiagnosticInfos,
|
|
writeResponse.Results,
|
|
writeResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ReferenceNodeManager variables update their SourceTimestamp on read.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(340)]
|
|
public async Task ReferenceNodeManagerVariablesUpdateTimestampOnReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read a variable from the ReferenceNodeManager (namespace index 2)
|
|
var nodeId = new NodeId("Scalar_Static_Byte", 2);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// First read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeFirstRead = DateTimeUtc.Now;
|
|
ReadResponse firstReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(firstReadResponse, Is.Not.Null);
|
|
Assert.That(firstReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(firstReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue firstValue = firstReadResponse.Results[0];
|
|
Assert.That(firstValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(firstValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("First read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
firstValue.SourceTimestamp, firstValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the timestamp is recent (not startup time)
|
|
Assert.That((long)firstValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeFirstRead.SubtractMilliseconds(1000)),
|
|
"SourceTimestamp should be close to the read time, not the server startup time");
|
|
|
|
// Wait a bit to ensure time difference
|
|
await Task.Delay(1500).ConfigureAwait(false);
|
|
|
|
// Second read
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeSecondRead = DateTimeUtc.Now;
|
|
ReadResponse secondReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(secondReadResponse, Is.Not.Null);
|
|
Assert.That(secondReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(secondReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue secondValue = secondReadResponse.Results[0];
|
|
Assert.That(secondValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(secondValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Second read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
secondValue.SourceTimestamp, secondValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the second timestamp is more recent than the first
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThan((long)firstValue.SourceTimestamp),
|
|
"SourceTimestamp should be updated on each read");
|
|
|
|
// Verify the second timestamp is recent
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeSecondRead.SubtractMilliseconds(1000)),
|
|
"SourceTimestamp should be close to the second read time");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the ReferenceNodeManager SelectionList variable exposes its
|
|
/// value.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerSelectionListVariableAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
|
|
var valueNodeId = new NodeId("DataAccess_SelectionList_Colors", namespaceIndex);
|
|
var selectionsNodeId = new NodeId(
|
|
"DataAccess_SelectionList_Colors_Selections",
|
|
namespaceIndex);
|
|
var descriptionsNodeId = new NodeId(
|
|
"DataAccess_SelectionList_Colors_SelectionDescriptions",
|
|
namespaceIndex);
|
|
var restrictToListNodeId = new NodeId(
|
|
"DataAccess_SelectionList_Colors_RestrictToList",
|
|
namespaceIndex);
|
|
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = valueNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = valueNodeId, AttributeId = Attributes.DataType },
|
|
new ReadValueId { NodeId = selectionsNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = selectionsNodeId, AttributeId = Attributes.DataType },
|
|
new ReadValueId { NodeId = descriptionsNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = restrictToListNodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse, Is.Not.Null);
|
|
Assert.That(readResponse.Results.IsNull, Is.False);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(nodesToRead.Count));
|
|
|
|
DataValue value = readResponse.Results[0];
|
|
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(value.WrappedValue.GetString(), Is.EqualTo("Red"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[1].WrappedValue.TryGetValue(out NodeId valueDataType),
|
|
Is.True);
|
|
Assert.That(valueDataType, Is.EqualTo(DataTypeIds.String));
|
|
|
|
Assert.That(
|
|
readResponse.Results[2].WrappedValue.TryGetValue(
|
|
out ArrayOf<string> selections),
|
|
Is.True);
|
|
Assert.That(selections, Has.Count.EqualTo(3));
|
|
Assert.That(selections[0], Is.EqualTo("Red"));
|
|
Assert.That(selections[1], Is.EqualTo("Green"));
|
|
Assert.That(selections[2], Is.EqualTo("Blue"));
|
|
Assert.That(
|
|
readResponse.Results[3].WrappedValue.TryGetValue(out NodeId selectionsDataType),
|
|
Is.True);
|
|
Assert.That(selectionsDataType, Is.EqualTo(DataTypeIds.String));
|
|
|
|
Assert.That(
|
|
readResponse.Results[4].WrappedValue.TryGetValue(
|
|
out ArrayOf<LocalizedText> selectionDescriptions),
|
|
Is.True);
|
|
Assert.That(selectionDescriptions, Has.Count.EqualTo(3));
|
|
Assert.That(selectionDescriptions[0].Text, Is.EqualTo("The color red"));
|
|
Assert.That(readResponse.Results[5].WrappedValue.GetBoolean(), Is.True);
|
|
|
|
ArrayOf<WriteValue> nodesToWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = valueNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue("Green")
|
|
},
|
|
new WriteValue
|
|
{
|
|
NodeId = valueNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue("Purple")
|
|
},
|
|
new WriteValue
|
|
{
|
|
NodeId = valueNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue("Red")
|
|
}
|
|
];
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(writeResponse.Results, Has.Count.EqualTo(nodesToWrite.Count));
|
|
Assert.That(writeResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(writeResponse.Results[1], Is.EqualTo(StatusCodes.BadOutOfRange));
|
|
Assert.That(writeResponse.Results[2], Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that the discrete DataAccess node seed values (TrueState/FalseState,
|
|
/// EnumStrings, EnumValues, ValueAsText) baked into the NodeSet2 model are
|
|
/// materialized at runtime by the source generator.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerDiscreteSeedValuesAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
|
|
var twoStateTrueId = new NodeId(
|
|
"DataAccess_TwoStateDiscreteType_DataAccess_TwoStateDiscreteType_001_TrueState",
|
|
namespaceIndex);
|
|
var twoStateFalseId = new NodeId(
|
|
"DataAccess_TwoStateDiscreteType_DataAccess_TwoStateDiscreteType_001_FalseState",
|
|
namespaceIndex);
|
|
var multiStateEnumStringsId = new NodeId(
|
|
"DataAccess_MultiStateDiscreteType_DataAccess_MultiStateDiscreteType_001_EnumStrings",
|
|
namespaceIndex);
|
|
var multiStateValueEnumValuesId = new NodeId(
|
|
"DataAccess_MultiStateValueDiscreteType_DataAccess_MultiStateValueDiscreteType_001_EnumValues",
|
|
namespaceIndex);
|
|
var multiStateValueAsTextId = new NodeId(
|
|
"DataAccess_MultiStateValueDiscreteType_DataAccess_MultiStateValueDiscreteType_001_ValueAsText",
|
|
namespaceIndex);
|
|
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = twoStateTrueId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = twoStateFalseId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = multiStateEnumStringsId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = multiStateValueEnumValuesId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = multiStateValueAsTextId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse, Is.Not.Null);
|
|
Assert.That(readResponse.Results.IsNull, Is.False);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(nodesToRead.Count));
|
|
|
|
foreach (DataValue result in readResponse.Results)
|
|
{
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
Assert.That(
|
|
readResponse.Results[0].WrappedValue.TryGetValue(out LocalizedText trueState),
|
|
Is.True);
|
|
Assert.That(trueState.Text, Is.EqualTo("red"));
|
|
Assert.That(
|
|
readResponse.Results[1].WrappedValue.TryGetValue(out LocalizedText falseState),
|
|
Is.True);
|
|
Assert.That(falseState.Text, Is.EqualTo("blue"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[2].WrappedValue.TryGetValue(
|
|
out ArrayOf<LocalizedText> enumStrings),
|
|
Is.True);
|
|
Assert.That(enumStrings, Has.Count.EqualTo(3));
|
|
Assert.That(enumStrings[0].Text, Is.EqualTo("open"));
|
|
Assert.That(enumStrings[1].Text, Is.EqualTo("closed"));
|
|
Assert.That(enumStrings[2].Text, Is.EqualTo("jammed"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[3].WrappedValue.TryGetValue(
|
|
out ArrayOf<ExtensionObject> enumValues),
|
|
Is.True);
|
|
Assert.That(enumValues, Has.Count.EqualTo(3));
|
|
Assert.That(
|
|
enumValues[0].TryGetValue(out EnumValueType? firstEnumValue),
|
|
Is.True);
|
|
Assert.That(firstEnumValue!.Value, Is.Zero);
|
|
Assert.That(firstEnumValue.DisplayName.Text, Is.EqualTo("open"));
|
|
|
|
Assert.That(
|
|
readResponse.Results[4].WrappedValue.TryGetValue(out LocalizedText valueAsText),
|
|
Is.True);
|
|
Assert.That(valueAsText.Text, Is.EqualTo("open"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that AccessLevelEx retains the same base access bits as AccessLevel.
|
|
/// </summary>
|
|
[TestCase("Scalar_Static_NonatomicReadWrite")]
|
|
[TestCase("AccessRights_AccessAll_NonatomicReadWrite")]
|
|
public async Task ReferenceNodeManagerNonatomicAccessLevelsAreConsistentAsync(string identifier)
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId(identifier, namespaceIndex);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevel },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.UserAccessLevel },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevelEx }
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
// the reference server grants StatusWrite and TimestampWrite on every
|
|
// writable variable (OPC 10000-3 8.57).
|
|
const byte expectedAccessLevel = (byte)(
|
|
AccessLevels.CurrentReadOrWrite |
|
|
AccessLevels.StatusWrite |
|
|
AccessLevels.TimestampWrite);
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
Assert.That(response.Results[0].WrappedValue.GetByte(),
|
|
Is.EqualTo(expectedAccessLevel));
|
|
Assert.That(response.Results[1].WrappedValue.GetByte(),
|
|
Is.EqualTo(expectedAccessLevel));
|
|
|
|
uint accessLevelEx = response.Results[2].WrappedValue.GetUInt32();
|
|
Assert.That(
|
|
accessLevelEx & 0xff,
|
|
Is.EqualTo((uint)expectedAccessLevel));
|
|
Assert.That(
|
|
accessLevelEx & (uint)AccessLevelExType.NonatomicRead,
|
|
Is.EqualTo((uint)AccessLevelExType.NonatomicRead));
|
|
Assert.That(
|
|
accessLevelEx & (uint)AccessLevelExType.NonatomicWrite,
|
|
Is.EqualTo((uint)AccessLevelExType.NonatomicWrite));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The ArrayItemType engineering units and axis definitions carry a UNECE unit, and the
|
|
/// Float AnalogItemType has a non-empty EURange, so the CTT Engineering Units and
|
|
/// PercentDeadband test cases do not skip the nodes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerDataAccessUnitsAndRangesAreConfiguredAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
string[] arrayItems = ["YArray", "XYArray", "Image", "Cube", "NDimension"];
|
|
var nodesToRead = new List<ReadValueId>();
|
|
foreach (string item in arrayItems)
|
|
{
|
|
nodesToRead.Add(new ReadValueId
|
|
{
|
|
NodeId = new NodeId(
|
|
$"DataAccess_ArrayItemType_DataAccess_ArrayItemType_{item}_EngineeringUnits",
|
|
namespaceIndex),
|
|
AttributeId = Attributes.Value
|
|
});
|
|
}
|
|
nodesToRead.Add(new ReadValueId
|
|
{
|
|
NodeId = new NodeId(
|
|
"DataAccess_ArrayItemType_DataAccess_ArrayItemType_Cube_ZAxisDefinition",
|
|
namespaceIndex),
|
|
AttributeId = Attributes.Value
|
|
});
|
|
nodesToRead.Add(new ReadValueId
|
|
{
|
|
NodeId = new NodeId(
|
|
"DataAccess_AnalogType_DataAccess_AnalogType_Float_EURange",
|
|
namespaceIndex),
|
|
AttributeId = Attributes.Value
|
|
});
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead.ToArrayOf(),
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int ii = 0; ii < arrayItems.Length; ii++)
|
|
{
|
|
Assert.That(response.Results[ii].StatusCode, Is.EqualTo(StatusCodes.Good), arrayItems[ii]);
|
|
Assert.That(response.Results[ii].WrappedValue.TryGetValue(out ExtensionObject extension), Is.True);
|
|
Assert.That(extension.TryGetValue(out EUInformation? units), Is.True, arrayItems[ii]);
|
|
Assert.That(units!.NamespaceUri, Is.EqualTo("http://www.opcfoundation.org/UA/units/un/cefact"));
|
|
Assert.That(units.UnitId, Is.EqualTo(12890), arrayItems[ii]);
|
|
Assert.That(units.DisplayName.Text, Is.EqualTo("mV"));
|
|
Assert.That(units.Description.Text, Is.EqualTo("millivolt"));
|
|
}
|
|
|
|
DataValue axis = response.Results[arrayItems.Length];
|
|
Assert.That(axis.WrappedValue.TryGetValue(out ExtensionObject axisExtension), Is.True);
|
|
Assert.That(axisExtension.TryGetValue(out AxisInformation? axisInformation), Is.True);
|
|
Assert.That(axisInformation!.EngineeringUnits.UnitId, Is.EqualTo(5457219));
|
|
Assert.That(axisInformation.EngineeringUnits.DisplayName.Text, Is.EqualTo("s"));
|
|
|
|
DataValue range = response.Results[arrayItems.Length + 1];
|
|
Assert.That(range.WrappedValue.TryGetValue(out ExtensionObject rangeExtension), Is.True);
|
|
Assert.That(rangeExtension.TryGetValue(out Range? euRange), Is.True);
|
|
Assert.That(euRange!.High, Is.GreaterThan(euRange.Low));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every static two-dimensional sample array is 5x5 so that the CTT multi-dimensional
|
|
/// IndexRange test cases (for example "0,2:4") can use it.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerStaticMatricesAreFiveByFiveAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
string[] suffixes =
|
|
[
|
|
"Boolean", "Byte", "ByteString", "DateTime", "Double", "Duration", "Float", "Guid",
|
|
"Int16", "Int32", "Int64", "Integer", "LocaleId", "LocalizedText", "NodeId", "Number",
|
|
"QualifiedName", "SByte", "String", "UInt16", "UInt32", "UInt64", "UInteger", "UtcTime",
|
|
"Variant", "XmlElement"
|
|
];
|
|
var nodesToRead = new List<ReadValueId>();
|
|
foreach (string suffix in suffixes)
|
|
{
|
|
var nodeId = new NodeId("Scalar_Static_Arrays2D_" + suffix, namespaceIndex);
|
|
nodesToRead.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ArrayDimensions });
|
|
nodesToRead.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value });
|
|
}
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead.ToArrayOf(),
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int ii = 0; ii < suffixes.Length; ii++)
|
|
{
|
|
DataValue dimensions = response.Results[2 * ii];
|
|
DataValue value = response.Results[(2 * ii) + 1];
|
|
Assert.That(dimensions.StatusCode, Is.EqualTo(StatusCodes.Good), suffixes[ii]);
|
|
Assert.That(dimensions.WrappedValue.TryGetValue(out ArrayOf<uint> arrayDimensions), Is.True, suffixes[ii]);
|
|
Assert.That(arrayDimensions.ToArray(), Is.EqualTo(s_staticMatrixDimensions), suffixes[ii]);
|
|
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good), suffixes[ii]);
|
|
Assert.That(value.WrappedValue.TypeInfo.ValueRank, Is.EqualTo(ValueRanks.TwoDimensions), suffixes[ii]);
|
|
Assert.That(value.WrappedValue.AsBoxedObject(), Is.InstanceOf<IConvertableToMatrix>(), suffixes[ii]);
|
|
var matrix = ((IConvertableToMatrix)value.WrappedValue.AsBoxedObject())
|
|
.ToMatrix(value.WrappedValue.TypeInfo.BuiltInType);
|
|
Assert.That(matrix.Dimensions, Is.EqualTo(s_staticMatrixDimensions), suffixes[ii]);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// DataAccess_DataItem_String does not provide the optional MinimumSamplingInterval attribute
|
|
/// (CTT Base Info Server Capabilities 2 002.js).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerDataItemHasNoMinimumSamplingIntervalAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId("DataAccess_DataItem_String", namespaceIndex);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.MinimumSamplingInterval },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.BadAttributeIdInvalid));
|
|
Assert.That(response.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
|
|
// the node can still be monitored; the sampling interval is revised as usual.
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
CreateSubscriptionResponse subscription = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
try
|
|
{
|
|
CreateMonitoredItemsResponse items = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscription.SubscriptionId,
|
|
TimestampsToReturn.Neither,
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
]).ConfigureAwait(false);
|
|
Assert.That(items.Results, Has.Count.EqualTo(1));
|
|
Assert.That(items.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(items.Results[0].RevisedSamplingInterval, Is.GreaterThanOrEqualTo(0));
|
|
}
|
|
finally
|
|
{
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscription.SubscriptionId]).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scalar_Static_NonHistorizing_Boolean advertises HistoryRead access but is not
|
|
/// historized; a HistoryRead returns Bad_HistoryOperationUnsupported (Part 4 §5.11.3,
|
|
/// CTT Historical Access Read Raw Err-025.js).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoryReadNonHistorizingNodeIsUnsupportedAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId("Scalar_Static_NonHistorizing_Boolean", namespaceIndex);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Historizing },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevel },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
Assert.That(readResponse.Results[0].WrappedValue.GetBoolean(), Is.False);
|
|
Assert.That(
|
|
readResponse.Results[1].WrappedValue.GetByte() & AccessLevels.HistoryRead,
|
|
Is.EqualTo(AccessLevels.HistoryRead));
|
|
Assert.That(readResponse.Results[2].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
|
|
var historyReadDetails = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = DateTimeUtc.Now.SubtractMilliseconds(60 * 60 * 1000),
|
|
EndTime = DateTimeUtc.Now,
|
|
NumValuesPerNode = 10,
|
|
IsReadModified = false,
|
|
ReturnBounds = false
|
|
};
|
|
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse historyReadResponse = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
new ExtensionObject(historyReadDetails),
|
|
TimestampsToReturn.Source,
|
|
false,
|
|
[new HistoryReadValueId { NodeId = nodeId }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(historyReadResponse.Results, Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
historyReadResponse.Results[0].StatusCode.Code,
|
|
Is.EqualTo(StatusCodes.BadHistoryOperationUnsupported));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Server object references nodes with HasComponent and with its subtype HasAddIn and
|
|
/// has more than three references, so it can back the CTT setting
|
|
/// "/Server Test/NodeIds/References/Has References of a ReferenceType and SubType"
|
|
/// (View Basic 2 005/015/016/020, View Minimum Continuation Point 01 010/011).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerObjectHasReferencesOfATypeAndItsSubtypeAsync()
|
|
{
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
null,
|
|
0,
|
|
[
|
|
new BrowseDescription
|
|
{
|
|
NodeId = ObjectIds.Server,
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
|
IncludeSubtypes = true,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(1));
|
|
ReferenceDescription[] references = response.Results[0].References.ToArray()!;
|
|
Assert.That(
|
|
references!.Count(r => r.ReferenceTypeId == ReferenceTypeIds.HasComponent),
|
|
Is.GreaterThanOrEqualTo(3));
|
|
Assert.That(
|
|
references.Count(r => r.ReferenceTypeId == ReferenceTypeIds.HasAddIn),
|
|
Is.GreaterThanOrEqualTo(1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Method and View node class sample nodes reference at least two nodes of their
|
|
/// own NodeClass (CTT View Minimum Continuation Point 01 012.js, View Basic 2 018.js).
|
|
/// </summary>
|
|
[Test]
|
|
[TestCase("Methods_Void", NodeClass.Method)]
|
|
[TestCase("Views_Operations", NodeClass.View)]
|
|
public async Task NodeClassSampleNodesReferenceTheirOwnNodeClassAsync(string identifier, NodeClass nodeClass)
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
ArrayOf<BrowseDescription> nodesToBrowse =
|
|
[
|
|
new BrowseDescription
|
|
{
|
|
NodeId = new NodeId(identifier, namespaceIndex),
|
|
BrowseDirection = BrowseDirection.Both,
|
|
ReferenceTypeId = ReferenceTypeIds.References,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
null,
|
|
0,
|
|
nodesToBrowse,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(1));
|
|
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
ArrayOf<ReferenceDescription> references = response.Results[0].References;
|
|
Assert.That(references.ToArray()!.Count(r => r.NodeClass == nodeClass), Is.GreaterThanOrEqualTo(2));
|
|
Assert.That(references.ToArray()!.Count(r => r.NodeClass != nodeClass), Is.GreaterThanOrEqualTo(1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Views can only be the source of hierarchical References (Part 3 §5.4), and the
|
|
/// CTT (Base Info Core Structure 2 001.js) also rejects Views targeted by
|
|
/// non-hierarchical References.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCase("Views_Operations")]
|
|
[TestCase("Views_Engineering")]
|
|
[TestCase("Views_Maintenance")]
|
|
public async Task SampleViewsHaveNoNonHierarchicalReferencesAsync(string identifier)
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
ArrayOf<BrowseDescription> nodesToBrowse =
|
|
[
|
|
new BrowseDescription
|
|
{
|
|
NodeId = new NodeId(identifier, namespaceIndex),
|
|
BrowseDirection = BrowseDirection.Both,
|
|
ReferenceTypeId = ReferenceTypeIds.NonHierarchicalReferences,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
}
|
|
];
|
|
|
|
var requestHeader = (RequestHeader)m_requestHeader.Clone();
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
null,
|
|
0,
|
|
nodesToBrowse,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.Results, Has.Count.EqualTo(1));
|
|
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(response.Results[0].References.ToArray(), Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the ReferenceNodeManager Currency variable exposes its value
|
|
/// and its CurrencyUnit property.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerCurrencyVariableAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
|
|
var amountNodeId = new NodeId("DataAccess_Currency_Amount", namespaceIndex);
|
|
var currencyUnitNodeId = new NodeId(
|
|
"DataAccess_Currency_Amount_CurrencyUnit",
|
|
namespaceIndex);
|
|
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = amountNodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = currencyUnitNodeId, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(readResponse, Is.Not.Null);
|
|
Assert.That(readResponse.Results.IsNull, Is.False);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
DataValue amount = readResponse.Results[0];
|
|
Assert.That(amount.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(amount.WrappedValue.GetDouble(), Is.EqualTo(42.0));
|
|
|
|
DataValue currencyUnit = readResponse.Results[1];
|
|
Assert.That(currencyUnit.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(currencyUnit.WrappedValue.TryGetValue(out ExtensionObject currencyUnitObject), Is.True);
|
|
Assert.That(currencyUnitObject.TryGetValue(out CurrencyUnitType? unit), Is.True);
|
|
Assert.That(unit!.NumericCode, Is.EqualTo(978));
|
|
Assert.That(unit.Exponent, Is.EqualTo(2));
|
|
Assert.That(unit.AlphabeticCode, Is.EqualTo("EUR"));
|
|
|
|
using var stream = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(
|
|
stream,
|
|
m_server.CurrentInstance.MessageContext,
|
|
leaveOpen: true))
|
|
{
|
|
unit.Encode(encoder);
|
|
}
|
|
byte[] encoded = stream.ToArray();
|
|
Assert.That(encoded, Has.Length.GreaterThan(2));
|
|
Assert.That(encoded[0], Is.EqualTo(0xD2), "NumericCode low byte");
|
|
Assert.That(encoded[1], Is.EqualTo(0x03), "NumericCode high byte");
|
|
Assert.That(encoded[2], Is.EqualTo(0x02), "Exponent byte");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ReferenceNodeManager array variables update their SourceTimestamp on read.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferenceNodeManagerArrayVariablesUpdateTimestampOnReadAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read an array variable from the ReferenceNodeManager (namespace index 2)
|
|
var nodeId = new NodeId("Scalar_Static_Arrays_Byte", 2);
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// First read
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeFirstRead = DateTimeUtc.Now;
|
|
ReadResponse firstReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(firstReadResponse, Is.Not.Null);
|
|
Assert.That(firstReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(firstReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue firstValue = firstReadResponse.Results[0];
|
|
Assert.That(firstValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(firstValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Array First read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
firstValue.SourceTimestamp, firstValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the timestamp is recent (not startup time)
|
|
Assert.That((long)firstValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeFirstRead.SubtractMilliseconds(1000)),
|
|
"Array SourceTimestamp should be close to the read time, not the server startup time");
|
|
|
|
// Wait a bit to ensure time difference
|
|
await Task.Delay(1500).ConfigureAwait(false);
|
|
|
|
// Second read
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
DateTimeUtc timeBeforeSecondRead = DateTimeUtc.Now;
|
|
ReadResponse secondReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(secondReadResponse, Is.Not.Null);
|
|
Assert.That(secondReadResponse.Results.IsNull, Is.False);
|
|
Assert.That(secondReadResponse.Results.Count, Is.EqualTo(1));
|
|
DataValue secondValue = secondReadResponse.Results[0];
|
|
Assert.That(secondValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(secondValue.SourceTimestamp.IsNull, Is.False);
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Array Second read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
secondValue.SourceTimestamp, secondValue.ServerTimestamp);
|
|
}
|
|
|
|
// Verify the second timestamp is more recent than the first
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThan((long)firstValue.SourceTimestamp),
|
|
"Array SourceTimestamp should be updated on each read");
|
|
|
|
// Verify the second timestamp is recent
|
|
Assert.That((long)secondValue.SourceTimestamp, Is.GreaterThanOrEqualTo((long)timeBeforeSecondRead.SubtractMilliseconds(1000)),
|
|
"Array SourceTimestamp should be close to the second read time");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update static Nodes, read modify write.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(350)]
|
|
public async Task ReadWriteUpdateNodesAsync()
|
|
{
|
|
// Nodes
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Browse full address space.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(400)]
|
|
[Benchmark]
|
|
public async Task BrowseFullAddressSpaceAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Translate references.
|
|
/// </summary>
|
|
[Test]
|
|
[Order(500)]
|
|
[Benchmark]
|
|
public async Task TranslateBrowsePathAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
if (m_operationLimits == null)
|
|
{
|
|
await GetOperationLimitsAsync().ConfigureAwait(false);
|
|
}
|
|
if (m_referenceDescriptions.IsNull)
|
|
{
|
|
m_referenceDescriptions = await CommonTestWorkers.BrowseFullAddressSpaceWorkerAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
m_operationLimits).ConfigureAwait(false);
|
|
}
|
|
_ = await CommonTestWorkers.TranslateBrowsePathWorkerAsync(
|
|
serverTestServices,
|
|
m_referenceDescriptions,
|
|
m_requestHeader,
|
|
m_operationLimits!).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Read a few notifications with Publish.
|
|
/// Delete the monitored item and subscription.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SubscriptionAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
await CommonTestWorkers.SubscriptionTestAsync(serverTestServices, m_requestHeader).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a single session + subscription and raise a single event on the server node.
|
|
/// Validates all the event fields are properly received.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerEventSubscribeTestAsync()
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
|
|
CreateSubscriptionResponse createSubscriptionResponse = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
|
|
uint subscriptionId = createSubscriptionResponse.SubscriptionId;
|
|
|
|
// Build an event filter for the base event type.
|
|
var eventFilter = new EventFilter();
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.EventId));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.EventType));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.SourceNode));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.SourceName));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Time));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Message));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
QualifiedName.From(BrowseNames.Severity));
|
|
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems = [
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 100,
|
|
DiscardOldest = true,
|
|
Filter = new ExtensionObject(eventFilter)
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse createItemsResponse = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(createItemsResponse.ResponseHeader, createItemsResponse.Results, monitoredItems);
|
|
Assert.AreEqual(1, createItemsResponse.Results.Count);
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[0].StatusCode));
|
|
|
|
// Generate event directly on the server
|
|
IServerInternal serverInternal = m_server.CurrentInstance;
|
|
ISystemContext serverContext = serverInternal.DefaultSystemContext;
|
|
var e = new BaseEventState(null);
|
|
const string eventMessage = "Integration Test Event";
|
|
e.Initialize(
|
|
serverContext,
|
|
serverInternal.ServerObject,
|
|
EventSeverity.Medium,
|
|
new LocalizedText(eventMessage));
|
|
serverInternal.ReportEvent(serverContext, e);
|
|
|
|
// Wait for subscription to deliver the event
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
|
|
// Publish request to get the event notification
|
|
var acknowledgements = new ArrayOf<SubscriptionAcknowledgement>();
|
|
PublishResponse publishResponse = await services.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.IsNotNull(publishResponse.NotificationMessage);
|
|
Assert.IsNotNull(publishResponse.NotificationMessage.NotificationData);
|
|
Assert.IsTrue(publishResponse.NotificationMessage.NotificationData.Count > 0);
|
|
|
|
publishResponse.NotificationMessage.NotificationData[0].TryGetValue(out EventNotificationList? eventNotification);
|
|
Assert.IsNotNull(eventNotification);
|
|
Assert.IsTrue(eventNotification.Events.Count > 0);
|
|
|
|
EventFieldList targetEvent = eventNotification.Events.ToList().FirstOrDefault(
|
|
x => x.EventFields[5].TryGetValue(out LocalizedText lt) && lt.Text == eventMessage)!;
|
|
Assert.IsNotNull(targetEvent, "Did not receive the target event.");
|
|
|
|
ArrayOf<Variant> eventFields = targetEvent.EventFields;
|
|
Assert.AreEqual(7, eventFields.Count); // we requested 7 fields in select clauses
|
|
|
|
Assert.IsFalse(eventFields[0].IsNull); // EventId
|
|
Assert.IsFalse(eventFields[1].IsNull); // EventType
|
|
Assert.IsFalse(eventFields[2].IsNull); // SourceNode
|
|
Assert.IsFalse(eventFields[3].IsNull); // SourceName
|
|
Assert.IsFalse(eventFields[4].IsNull); // Time
|
|
Assert.That(eventFields[5].TryGetValue(out LocalizedText receivedMessage), Is.True); // Message
|
|
Assert.IsFalse(receivedMessage.IsNull);
|
|
Assert.AreEqual(eventMessage, receivedMessage.Text);
|
|
Assert.That(eventFields[6].TryGetValue(out ushort receiveSeverity), Is.True);
|
|
Assert.AreEqual((ushort)EventSeverity.Medium, receiveSeverity); // Severity
|
|
|
|
// Delete subscription
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscriptionId]).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create multiple sessions, each with a subscription.
|
|
/// Close all sessions without deleting subscriptions (abandoning them).
|
|
/// Concurrently delete the abandoned subscriptions through server-owned cleanup.
|
|
/// Verifies that the concurrent dictionary backing abandoned subscriptions
|
|
/// handles parallel access correctly (fix for issue #3612).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task DeleteAbandonedSubscriptionsConcurrentlyAsync()
|
|
{
|
|
const int sessionCount = 5;
|
|
var subscriptionIds = new List<uint>();
|
|
var subscriptionManager = (SubscriptionManager)m_server.CurrentInstance.SubscriptionManager;
|
|
int originalSubscriptionCount = subscriptionManager.GetSubscriptions().Count;
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
|
|
// Create multiple sessions, each with a subscription, then close
|
|
// the session without deleting subscriptions so they become abandoned.
|
|
for (int i = 0; i < sessionCount; i++)
|
|
{
|
|
(RequestHeader header, SecureChannelContext context) =
|
|
await m_server.CreateAndActivateSessionAsync($"AbandonSession_{i}")
|
|
.ConfigureAwait(false);
|
|
|
|
var services = new ServerTestServices(m_server, context);
|
|
header.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<uint> ids = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
services, header, testSet, kQueueSize, -1).ConfigureAwait(false);
|
|
subscriptionIds.AddRange(ids.ToList());
|
|
|
|
// Close session without deleting subscriptions - makes them abandoned
|
|
header.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(context, header, false, RequestLifetime.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
Assert.That(subscriptionIds, Has.Count.EqualTo(sessionCount));
|
|
Assert.That(subscriptionIds, Is.Unique);
|
|
foreach (uint id in subscriptionIds)
|
|
{
|
|
Assert.That(subscriptionManager.TryGetSubscription(id, out ISubscription? subscription), Is.True);
|
|
Assert.That(subscription!.Session, Is.Null);
|
|
}
|
|
Assert.That(subscriptionManager.CaptureAbandonedPublishTimerSnapshot()
|
|
.Select(subscription => subscription.Id), Is.SupersetOf(subscriptionIds));
|
|
|
|
// A session cannot delete another session's abandoned subscription.
|
|
// Server-owned cleanup uses the same null context as ServerInternalData.
|
|
var deleteStart = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
Task<StatusCode>[] deleteTasks =
|
|
[
|
|
.. subscriptionIds.Select(async id =>
|
|
{
|
|
await deleteStart.Task.ConfigureAwait(false);
|
|
return await subscriptionManager.DeleteSubscriptionAsync(null!, id).ConfigureAwait(false);
|
|
})
|
|
];
|
|
deleteStart.SetResult(true);
|
|
StatusCode[] results = await Task.WhenAll(deleteTasks).ConfigureAwait(false);
|
|
|
|
Assert.That(results, Has.Length.EqualTo(sessionCount));
|
|
foreach (StatusCode result in results)
|
|
{
|
|
Assert.That(result, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
foreach (uint id in subscriptionIds)
|
|
{
|
|
Assert.That(subscriptionManager.TryGetSubscription(id, out _), Is.False);
|
|
}
|
|
Assert.That(subscriptionManager.GetSubscriptions(), Has.Count.EqualTo(originalSubscriptionCount));
|
|
Assert.That(subscriptionManager.CaptureAbandonedPublishTimerSnapshot()
|
|
.Select(subscription => subscription.Id).Intersect(subscriptionIds), Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a secondary Session.
|
|
/// Create a subscription with a monitored item.
|
|
/// Close session, but do not delete subscriptions.
|
|
/// Transfer subscription from closed session to the other.
|
|
/// </summary>
|
|
[Theory]
|
|
public async Task TransferSubscriptionSessionClosedAsync(bool sendInitialData, bool useSecurity)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferContext) = await m_server.CreateAndActivateSessionAsync(
|
|
"ClosedSession",
|
|
useSecurity,
|
|
clientApplicationUri: kClientApplicationUri).ConfigureAwait(false);
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
serverTestServices.SecureChannelContext = transferContext;
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
transferRequestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(transferContext, transferRequestHeader, false, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
//restore security context, transfer abandoned subscription
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
await CommonTestWorkers.TransferSubscriptionTestAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
sendInitialData,
|
|
!useSecurity).ConfigureAwait(false);
|
|
|
|
if (useSecurity)
|
|
{
|
|
// subscription was deleted, expect 'BadNoSubscription'
|
|
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
await CommonTestWorkers.VerifySubscriptionTransferredAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
true).ConfigureAwait(false);
|
|
});
|
|
Assert.That(
|
|
sre.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadNoSubscription));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Create a secondary Session.
|
|
/// Transfer subscription with a monitored item from one session to the other.
|
|
/// </summary>
|
|
[Theory]
|
|
public async Task TransferSubscriptionAsync(bool sendInitialData, bool useSecurity)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferSecurityContext) = await m_server.CreateAndActivateSessionAsync(
|
|
"TransferSession",
|
|
useSecurity,
|
|
clientApplicationUri: kClientApplicationUri).ConfigureAwait(false);
|
|
serverTestServices.SecureChannelContext = transferSecurityContext;
|
|
await CommonTestWorkers.TransferSubscriptionTestAsync(
|
|
serverTestServices,
|
|
transferRequestHeader,
|
|
subscriptionIds,
|
|
sendInitialData,
|
|
!useSecurity).ConfigureAwait(false);
|
|
|
|
if (useSecurity)
|
|
{
|
|
//restore security context
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
await CommonTestWorkers.VerifySubscriptionTransferredAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
subscriptionIds,
|
|
true).ConfigureAwait(false);
|
|
}
|
|
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(transferSecurityContext, transferRequestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an anonymous Subscription cannot be transferred to a
|
|
/// Session with a different client ApplicationUri.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TransferAnonymousSubscriptionWithDifferentApplicationUriDeniedAsync()
|
|
{
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
NodeId[] testSet =
|
|
[
|
|
.. CommonTestWorkers.NodeIdTestSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
];
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
kQueueSize,
|
|
-1).ConfigureAwait(false);
|
|
|
|
(RequestHeader transferRequestHeader, SecureChannelContext transferSecurityContext) =
|
|
await m_server.CreateAndActivateSessionAsync(
|
|
"TransferSessionDifferentApplicationUri",
|
|
useSecurity: true,
|
|
clientApplicationUri:
|
|
"urn:localhost:opcfoundation.org:DifferentServerFixtureClient")
|
|
.ConfigureAwait(false);
|
|
try
|
|
{
|
|
serverTestServices.SecureChannelContext = transferSecurityContext;
|
|
await CommonTestWorkers.TransferSubscriptionTestAsync(
|
|
serverTestServices,
|
|
transferRequestHeader,
|
|
subscriptionIds,
|
|
sendInitialData: false,
|
|
expectAccessDenied: true).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
transferRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(
|
|
transferSecurityContext,
|
|
transferRequestHeader,
|
|
true,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a subscription with a monitored item.
|
|
/// Call ResendData.
|
|
/// Ensure only a single value per monitored item is returned after ResendData was called.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCase(true, kQueueSize)]
|
|
[TestCase(false, kQueueSize)]
|
|
[TestCase(true, 0U)]
|
|
[TestCase(false, 0U)]
|
|
public async Task ResendDataAsync(bool updateValues, uint queueSize)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var serverTestServices = new ServerTestServices(m_server, m_secureChannelContext);
|
|
|
|
NamespaceTable namespaceUris = m_server.CurrentInstance.NamespaceUris;
|
|
var testSetCollection = CommonTestWorkers
|
|
.NodeIdTestSetStatic.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris))
|
|
.ToList();
|
|
testSetCollection.AddRange(
|
|
CommonTestWorkers.NodeIdTestDataSetStatic
|
|
.Select(n => ExpandedNodeId.ToNodeId(n, namespaceUris)));
|
|
NodeId[] testSet = [.. testSetCollection];
|
|
|
|
//Re-use method CreateSubscriptionForTransfer to create a subscription
|
|
ArrayOf<uint> subscriptionIds = await CommonTestWorkers.CreateSubscriptionForTransferAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
testSet,
|
|
queueSize,
|
|
0).ConfigureAwait(false);
|
|
|
|
(RequestHeader resendDataRequestHeader, SecureChannelContext resendDataSecurityContext) =
|
|
await m_server.CreateAndActivateSessionAsync("ResendData").ConfigureAwait(false);
|
|
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
// After the ResendData call there will be data to publish again
|
|
ArrayOf<CallMethodRequest> nodesToCall = await ResendDataCallAsync(
|
|
StatusCodes.Good,
|
|
subscriptionIds).ConfigureAwait(false);
|
|
|
|
Thread.Sleep(1000);
|
|
|
|
// Make sure publish queue becomes empty by consuming it
|
|
Assert.That(subscriptionIds.Count, Is.EqualTo(1));
|
|
|
|
// Issue a Publish request
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements = [];
|
|
PublishResponse publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.EqualTo(1));
|
|
|
|
// Validate nothing to publish a few times
|
|
const int timesToCallPublish = 3;
|
|
for (int i = 0; i < timesToCallPublish; i++)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
}
|
|
|
|
// Validate ResendData method call returns error from different session contexts
|
|
|
|
// call ResendData method from different session context
|
|
resendDataRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
CallResponse callResponse = await m_server.CallAsync(
|
|
resendDataSecurityContext,
|
|
resendDataRequestHeader,
|
|
nodesToCall, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
serverTestServices.SecureChannelContext = m_secureChannelContext;
|
|
|
|
Assert.That(callResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
|
|
ServerFixtureUtils.ValidateResponse(callResponse.ResponseHeader, callResponse.Results, nodesToCall);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
callResponse.DiagnosticInfos,
|
|
nodesToCall,
|
|
callResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
|
|
// Still nothing to publish since previous ResendData call did not execute
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
|
|
if (updateValues)
|
|
{
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
|
|
// fill queues, but only a single value per resend publish shall be returned
|
|
for (int i = 1; i < queueSize; i++)
|
|
{
|
|
//If sampling groups are used, samplingInterval needs to be waited before values are queued
|
|
if (m_fixture.UseSamplingGroupsInReferenceNodeManager)
|
|
{
|
|
await Task.Delay((int)(100.0 * 1.7)).ConfigureAwait(false);
|
|
}
|
|
await UpdateValuesAsync(testSet).ConfigureAwait(false);
|
|
}
|
|
|
|
// Wait a bit to ensure that the server has time to queue the values
|
|
await Task.Delay(1000).ConfigureAwait(false);
|
|
}
|
|
|
|
// call ResendData method from the same session context
|
|
await ResendDataCallAsync(StatusCodes.Good, subscriptionIds).ConfigureAwait(false);
|
|
|
|
// Data should be available for publishing now
|
|
int totalNotifications = await CollectNotificationsAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
acknowledgements,
|
|
subscriptionIds[0],
|
|
testSet.Length).ConfigureAwait(false);
|
|
|
|
Assert.That(totalNotifications, Is.EqualTo(testSet.Length),
|
|
"One MonitoredItemNotification should be returned for each Node present in the TestSet");
|
|
|
|
await Task.Delay(1000).ConfigureAwait(false);
|
|
|
|
if (updateValues && queueSize > 1)
|
|
{
|
|
// remaining queue Data should be sent in this publish
|
|
int expectedCount = testSet.Length * ((int)queueSize - 1);
|
|
totalNotifications = await CollectNotificationsAsync(
|
|
serverTestServices,
|
|
m_requestHeader,
|
|
acknowledgements,
|
|
subscriptionIds[0],
|
|
expectedCount).ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
totalNotifications,
|
|
Is.EqualTo(expectedCount).Within(testSet.Length));
|
|
}
|
|
|
|
// Call ResendData method with invalid subscription Id
|
|
await ResendDataCallAsync(StatusCodes.BadSubscriptionIdInvalid, [subscriptionIds[^1] + 20]).ConfigureAwait(false);
|
|
|
|
// Nothing to publish since previous ResendData call did not execute
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
publishResponse = await serverTestServices.PublishAsync(
|
|
m_requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
publishResponse.DiagnosticInfos,
|
|
acknowledgements,
|
|
publishResponse.ResponseHeader.StringTable,
|
|
serverTestServices.Logger);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionIds[0]));
|
|
Assert.That(publishResponse.NotificationMessage.NotificationData.Count, Is.Zero);
|
|
|
|
resendDataRequestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(resendDataSecurityContext, resendDataRequestHeader, true, RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<int> CollectNotificationsAsync(
|
|
ServerTestServices serverTestServices,
|
|
RequestHeader requestHeader,
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements,
|
|
uint subscriptionId,
|
|
int expectedCount)
|
|
{
|
|
int totalNotifications = 0;
|
|
int retries = 50;
|
|
while (totalNotifications < expectedCount && retries-- > 0)
|
|
{
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
PublishResponse publishResponse = await serverTestServices.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.That(publishResponse.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
ServerFixtureUtils.ValidateResponse(publishResponse.ResponseHeader);
|
|
Assert.That(publishResponse.SubscriptionId, Is.EqualTo(subscriptionId));
|
|
|
|
if (publishResponse.NotificationMessage.NotificationData.Count > 0)
|
|
{
|
|
// acknowledge the notification
|
|
acknowledgements =
|
|
[
|
|
new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishResponse.NotificationMessage.SequenceNumber
|
|
}
|
|
];
|
|
|
|
foreach (ExtensionObject item in publishResponse.NotificationMessage.NotificationData)
|
|
{
|
|
if (item.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
totalNotifications += dcn.MonitoredItems.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalNotifications >= expectedCount)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (!publishResponse.MoreNotifications)
|
|
{
|
|
// Wait for potential background processing
|
|
await Task.Delay(100).ConfigureAwait(false);
|
|
}
|
|
}
|
|
return totalNotifications;
|
|
}
|
|
|
|
private async Task<ArrayOf<CallMethodRequest>> ResendDataCallAsync(
|
|
StatusCode expectedStatus,
|
|
ArrayOf<uint> subscriptionIds)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Find the ResendData method
|
|
ArrayOf<CallMethodRequest> nodesToCall = subscriptionIds
|
|
.ConvertAll(subscriptionId => new CallMethodRequest
|
|
{
|
|
ObjectId = ObjectIds.Server,
|
|
MethodId = MethodIds.Server_ResendData,
|
|
InputArguments = [new Variant(subscriptionId)]
|
|
});
|
|
|
|
//call ResendData method with subscription ids
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
CallResponse callResponse = await m_server.CallAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
nodesToCall, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(callResponse.Results[0].StatusCode, Is.EqualTo(expectedStatus));
|
|
ServerFixtureUtils.ValidateResponse(callResponse.ResponseHeader, callResponse.Results, nodesToCall);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
callResponse.DiagnosticInfos,
|
|
nodesToCall,
|
|
callResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
|
|
return nodesToCall;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read Values of NodeIds, determine types, write back new random values.
|
|
/// </summary>
|
|
/// <param name="testSet">The nodeIds to modify.</param>
|
|
private async Task UpdateValuesAsync(NodeId[] testSet)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read values
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
var nodesToRead = testSet
|
|
.Select(nodeId => new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
})
|
|
.ToArrayOf();
|
|
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
readResponse.DiagnosticInfos,
|
|
readResponse.Results,
|
|
readResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(testSet.Length));
|
|
|
|
var modifiedValues = new List<DataValue>();
|
|
foreach (DataValue dataValue in readResponse.Results)
|
|
{
|
|
TypeInfo typeInfo = dataValue.WrappedValue.TypeInfo;
|
|
Assert.That(typeInfo.IsUnknown, Is.False);
|
|
Variant value = m_generator.GetRandomScalar(typeInfo.BuiltInType);
|
|
modifiedValues.Add(new DataValue(value));
|
|
}
|
|
|
|
int ii = 0;
|
|
var nodesToWrite = testSet
|
|
.Select(nodeId => new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = modifiedValues[ii++]
|
|
})
|
|
.ToArrayOf();
|
|
|
|
// Write Nodes
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
nodesToWrite, RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
writeResponse.DiagnosticInfos,
|
|
writeResponse.Results,
|
|
writeResponse.ResponseHeader.StringTable,
|
|
logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an engineering-unit change emits a semantic-change event and a data notification with
|
|
/// SemanticsChanged.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task SemanticChangeNotificationTestAsync()
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
|
|
// Step 1: Create a subscription
|
|
CreateSubscriptionResponse createSubscriptionResponse = await services.CreateSubscriptionAsync(
|
|
requestHeader,
|
|
100,
|
|
100,
|
|
10,
|
|
0,
|
|
true,
|
|
0).ConfigureAwait(false);
|
|
|
|
uint subscriptionId = createSubscriptionResponse.SubscriptionId;
|
|
|
|
// We monitor the server EventNotifier for SemanticChangeEventType
|
|
var serverObject = new ReadValueId { NodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier };
|
|
var eventFilter = new EventFilter();
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.SemanticChangeEventType,
|
|
QualifiedName.From(BrowseNames.EventId));
|
|
eventFilter.AddSelectClause(
|
|
ObjectTypeIds.SemanticChangeEventType,
|
|
QualifiedName.From(BrowseNames.Changes));
|
|
|
|
// Monitor a variable from ReferenceNodeManager (AnalogItem with EngineeringUnits)
|
|
var nodeId = new NodeId("DataAccess_AnalogType_Double",
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(Quickstarts.ReferenceServer.Namespaces.ReferenceServer));
|
|
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems =
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = serverObject,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true,
|
|
Filter = new ExtensionObject(eventFilter)
|
|
}
|
|
},
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 2,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse createItemsResponse = await services.CreateMonitoredItemsAsync(
|
|
requestHeader,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(createItemsResponse.ResponseHeader, createItemsResponse.Results, monitoredItems);
|
|
Assert.AreEqual(2, createItemsResponse.Results.Count);
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[0].StatusCode));
|
|
Assert.IsTrue(StatusCode.IsGood(createItemsResponse.Results[1].StatusCode));
|
|
|
|
// Initial publish to clear any initial data change notifications
|
|
var acknowledgements = new ArrayOf<SubscriptionAcknowledgement>();
|
|
PublishResponse publishResponse = await services.PublishAsync(requestHeader, acknowledgements).ConfigureAwait(false);
|
|
|
|
if (publishResponse.NotificationMessage.NotificationData.Count > 0)
|
|
{
|
|
acknowledgements =
|
|
[
|
|
new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishResponse.NotificationMessage.SequenceNumber
|
|
}
|
|
];
|
|
}
|
|
|
|
// Step 2: Write a new value to a semantic property (EngineeringUnits)
|
|
var euNodeId = new NodeId("DataAccess_AnalogType_DataAccess_AnalogType_Double_EngineeringUnits",
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(Quickstarts.ReferenceServer.Namespaces.ReferenceServer));
|
|
var engUnits = new EUInformation("V", "volt", "http://www.opcfoundation.org/UA/units/un/cefact")
|
|
{
|
|
UnitId = 4274026 // "V"
|
|
};
|
|
|
|
ArrayOf<WriteValue> writeValues = new WriteValue[]
|
|
{
|
|
new() {
|
|
NodeId = euNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new ExtensionObject(engUnits))
|
|
}
|
|
}.ToArrayOf();
|
|
|
|
requestHeader.Timestamp = DateTime.UtcNow;
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
writeValues, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, writeValues);
|
|
Assert.IsTrue(StatusCode.IsGood(writeResponse.Results[0]));
|
|
|
|
// Wait for subscription to deliver the event and data change notification
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
|
|
// Step 3: Issue publish to receive DataChangeNotification and SemanticChangeEvent
|
|
publishResponse = await services.PublishAsync(
|
|
requestHeader,
|
|
acknowledgements).ConfigureAwait(false);
|
|
|
|
Assert.IsNotNull(publishResponse.NotificationMessage);
|
|
Assert.IsNotNull(publishResponse.NotificationMessage.NotificationData);
|
|
Assert.IsTrue(publishResponse.NotificationMessage.NotificationData.Count == 2, "One Event and One DataChangeNotification expected.");
|
|
|
|
bool dataChangeReceived = false;
|
|
bool semanticsChangedBitSet = false;
|
|
bool eventReceived = false;
|
|
|
|
foreach (ExtensionObject data in publishResponse.NotificationMessage.NotificationData)
|
|
{
|
|
if (data.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == 2)
|
|
{
|
|
dataChangeReceived = true;
|
|
if (item.Value.StatusCode.SemanticsChanged)
|
|
{
|
|
semanticsChangedBitSet = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (data.TryGetValue(out EventNotificationList? enl))
|
|
{
|
|
foreach (EventFieldList e in enl.Events)
|
|
{
|
|
if (e.ClientHandle == 1 && e.EventFields.Count >= 2)
|
|
{
|
|
bool success = e.EventFields[1]
|
|
.TryGetStructure(out ArrayOf<SemanticChangeStructureDataType> semanticChangeEvent);
|
|
|
|
if (success && !semanticChangeEvent.IsNull && semanticChangeEvent.Count > 0)
|
|
{
|
|
foreach (SemanticChangeStructureDataType change in semanticChangeEvent)
|
|
{
|
|
if (change.Affected == nodeId)
|
|
{
|
|
eventReceived = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.IsTrue(dataChangeReceived, "Did not receive DataChangeNotification.");
|
|
Assert.IsTrue(semanticsChangedBitSet, "SemanticsChanged bit is not set.");
|
|
Assert.IsTrue(eventReceived, "Did not receive SemanticChangeEvent.");
|
|
|
|
// Delete subscription
|
|
await services.DeleteSubscriptionsAsync(
|
|
requestHeader,
|
|
[subscriptionId]).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that historical EventNotifier bits are set on the actual
|
|
/// historical notifier rather than inferred on the Server object.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoricalEventNotifierBitsAreNodeSpecificAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read Server object EventNotifier attribute
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.EventNotifier,
|
|
NodeId = ObjectIds.Server
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Both,
|
|
readIdCollection, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
|
Assert.That(readResponse.Results[0].WrappedValue.IsNull, Is.False);
|
|
|
|
byte eventNotifier = (byte)readResponse.Results[0].WrappedValue;
|
|
|
|
// Read history capabilities
|
|
ArrayOf<ReadValueId> historyCapabilitiesReadIds =
|
|
[
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability
|
|
},
|
|
new ReadValueId {
|
|
AttributeId = Attributes.Value,
|
|
NodeId = VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Both,
|
|
historyCapabilitiesReadIds, RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, historyCapabilitiesReadIds);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
bool accessHistoryEventsCapability =
|
|
!readResponse.Results[0].WrappedValue.IsNull &&
|
|
(bool)readResponse.Results[0].WrappedValue;
|
|
bool accessHistoryDataCapability =
|
|
!readResponse.Results[1].WrappedValue.IsNull &&
|
|
(bool)readResponse.Results[1].WrappedValue;
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Server EventNotifier: {EventNotifier}", eventNotifier);
|
|
logger.LogInformation("AccessHistoryEventsCapability: {AccessHistoryEventsCapability}", accessHistoryEventsCapability);
|
|
logger.LogInformation("AccessHistoryDataCapability: {AccessHistoryDataCapability}", accessHistoryDataCapability);
|
|
}
|
|
|
|
Assert.That(
|
|
eventNotifier & EventNotifiers.HistoryRead,
|
|
Is.Zero,
|
|
"The Server object is not itself configured as a historical event notifier.");
|
|
if (accessHistoryEventsCapability)
|
|
{
|
|
ArrayOf<ReadValueId> notifierRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.EventNotifier,
|
|
NodeId = new NodeId("CTT", 2)
|
|
}
|
|
];
|
|
ReadResponse notifierResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Neither,
|
|
notifierRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(
|
|
(byte)notifierResponse.Results[0].WrappedValue &
|
|
EventNotifiers.HistoryRead,
|
|
Is.Not.Zero,
|
|
"The configured CTT historical event notifier must expose HistoryRead.");
|
|
}
|
|
|
|
// Verify SubscribeToEvents bit is set (Server object should always support events)
|
|
Assert.That(eventNotifier & EventNotifiers.SubscribeToEvents,
|
|
Is.Not.Zero,
|
|
"Server EventNotifier should have SubscribeToEvents bit set");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that ServerStatus children have matching SourceTimestamp and ServerTimestamp.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ServerStatusTimestampsMatchAsync()
|
|
{
|
|
ILogger<ReferenceServerTests> logger = m_telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Read ServerStatus children (CurrentTime, StartTime, State, etc.)
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_CurrentTime, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_StartTime, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value }
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
0,
|
|
TimestampsToReturn.Both,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(3));
|
|
|
|
// Verify that SourceTimestamp and ServerTimestamp are equal for all ServerStatus children
|
|
for (int i = 0; i < readResponse.Results.Count; i++)
|
|
{
|
|
DataValue result = readResponse.Results[i];
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation(
|
|
"NodeId: {NodeId}, SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}",
|
|
nodesToRead[i].NodeId,
|
|
result.SourceTimestamp,
|
|
result.ServerTimestamp);
|
|
}
|
|
|
|
Assert.That(result.ServerTimestamp, Is.EqualTo(result.SourceTimestamp),
|
|
$"SourceTimestamp and ServerTimestamp should be equal for {nodesToRead[i].NodeId}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the Int32Value node (ns=3;i=2808) allows historical data access.
|
|
/// Verifies the fix for issue #2520 where the node was marked as historizing
|
|
/// but history read operations returned BadHistoryOperationUnsupported.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoryReadInt32ValueNodeAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<ReferenceServerTests>();
|
|
|
|
// Get the NodeId for Data_Dynamic_Scalar_Int32Value
|
|
var int32ValueNodeId = new NodeId(
|
|
TestData.Variables.Data_Dynamic_Scalar_Int32Value,
|
|
(ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(TestData.Namespaces.TestData));
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Testing history read for Int32Value node: {NodeId}", int32ValueNodeId);
|
|
}
|
|
|
|
// Verify the node has Historizing attribute set to true
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.Historizing,
|
|
NodeId = int32ValueNodeId
|
|
},
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.AccessLevel,
|
|
NodeId = int32ValueNodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIdCollection,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
bool historizing = (bool)readResponse.Results[0].WrappedValue;
|
|
byte accessLevel = (byte)readResponse.Results[1].WrappedValue;
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Historizing: {Historizing}, AccessLevel: {AccessLevel}", historizing, accessLevel);
|
|
}
|
|
|
|
Assert.That(historizing, Is.True, "Int32Value node should have Historizing=true");
|
|
Assert.That(accessLevel & AccessLevels.HistoryRead,
|
|
Is.Not.Zero,
|
|
"Int32Value node should have HistoryRead access level");
|
|
|
|
// Perform a history read operation
|
|
var historyReadDetails = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = DateTimeUtc.Now.SubtractMilliseconds(60 * 60 * 1000),
|
|
EndTime = DateTimeUtc.Now,
|
|
NumValuesPerNode = 10,
|
|
IsReadModified = false,
|
|
ReturnBounds = false
|
|
};
|
|
|
|
ArrayOf<HistoryReadValueId> nodesToRead =
|
|
[
|
|
new HistoryReadValueId
|
|
{
|
|
NodeId = int32ValueNodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse historyReadResponse = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
new ExtensionObject(historyReadDetails),
|
|
TimestampsToReturn.Both,
|
|
false,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(historyReadResponse.ResponseHeader, historyReadResponse.Results, nodesToRead);
|
|
Assert.That(historyReadResponse.Results.Count, Is.EqualTo(1));
|
|
|
|
HistoryReadResult result = historyReadResponse.Results[0];
|
|
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("History read StatusCode: {StatusCode}", result.StatusCode);
|
|
}
|
|
|
|
// The result should be Good or GoodMoreData (if there are more values)
|
|
Assert.That(StatusCode.IsGood(result.StatusCode),
|
|
Is.True,
|
|
$"History read should succeed, but got: {result.StatusCode}");
|
|
Assert.That(result.HistoryData.IsNull, Is.False, "HistoryData should not be null");
|
|
|
|
// Verify we got HistoryData back
|
|
if (result.HistoryData.TryGetValue(out HistoryData? historyData))
|
|
{
|
|
if (logger.IsEnabled(LogLevel.Information))
|
|
{
|
|
logger.LogInformation("Retrieved {Count} history values", historyData.DataValues.Count);
|
|
}
|
|
Assert.That(historyData.DataValues.IsNull, Is.False, "DataValues should not be null");
|
|
Assert.That(historyData.DataValues.Count, Is.GreaterThan(0), "Should have at least one historical value");
|
|
|
|
// Verify the data values have proper timestamps
|
|
foreach (DataValue dataValue in historyData.DataValues)
|
|
{
|
|
Assert.That(dataValue.IsNull, Is.False, "DataValue should not be null");
|
|
Assert.That(dataValue.ServerTimestamp,
|
|
Is.Not.EqualTo(DateTimeUtc.MinValue),
|
|
"DataValue should have a valid ServerTimestamp");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Assert.Fail("HistoryData body should be of type HistoryData");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that a HistoryRead on the <c>AccessRights_AccessAll_RO_NotUser</c>
|
|
/// node is denied for the current (anonymous) user. The node advertises
|
|
/// HistoryRead in its AccessLevel but its UserAccessLevel is None, so the
|
|
/// server must return BadUserAccessDenied instead of the historical data.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task HistoryReadUserAccessDeniedNodeAsync()
|
|
{
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
var nodeId = new NodeId("AccessRights_AccessAll_RO_NotUser", namespaceIndex);
|
|
|
|
// Sanity check: the node advertises HistoryRead in AccessLevel but the
|
|
// current user is not granted HistoryRead via UserAccessLevel.
|
|
ArrayOf<ReadValueId> readIdCollection =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.AccessLevel,
|
|
NodeId = nodeId
|
|
},
|
|
new ReadValueId
|
|
{
|
|
AttributeId = Attributes.UserAccessLevel,
|
|
NodeId = nodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIdCollection,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, readIdCollection);
|
|
Assert.That(readResponse.Results.Count, Is.EqualTo(2));
|
|
|
|
byte accessLevel = (byte)readResponse.Results[0].WrappedValue;
|
|
byte userAccessLevel = (byte)readResponse.Results[1].WrappedValue;
|
|
|
|
Assert.That(accessLevel & AccessLevels.HistoryRead,
|
|
Is.Not.Zero,
|
|
"Node should advertise HistoryRead in AccessLevel");
|
|
Assert.That(userAccessLevel & AccessLevels.HistoryRead,
|
|
Is.Zero,
|
|
"Node should not grant HistoryRead to the current user via UserAccessLevel");
|
|
|
|
// Perform a history read operation; it must be denied for the current user.
|
|
var historyReadDetails = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = DateTimeUtc.Now.SubtractMilliseconds(60 * 60 * 1000),
|
|
EndTime = DateTimeUtc.Now,
|
|
NumValuesPerNode = 10,
|
|
IsReadModified = false,
|
|
ReturnBounds = false
|
|
};
|
|
|
|
ArrayOf<HistoryReadValueId> nodesToRead =
|
|
[
|
|
new HistoryReadValueId
|
|
{
|
|
NodeId = nodeId
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse historyReadResponse = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
new ExtensionObject(historyReadDetails),
|
|
TimestampsToReturn.Both,
|
|
false,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(historyReadResponse.ResponseHeader, historyReadResponse.Results, nodesToRead);
|
|
Assert.That(historyReadResponse.Results.Count, Is.EqualTo(1));
|
|
|
|
HistoryReadResult result = historyReadResponse.Results[0];
|
|
|
|
Assert.That(result.StatusCode.Code,
|
|
Is.EqualTo(StatusCodes.BadUserAccessDenied),
|
|
$"History read on a node without user HistoryRead access should be denied, but got: {result.StatusCode}");
|
|
Assert.That(result.HistoryData.IsNull,
|
|
Is.True,
|
|
"No historical data should be returned when access is denied");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test provisioning mode - server should start with limited namespace.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ProvisioningModeTestAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
// start Ref server in provisioning mode
|
|
var fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
AllNodeManagers = false,
|
|
OperationLimits = false,
|
|
DurableSubscriptionsEnabled = false,
|
|
AutoAccept = true,
|
|
ProvisioningMode = true
|
|
};
|
|
|
|
ReferenceServer server = await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
// Verify provisioning mode is enabled
|
|
Assert.That(server.ProvisioningMode, Is.True, "Server should be in provisioning mode");
|
|
|
|
// OPC 10000-12 Annex G.2: the application setup state is reported
|
|
// as ServerState NoConfiguration and through InApplicationSetup.
|
|
Assert.That(server.CurrentInstance.CurrentState, Is.EqualTo(ServerState.NoConfiguration));
|
|
Assert.That(server.CurrentInstance.IsRunning, Is.True, "NoConfiguration still serves requests");
|
|
var configurationManager = (ConfigurationNodeManager)server.CurrentInstance.ConfigurationNodeManager;
|
|
ServerConfigurationState configuration = configurationManager
|
|
.FindPredefinedNode<ServerConfigurationState>(ObjectIds.ServerConfiguration);
|
|
Assert.That(configuration.InApplicationSetup, Is.Not.Null);
|
|
Assert.That(configuration.InApplicationSetup.Value, Is.True);
|
|
|
|
// Get endpoints - in provisioning mode, anonymous authentication should not be allowed
|
|
ArrayOf<EndpointDescription> endpoints = server.GetEndpoints();
|
|
Assert.That(endpoints.IsNull, Is.False);
|
|
Assert.That(endpoints.Count, Is.GreaterThan(0), "Server should have endpoints");
|
|
|
|
// Check that anonymous token policy is not present for at least one endpoint
|
|
bool hasEndpointWithoutAnonymous = false;
|
|
foreach (EndpointDescription endpoint in endpoints)
|
|
{
|
|
bool hasAnonymous = endpoint.UserIdentityTokens.Contains(
|
|
policy => policy.TokenType == UserTokenType.Anonymous);
|
|
if (!hasAnonymous)
|
|
{
|
|
hasEndpointWithoutAnonymous = true;
|
|
break;
|
|
}
|
|
}
|
|
Assert.That(hasEndpointWithoutAnonymous,
|
|
Is.True,
|
|
"At least one endpoint should not allow anonymous authentication in provisioning mode");
|
|
|
|
// Clean up
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that ArrayItemType sub-type nodes are accessible and readable.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ArrayItemTypeNodesExistAndReadableAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "YArray", new NodeId("DataAccess_ArrayItemType_YArray", 2) },
|
|
{ "XYArray", new NodeId("DataAccess_ArrayItemType_XYArray", 2) },
|
|
{ "Image", new NodeId("DataAccess_ArrayItemType_Image", 2) },
|
|
{ "Cube", new NodeId("DataAccess_ArrayItemType_Cube", 2) },
|
|
{ "NDimension", new NodeId("DataAccess_ArrayItemType_NDimension", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ValueRank }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Value read of {name} should succeed");
|
|
Assert.That(readResponse.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"DataType read of {name} should succeed");
|
|
Assert.That(readResponse.Results[2].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"ValueRank read of {name} should succeed");
|
|
|
|
TestContext.Out.WriteLine("{0}: DataType={1}, ValueRank={2}",
|
|
name,
|
|
readResponse.Results[1].WrappedValue,
|
|
readResponse.Results[2].WrappedValue);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that TriggerNode01 and TriggerNode02 exist, are readable and writable,
|
|
/// and that writing to them fires a BaseEvent.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TriggerNodesFiringEventsOnWriteAsync()
|
|
{
|
|
var triggerNode01 = new NodeId("NodeIds_Events_TriggerNode01", 2);
|
|
var triggerNode02 = new NodeId("NodeIds_Events_TriggerNode02", 2);
|
|
DateTimeUtc eventWindowStart = DateTime.UtcNow.AddSeconds(-1);
|
|
|
|
// Verify both nodes exist and are readable
|
|
foreach (NodeId nodeId in new[] { triggerNode01, triggerNode02 })
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Read of trigger node {nodeId} should succeed");
|
|
}
|
|
|
|
// Verify both nodes are writable
|
|
foreach (NodeId nodeId in new[] { triggerNode01, triggerNode02 })
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<WriteValue> nodesToWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(42))
|
|
}
|
|
];
|
|
WriteResponse writeResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
nodesToWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(writeResponse.ResponseHeader, writeResponse.Results, nodesToWrite);
|
|
Assert.That(writeResponse.Results[0], Is.EqualTo(StatusCodes.Good),
|
|
$"Write to trigger node {nodeId} should succeed and fire an event");
|
|
}
|
|
|
|
var filter = new EventFilter();
|
|
filter.AddSelectClause(
|
|
ObjectTypeIds.BaseEventType,
|
|
BrowseNames.Message,
|
|
Attributes.Value);
|
|
bool captured = false;
|
|
for (int attempt = 0; attempt < 20 && !captured; attempt++)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
HistoryReadResponse response = await m_server.HistoryReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
new ExtensionObject(new ReadEventDetails
|
|
{
|
|
StartTime = eventWindowStart,
|
|
EndTime = DateTime.UtcNow.AddSeconds(1),
|
|
NumValuesPerNode = 100,
|
|
Filter = filter
|
|
}),
|
|
TimestampsToReturn.Neither,
|
|
false,
|
|
[new HistoryReadValueId { NodeId = new NodeId("CTT", 2) }],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
if (response.Results.Count == 1 &&
|
|
StatusCode.IsGood(response.Results[0].StatusCode))
|
|
{
|
|
if (ExtensionObject.ToEncodeable(
|
|
response.Results[0].HistoryData) is not HistoryEvent historyEvent)
|
|
{
|
|
continue;
|
|
}
|
|
foreach (HistoryEventFieldList historicalEvent in
|
|
historyEvent.Events)
|
|
{
|
|
if (historicalEvent.EventFields.Count == 1 &&
|
|
historicalEvent.EventFields[0].TryGetValue(
|
|
out LocalizedText message) &&
|
|
message.Text?.Contains(
|
|
"Trigger event",
|
|
StringComparison.Ordinal) == true)
|
|
{
|
|
captured = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!captured)
|
|
{
|
|
await Task.Delay(50).ConfigureAwait(false);
|
|
}
|
|
}
|
|
Assert.That(
|
|
captured,
|
|
Is.True,
|
|
"Writing a trigger node must publish and archive the event on the CTT notifier.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the Variant arrays (1D and 2D) are accessible with proper NodeIds.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task VariantArrayNodesExistAndReadableAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "Scalar_Static_Arrays_Variant", new NodeId("Scalar_Static_Arrays_Variant", 2) },
|
|
{ "Scalar_Static_Arrays2D_Variant", new NodeId("Scalar_Static_Arrays2D_Variant", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Read of {name} should succeed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the Variant matrix can be encoded with neighboring results in a batched Read.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task VariantMatrixDoesNotPoisonBatchedReadEncodingAsync()
|
|
{
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = new NodeId("Scalar_Static_Arrays2D_Int32", 2),
|
|
AttributeId = Attributes.Value
|
|
},
|
|
new ReadValueId
|
|
{
|
|
NodeId = new NodeId("Scalar_Static_Arrays2D_Variant", 2),
|
|
AttributeId = Attributes.Value
|
|
},
|
|
new ReadValueId
|
|
{
|
|
NodeId = new NodeId("Scalar_Static_Arrays2D_UInt32", 2),
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int i = 0; i < readResponse.Results.Count; i++)
|
|
{
|
|
Assert.That(readResponse.Results[i].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
using var stream = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(
|
|
stream,
|
|
m_server.CurrentInstance.MessageContext,
|
|
leaveOpen: true))
|
|
{
|
|
encoder.WriteDataValueArray(null, readResponse.Results);
|
|
}
|
|
|
|
stream.Position = 0;
|
|
using var decoder = new BinaryDecoder(stream, m_server.CurrentInstance.MessageContext);
|
|
ArrayOf<DataValue> decodedResults = decoder.ReadDataValueArray(null);
|
|
|
|
Assert.That(decodedResults, Has.Count.EqualTo(nodesToRead.Count));
|
|
for (int i = 0; i < decodedResults.Count; i++)
|
|
{
|
|
Assert.That(decodedResults[i].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(decodedResults[i].WrappedValue, Is.EqualTo(readResponse.Results[i].WrappedValue));
|
|
}
|
|
MatrixOf<Variant> matrix = decodedResults[1].WrappedValue.GetVariantMatrix();
|
|
Assert.That(matrix.Dimensions, Has.Length.EqualTo(2));
|
|
Assert.That(matrix.Dimensions[0], Is.EqualTo(5));
|
|
Assert.That(matrix.Dimensions[1], Is.EqualTo(5));
|
|
Assert.That(matrix.Count, Is.EqualTo(25));
|
|
// CTT multi-dimensional IndexRange tests need scalar elements of a simple type.
|
|
foreach (Variant element in matrix.Span)
|
|
{
|
|
Assert.That(element.TypeInfo.ValueRank, Is.EqualTo(ValueRanks.Scalar));
|
|
Assert.That(element.TypeInfo.BuiltInType, Is.LessThan(BuiltInType.XmlElement));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies whole-matrix writes and valid multi-dimensional NumericRange reads and writes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task MatrixReadWriteAndNumericRangeAsync()
|
|
{
|
|
var nodeId = new NodeId("Scalar_Static_Arrays2D_Int32", 2);
|
|
ArrayOf<ReadValueId> wholeMatrixRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
ReadResponse originalRead = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
wholeMatrixRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(originalRead.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Variant originalValue = originalRead.Results[0].WrappedValue;
|
|
|
|
try
|
|
{
|
|
MatrixOf<int> matrix = new int[5, 5]
|
|
{
|
|
{ 1, 2, 3, 4, 5 },
|
|
{ 6, 7, 8, 9, 10 },
|
|
{ 11, 12, 13, 14, 15 },
|
|
{ 16, 17, 18, 19, 20 },
|
|
{ 21, 22, 23, 24, 25 }
|
|
};
|
|
ArrayOf<WriteValue> wholeMatrixWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(Variant.From(matrix))
|
|
}
|
|
];
|
|
|
|
WriteResponse wholeWriteResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
wholeMatrixWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(wholeWriteResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
|
|
ArrayOf<ReadValueId> rowRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
IndexRange = "1,0:2"
|
|
}
|
|
];
|
|
ReadResponse rowReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
rowRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
DataValue rowValue = rowReadResponse.Results[0];
|
|
Assert.That(rowValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(rowValue.SourceTimestamp.IsNull, Is.False);
|
|
MatrixOf<int> row = rowValue.WrappedValue.GetInt32Matrix();
|
|
Assert.That(row.Dimensions, Has.Length.EqualTo(2));
|
|
Assert.That(row.Dimensions[0], Is.EqualTo(1));
|
|
Assert.That(row.Dimensions[1], Is.EqualTo(3));
|
|
Assert.That(row.Count, Is.EqualTo(3));
|
|
Assert.That(row.Span[0], Is.EqualTo(6));
|
|
Assert.That(row.Span[1], Is.EqualTo(7));
|
|
Assert.That(row.Span[2], Is.EqualTo(8));
|
|
|
|
MatrixOf<int> replacement = new int[1, 2] { { 40, 50 } };
|
|
ArrayOf<WriteValue> rangeWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
IndexRange = "1,1:2",
|
|
Value = new DataValue(Variant.From(replacement))
|
|
}
|
|
];
|
|
WriteResponse rangeWriteResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
rangeWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(rangeWriteResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
|
|
ReadResponse updatedRowReadResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Both,
|
|
rowRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
DataValue updatedRowValue = updatedRowReadResponse.Results[0];
|
|
Assert.That(updatedRowValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
MatrixOf<int> updatedRow = updatedRowValue.WrappedValue.GetInt32Matrix();
|
|
Assert.That(updatedRow.Dimensions, Has.Length.EqualTo(2));
|
|
Assert.That(updatedRow.Dimensions[0], Is.EqualTo(1));
|
|
Assert.That(updatedRow.Dimensions[1], Is.EqualTo(3));
|
|
Assert.That(updatedRow.Count, Is.EqualTo(3));
|
|
Assert.That(updatedRow.Span[0], Is.EqualTo(6));
|
|
Assert.That(updatedRow.Span[1], Is.EqualTo(40));
|
|
Assert.That(updatedRow.Span[2], Is.EqualTo(50));
|
|
}
|
|
finally
|
|
{
|
|
ArrayOf<WriteValue> restoreWrite =
|
|
[
|
|
new WriteValue
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(originalValue)
|
|
}
|
|
];
|
|
WriteResponse restoreResponse = await m_server.WriteAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
restoreWrite,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
Assert.That(restoreResponse.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that Enumeration and Image type scalar nodes are accessible.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ScalarStaticEnumerationAndImageNodesExistAsync()
|
|
{
|
|
var nodeIds = new Dictionary<string, NodeId>
|
|
{
|
|
{ "Enumeration", new NodeId("Scalar_Static_Enumeration", 2) },
|
|
{ "Image", new NodeId("Scalar_Static_Image", 2) },
|
|
{ "ImageBMP", new NodeId("Scalar_Static_ImageBMP", 2) },
|
|
{ "ImageGIF", new NodeId("Scalar_Static_ImageGIF", 2) },
|
|
{ "ImageJPG", new NodeId("Scalar_Static_ImageJPG", 2) },
|
|
{ "ImagePNG", new NodeId("Scalar_Static_ImagePNG", 2) }
|
|
};
|
|
|
|
foreach (KeyValuePair<string, NodeId> kvp in nodeIds)
|
|
{
|
|
string name = kvp.Key;
|
|
NodeId nodeId = kvp.Value;
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"Value read of {name} should succeed");
|
|
Assert.That(readResponse.Results[1].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
$"DataType read of {name} should succeed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that the HasReferenceTypeAndSubType node exists and has the expected references.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReferencesHasReferenceTypeAndSubTypePreservesBrowseReferencesAsync()
|
|
{
|
|
int namespaceIndex = m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(0));
|
|
var nodeId = new NodeId("References_HasReferenceTypeAndSubType", (ushort)namespaceIndex);
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }
|
|
];
|
|
ReadResponse readResponse = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(readResponse.ResponseHeader, readResponse.Results, nodesToRead);
|
|
Assert.That(readResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good),
|
|
"Read of References_HasReferenceTypeAndSubType should succeed");
|
|
|
|
var instructionsId = new NodeId("Scalar_Instructions", (ushort)namespaceIndex);
|
|
var booleanId = new NodeId("Scalar_Static_Boolean", (ushort)namespaceIndex);
|
|
ArrayOf<BrowseDescription> nodesToBrowse =
|
|
[
|
|
Describe(nodeId, ReferenceTypeIds.HasComponent, BrowseDirection.Forward),
|
|
Describe(nodeId, ReferenceTypeIds.HasComponent, BrowseDirection.Forward, includeSubtypes: true),
|
|
Describe(instructionsId, ReferenceTypeIds.HasComponent, BrowseDirection.Inverse),
|
|
Describe(booleanId, ReferenceTypeIds.HasOrderedComponent, BrowseDirection.Inverse),
|
|
Describe(nodeId, ReferenceTypeIds.References, BrowseDirection.Both, includeSubtypes: true),
|
|
Describe(instructionsId, ReferenceTypeIds.Organizes, BrowseDirection.Inverse),
|
|
Describe(booleanId, ReferenceTypeIds.Organizes, BrowseDirection.Inverse)
|
|
];
|
|
BrowseResponse response = await m_server.BrowseAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
null,
|
|
0,
|
|
nodesToBrowse,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(response.ResponseHeader, response.Results, nodesToBrowse);
|
|
for (int index = 0; index < response.Results.Count; index++)
|
|
{
|
|
Assert.That(response.Results[index].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(response.Results[index].ContinuationPoint.IsEmpty, Is.True);
|
|
}
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(response.Results[0].References.Count, Is.EqualTo(1));
|
|
Assert.That(response.Results[1].References.Count, Is.EqualTo(2));
|
|
Assert.That(response.Results[2].References.Count, Is.EqualTo(1));
|
|
Assert.That(response.Results[3].References.Count, Is.EqualTo(1));
|
|
});
|
|
ReferenceDescription component = response.Results[0].References[0];
|
|
ReferenceDescription orderedComponent = response.Results[1].References.ToArray()!
|
|
.Single(reference => reference.ReferenceTypeId == ReferenceTypeIds.HasOrderedComponent);
|
|
ReferenceDescription[] allReferences = response.Results[4].References.ToArray()!;
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(component.ReferenceTypeId, Is.EqualTo(ReferenceTypeIds.HasComponent));
|
|
Assert.That(component.IsForward, Is.True);
|
|
Assert.That(component.NodeId, Is.EqualTo(new ExpandedNodeId(instructionsId)));
|
|
Assert.That(orderedComponent.IsForward, Is.True);
|
|
Assert.That(orderedComponent.NodeId, Is.EqualTo(new ExpandedNodeId(booleanId)));
|
|
Assert.That(response.Results[2].References[0].IsForward, Is.False);
|
|
Assert.That(response.Results[2].References[0].NodeId, Is.EqualTo(new ExpandedNodeId(nodeId)));
|
|
Assert.That(response.Results[3].References[0].IsForward, Is.False);
|
|
Assert.That(response.Results[3].References[0].NodeId, Is.EqualTo(new ExpandedNodeId(nodeId)));
|
|
Assert.That(allReferences, Has.Length.EqualTo(4));
|
|
Assert.That(allReferences!.Any(reference =>
|
|
reference.ReferenceTypeId == ReferenceTypeIds.HasTypeDefinition &&
|
|
reference.IsForward &&
|
|
reference.NodeId == VariableTypeIds.BaseDataVariableType), Is.True);
|
|
Assert.That(allReferences.Any(reference =>
|
|
reference.ReferenceTypeId == ReferenceTypeIds.Organizes &&
|
|
!reference.IsForward &&
|
|
reference.NodeId == new NodeId("References", (ushort)namespaceIndex)), Is.True);
|
|
Assert.That(response.Results[5].References.ToArray()!.Any(reference =>
|
|
!reference.IsForward &&
|
|
reference.NodeId == new NodeId("Scalar", (ushort)namespaceIndex)), Is.True);
|
|
Assert.That(response.Results[6].References.ToArray()!.Any(reference =>
|
|
!reference.IsForward &&
|
|
reference.NodeId == new NodeId("Scalar_Static", (ushort)namespaceIndex)), Is.True);
|
|
});
|
|
|
|
static BrowseDescription Describe(
|
|
NodeId id,
|
|
NodeId referenceTypeId,
|
|
BrowseDirection direction,
|
|
bool includeSubtypes = false)
|
|
{
|
|
return new BrowseDescription
|
|
{
|
|
NodeId = id,
|
|
BrowseDirection = direction,
|
|
ReferenceTypeId = referenceTypeId,
|
|
IncludeSubtypes = includeSubtypes,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that after transferring a subscription to a session that then switches its
|
|
/// identity to one that lacks access to the monitored node, permission caches are
|
|
/// invalidated and subsequent value-change notifications for that node are suppressed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Scenario:
|
|
/// 1. Session A authenticates as user1 (AuthenticatedUser role) and creates a
|
|
/// subscription monitoring <c>AccessRights_RolePermissions_AuthenticatedUser</c>
|
|
/// (a node whose RolePermissions allow only the AuthenticatedUser role to read it).
|
|
/// 2. Session B also authenticates as user1 so that TransferSubscriptions accepts the
|
|
/// transfer (identity check: same username).
|
|
/// 3. The subscription is transferred to session B with sendInitialValues=true; session B
|
|
/// publishes and receives the initial value — confirming the node is accessible.
|
|
/// 4. Session B re-activates with anonymous identity. This triggers
|
|
/// <c>SessionActivatedAsync</c> → <c>InvalidatePermissionCacheForSession</c>,
|
|
/// clearing the cached permission for the transferred item.
|
|
/// 5. Session A (still user1) writes a new value to the restricted node, triggering
|
|
/// <c>ValidateItemDataChange</c> for session B's item. With the cache cleared, the
|
|
/// permission is re-evaluated for the anonymous identity → BadUserAccessDenied →
|
|
/// the notification is suppressed.
|
|
/// 6. Session B publishes: no DataChangeNotification for the restricted node should
|
|
/// be present, proving that permissions were correctly re-evaluated.
|
|
/// </remarks>
|
|
/// <exception cref="AssertionException">
|
|
/// The fixture has no TCP or HTTPS endpoint, or the selected endpoint has no Username token policy.
|
|
/// </exception>
|
|
[Test]
|
|
public async Task PermissionsRevalidatedAfterTransferSubscriptionsAndReactivationAsync()
|
|
{
|
|
// The ReferenceServer registers its nodes under namespace index 2.
|
|
// "AccessRights_RolePermissions_AuthenticatedUser" allows Browse/Read/Write only
|
|
// for WellKnownRole_AuthenticatedUser; anonymous sessions cannot read it.
|
|
var restrictedNodeId = new NodeId("AccessRights_RolePermissions_AuthenticatedUser", 2);
|
|
|
|
EndpointDescription endpoint = m_server.GetEndpoints().Find(e =>
|
|
e.TransportProfileUri is Profiles.UaTcpTransport or Profiles.HttpsBinaryTransport)
|
|
?? throw new AssertionException("The fixture requires a TCP or HTTPS endpoint.");
|
|
UserTokenPolicy userNamePolicy = endpoint.UserIdentityTokens.Find(
|
|
policy => policy.TokenType == UserTokenType.UserName)
|
|
?? throw new AssertionException("The endpoint must advertise a Username token policy.");
|
|
|
|
var usernameToken = new UserNameIdentityToken
|
|
{
|
|
UserName = "user1",
|
|
Password = System.Text.Encoding.UTF8.GetBytes("password").ToByteString(),
|
|
PolicyId = userNamePolicy.PolicyId
|
|
};
|
|
|
|
// Session A: authenticate as user1.
|
|
(RequestHeader headerA, SecureChannelContext channelA) =
|
|
await m_server.CreateAndActivateSessionAsync(
|
|
"TransferTest_SessionA",
|
|
useSecurity: false,
|
|
identityToken: usernameToken).ConfigureAwait(false);
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
|
|
uint subscriptionId = 0;
|
|
try
|
|
{
|
|
var servicesA = new ServerTestServices(m_server, channelA);
|
|
|
|
// Create a subscription in session A.
|
|
CreateSubscriptionResponse subResp = await servicesA.CreateSubscriptionAsync(
|
|
headerA,
|
|
requestedPublishingInterval: 100,
|
|
requestedLifetimeCount: 20,
|
|
requestedMaxKeepAliveCount: 10,
|
|
maxNotificationsPerPublish: 0,
|
|
publishingEnabled: true,
|
|
priority: 0).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(subResp.ResponseHeader);
|
|
subscriptionId = subResp.SubscriptionId;
|
|
|
|
// Monitor the restricted node. Session A (user1 / AuthenticatedUser role)
|
|
// has Read permission, so creation should succeed.
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems =
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId
|
|
{
|
|
NodeId = restrictedNodeId,
|
|
AttributeId = Attributes.Value
|
|
},
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = -1,
|
|
QueueSize = 10,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
];
|
|
|
|
CreateMonitoredItemsResponse itemsResp = await servicesA.CreateMonitoredItemsAsync(
|
|
headerA,
|
|
subscriptionId,
|
|
TimestampsToReturn.Both,
|
|
monitoredItems).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(itemsResp.ResponseHeader, itemsResp.Results, monitoredItems);
|
|
Assert.That(StatusCode.IsGood(itemsResp.Results[0].StatusCode), Is.True,
|
|
"Monitored item creation on restricted node should succeed for AuthenticatedUser.");
|
|
|
|
// Write an initial value so the node has a known, non-default value.
|
|
// This also pre-populates the monitored item's last-value cache.
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
WriteResponse writeResp0 = await m_server.WriteAsync(
|
|
channelA,
|
|
headerA,
|
|
[new WriteValue
|
|
{
|
|
NodeId = restrictedNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant((short)10))
|
|
}],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResp0.ResponseHeader);
|
|
Assert.That(StatusCode.IsGood(writeResp0.Results[0]), Is.True,
|
|
"Initial write from user1 should succeed.");
|
|
|
|
// Consume any initial notifications queued in session A.
|
|
await Task.Delay(300).ConfigureAwait(false);
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
PublishResponse publishA = await servicesA.PublishAsync(headerA, []).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(publishA.ResponseHeader);
|
|
|
|
// Session B: authenticate as user1 as well.
|
|
// TransferSubscriptions requires the same username for the identity check to pass.
|
|
(RequestHeader headerB, SecureChannelContext channelB) =
|
|
await m_server.CreateAndActivateSessionAsync(
|
|
"TransferTest_SessionB",
|
|
useSecurity: false,
|
|
identityToken: usernameToken).ConfigureAwait(false);
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
|
|
try
|
|
{
|
|
var servicesB = new ServerTestServices(m_server, channelB);
|
|
|
|
// Transfer the subscription from session A to session B.
|
|
// sendInitialValues=true causes the server to re-queue the last known value
|
|
// for all monitored items immediately after transfer.
|
|
TransferSubscriptionsResponse transferResp =
|
|
await servicesB.TransferSubscriptionsAsync(
|
|
headerB,
|
|
[subscriptionId],
|
|
sendInitialValues: true).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(transferResp.ResponseHeader);
|
|
Assert.That(StatusCode.IsGood(transferResp.Results[0].StatusCode), Is.True,
|
|
"Subscription transfer should succeed: both sessions use user1 identity.");
|
|
|
|
// Publish from session B to receive the initial-values notification.
|
|
// At this point session B is still user1 so the read is permitted.
|
|
await Task.Delay(200).ConfigureAwait(false);
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
PublishResponse publishB1 = await servicesB.PublishAsync(
|
|
headerB,
|
|
publishA.NotificationMessage.SequenceNumber > 0
|
|
? [new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishA.NotificationMessage.SequenceNumber
|
|
}]
|
|
: []).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(publishB1.ResponseHeader);
|
|
|
|
Assert.That(
|
|
HasDataChangeNotificationForClientHandle(publishB1.NotificationMessage, 1),
|
|
Is.True,
|
|
"Session B (user1) must receive the initial data notification for the restricted node.");
|
|
|
|
// Re-activate session B with anonymous identity.
|
|
// The identity change triggers SessionActivatedAsync →
|
|
// InvalidatePermissionCacheForSession, clearing the cached permission for
|
|
// the transferred monitored item.
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
ActivateSessionResponse reactivateResp = await m_server.ActivateSessionAsync(
|
|
channelB,
|
|
headerB,
|
|
default,
|
|
[],
|
|
[],
|
|
default,
|
|
null,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(reactivateResp.ResponseHeader);
|
|
|
|
// Write a new value from session A (user1 still has Write permission).
|
|
// This triggers ValidateItemDataChange for session B's monitored item.
|
|
// With the permission cache cleared, anonymous cannot read the restricted
|
|
// node → notification is suppressed.
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
WriteResponse writeResp = await m_server.WriteAsync(
|
|
channelA,
|
|
headerA,
|
|
[new WriteValue
|
|
{
|
|
NodeId = restrictedNodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant((short)99))
|
|
}],
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(writeResp.ResponseHeader);
|
|
Assert.That(StatusCode.IsGood(writeResp.Results[0]), Is.True,
|
|
"Session A (user1) should still be able to write to the restricted node.");
|
|
|
|
// Allow time for the value change to be processed.
|
|
await Task.Delay(300).ConfigureAwait(false);
|
|
|
|
// Publish from session B. Because the anonymous identity cannot read the
|
|
// restricted node, session B must not receive the value; the denied access
|
|
// is reported as a bad status instead (Part 4 5.13.2.1).
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
PublishResponse publishB2 = await servicesB.PublishAsync(
|
|
headerB,
|
|
[new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = publishB1.NotificationMessage.SequenceNumber
|
|
}]).ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(publishB2.ResponseHeader);
|
|
|
|
foreach (DataValue value in GetDataChangeValuesForClientHandle(
|
|
publishB2.NotificationMessage, 1))
|
|
{
|
|
Assert.That(
|
|
value.StatusCode == StatusCodes.BadUserAccessDenied ||
|
|
value.StatusCode == StatusCodes.BadNotReadable,
|
|
Is.True,
|
|
"After re-activation as anonymous, session B must only receive an access " +
|
|
$"error for the AuthenticatedUser-restricted node, got {value.StatusCode}.");
|
|
Assert.That(value.WrappedValue.IsNull, Is.True,
|
|
"The restricted value must not be delivered to the anonymous session.");
|
|
}
|
|
|
|
// Clean up.
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
await servicesB.DeleteSubscriptionsAsync(headerB, [subscriptionId]).ConfigureAwait(false);
|
|
subscriptionId = 0;
|
|
}
|
|
finally
|
|
{
|
|
headerB.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(
|
|
channelB,
|
|
headerB,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (subscriptionId != 0)
|
|
{
|
|
var servicesA = new ServerTestServices(m_server, channelA);
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
await servicesA.DeleteSubscriptionsAsync(headerA, [subscriptionId])
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
headerA.Timestamp = DateTimeUtc.Now;
|
|
await m_server.CloseSessionAsync(
|
|
channelA,
|
|
headerA,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> if any <see cref="DataChangeNotification"/> in the given
|
|
/// <paramref name="message"/> contains a <see cref="MonitoredItemNotification"/>
|
|
/// with the specified <paramref name="clientHandle"/>.
|
|
/// </summary>
|
|
private static bool HasDataChangeNotificationForClientHandle(
|
|
NotificationMessage message,
|
|
uint clientHandle)
|
|
{
|
|
foreach (ExtensionObject extObj in message.NotificationData)
|
|
{
|
|
if (extObj.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == clientHandle)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the values of all <see cref="MonitoredItemNotification"/>s with the specified
|
|
/// <paramref name="clientHandle"/> in the given <paramref name="message"/>.
|
|
/// </summary>
|
|
private static List<DataValue> GetDataChangeValuesForClientHandle(
|
|
NotificationMessage message,
|
|
uint clientHandle)
|
|
{
|
|
var values = new List<DataValue>();
|
|
foreach (ExtensionObject extObj in message.NotificationData)
|
|
{
|
|
if (extObj.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == clientHandle)
|
|
{
|
|
values.Add(item.Value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
}
|
|
}
|