mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
b1c0cd20fd
# Description Persisted classic subscriptions retain their server/client handles but previously lost relative-path resolution metadata. Transfer could succeed, yet a later `ApplyChangesAsync` attempted to resolve an already-created item and failed. The SDK now owns this restoration lifecycle without exposing a public resolution-state setter. - Preserve the originally monitored target in internal snapshot state and restore both `ResolvedNodeId` and `Status.NodeId`. Transfer/reactivation use handles without re-browsing; fresh creation resolves paths again. - Version classic subscription/configuration files and read existing unversioned 2.0 files using frozen legacy wire layouts. Legacy missing targets remain unknown rather than being guessed. - Exclude existing items from creation-time resolution, reserve creation before asynchronous translation, and reject stale translation results. Failed deletion retains identity and pending retry state. - Support forward-only snapshot input using position tracking and bounded, per-ExtensionObject buffering in the binary decoder, including short reads. No whole-file buffering or reflection-based serialization is introduced. - Add unit/integration regressions and document the supported lifecycle and migration implications. **Compatibility:** Newly written files require the updated SDK. Existing 2.0 files remain readable. Assigning a different `StartNodeId` or `RelativePath` to a created/in-flight item now immediately throws `BadInvalidState`; unchanged assignments remain allowed. No V2 subscription-engine changes or 1.5 XML import are included. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] 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.
981 lines
40 KiB
C#
981 lines
40 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/
|
|
* ======================================================================*/
|
|
|
|
// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
|
|
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
|
|
#pragma warning disable CA2000
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Client.Tests
|
|
{
|
|
[TestFixture]
|
|
[Category("Client")]
|
|
[Category("Session")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
public sealed class SessionStateEncoderTests
|
|
{
|
|
private ServiceMessageContext m_context;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
m_context = ServiceMessageContext.Create(telemetry);
|
|
m_context.Factory.Builder.AddOpcUaClientDataTypes();
|
|
}
|
|
|
|
[Test]
|
|
public void MonitoredItemStateRoundTripWithDefaults()
|
|
{
|
|
var original = new MonitoredItemState();
|
|
|
|
MonitoredItemState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.DisplayName, Is.EqualTo(original.DisplayName));
|
|
Assert.That(decoded.StartNodeId, Is.EqualTo(original.StartNodeId));
|
|
Assert.That(decoded.NodeClass, Is.EqualTo(original.NodeClass));
|
|
Assert.That(decoded.AttributeId, Is.EqualTo(original.AttributeId));
|
|
Assert.That(decoded.MonitoringMode, Is.EqualTo(original.MonitoringMode));
|
|
Assert.That(decoded.SamplingInterval, Is.EqualTo(original.SamplingInterval));
|
|
Assert.That(decoded.QueueSize, Is.EqualTo(original.QueueSize));
|
|
Assert.That(decoded.DiscardOldest, Is.EqualTo(original.DiscardOldest));
|
|
Assert.That(decoded.ServerId, Is.EqualTo(original.ServerId));
|
|
Assert.That(decoded.ClientId, Is.EqualTo(original.ClientId));
|
|
Assert.That(decoded.TriggeringItemId, Is.EqualTo(original.TriggeringItemId));
|
|
Assert.That(decoded.CacheQueueSize, Is.EqualTo(original.CacheQueueSize));
|
|
}
|
|
|
|
[Test]
|
|
public void MonitoredItemStateRoundTripWithAllFieldsPopulated()
|
|
{
|
|
var timestamp = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc);
|
|
var original = new MonitoredItemState
|
|
{
|
|
DisplayName = "TestItem",
|
|
StartNodeId = new NodeId(42, 2),
|
|
RelativePath = "Child1.Child2",
|
|
NodeClass = NodeClass.Variable,
|
|
AttributeId = Attributes.Value,
|
|
IndexRange = "0:9",
|
|
Encoding = new QualifiedName("DefaultBinary", 0),
|
|
MonitoringMode = MonitoringMode.Sampling,
|
|
SamplingInterval = 500,
|
|
Filter = new DataChangeFilter
|
|
{
|
|
Trigger = DataChangeTrigger.StatusValue,
|
|
DeadbandType = (uint)DeadbandType.Absolute,
|
|
DeadbandValue = 1.5
|
|
},
|
|
QueueSize = 10,
|
|
DiscardOldest = false,
|
|
ServerId = 100,
|
|
ClientId = 200,
|
|
Timestamp = timestamp,
|
|
TriggeringItemId = 50,
|
|
TriggeredItems = [300, 301, 302],
|
|
CacheQueueSize = 5,
|
|
ResolvedNodeId = new NodeId(43, 2)
|
|
};
|
|
|
|
MonitoredItemState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.DisplayName, Is.EqualTo("TestItem"));
|
|
Assert.That(decoded.StartNodeId, Is.EqualTo(new NodeId(42, 2)));
|
|
Assert.That(decoded.RelativePath, Is.EqualTo("Child1.Child2"));
|
|
Assert.That(decoded.NodeClass, Is.EqualTo(NodeClass.Variable));
|
|
Assert.That(decoded.AttributeId, Is.EqualTo(Attributes.Value));
|
|
Assert.That(decoded.IndexRange, Is.EqualTo("0:9"));
|
|
Assert.That(decoded.Encoding, Is.EqualTo(new QualifiedName("DefaultBinary", 0)));
|
|
Assert.That(decoded.MonitoringMode, Is.EqualTo(MonitoringMode.Sampling));
|
|
Assert.That(decoded.SamplingInterval, Is.EqualTo(500));
|
|
Assert.That(decoded.QueueSize, Is.EqualTo(10u));
|
|
Assert.That(decoded.DiscardOldest, Is.False);
|
|
Assert.That(decoded.ServerId, Is.EqualTo(100u));
|
|
Assert.That(decoded.ClientId, Is.EqualTo(200u));
|
|
Assert.That(decoded.Timestamp, Is.EqualTo(timestamp));
|
|
Assert.That(decoded.TriggeringItemId, Is.EqualTo(50u));
|
|
Assert.That(decoded.TriggeredItems.Count, Is.EqualTo(3));
|
|
Assert.That(decoded.CacheQueueSize, Is.EqualTo(5u));
|
|
Assert.That(decoded.ResolvedNodeId, Is.EqualTo(new NodeId(43, 2)));
|
|
}
|
|
|
|
[Test]
|
|
public void MonitoredItemStateRoundTripWithNullFilter()
|
|
{
|
|
var original = new MonitoredItemState
|
|
{
|
|
DisplayName = "NoFilter",
|
|
Filter = null
|
|
};
|
|
|
|
MonitoredItemState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.Filter, Is.Null);
|
|
Assert.That(decoded.DisplayName, Is.EqualTo("NoFilter"));
|
|
}
|
|
|
|
[Test]
|
|
public void MonitoredItemStateRoundTripWithEventFilter()
|
|
{
|
|
var original = new MonitoredItemState
|
|
{
|
|
DisplayName = "EventItem",
|
|
NodeClass = NodeClass.Object,
|
|
AttributeId = Attributes.EventNotifier,
|
|
Filter = new EventFilter
|
|
{
|
|
SelectClauses =
|
|
[
|
|
new SimpleAttributeOperand
|
|
{
|
|
TypeDefinitionId = ObjectTypeIds.BaseEventType,
|
|
BrowsePath = [new QualifiedName(BrowseNames.EventId)],
|
|
AttributeId = Attributes.Value
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
MonitoredItemState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.Filter, Is.InstanceOf<EventFilter>());
|
|
var eventFilter = (EventFilter)decoded.Filter;
|
|
Assert.That(eventFilter.SelectClauses.Count, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void SubscriptionStateRoundTripWithDefaults()
|
|
{
|
|
var original = new SubscriptionState();
|
|
|
|
SubscriptionState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.DisplayName, Is.EqualTo(original.DisplayName));
|
|
Assert.That(decoded.PublishingInterval, Is.EqualTo(original.PublishingInterval));
|
|
Assert.That(decoded.KeepAliveCount, Is.EqualTo(original.KeepAliveCount));
|
|
Assert.That(decoded.LifetimeCount, Is.EqualTo(original.LifetimeCount));
|
|
Assert.That(decoded.MaxNotificationsPerPublish, Is.EqualTo(original.MaxNotificationsPerPublish));
|
|
Assert.That(decoded.PublishingEnabled, Is.EqualTo(original.PublishingEnabled));
|
|
Assert.That(decoded.Priority, Is.EqualTo(original.Priority));
|
|
Assert.That(decoded.TimestampsToReturn, Is.EqualTo(original.TimestampsToReturn));
|
|
Assert.That(decoded.MaxMessageCount, Is.EqualTo(original.MaxMessageCount));
|
|
Assert.That(decoded.MonitoredItems.IsEmpty, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void SubscriptionStateRoundTripWithMonitoredItems()
|
|
{
|
|
var timestamp = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Utc);
|
|
var original = new SubscriptionState
|
|
{
|
|
DisplayName = "TestSubscription",
|
|
PublishingInterval = 1000,
|
|
KeepAliveCount = 10,
|
|
LifetimeCount = 30,
|
|
MaxNotificationsPerPublish = 100,
|
|
PublishingEnabled = true,
|
|
Priority = 5,
|
|
TimestampsToReturn = TimestampsToReturn.Source,
|
|
MaxMessageCount = 20,
|
|
MinLifetimeInterval = 5000,
|
|
DisableMonitoredItemCache = true,
|
|
SequentialPublishing = true,
|
|
RepublishAfterTransfer = true,
|
|
TransferId = 42,
|
|
MonitoredItems =
|
|
[
|
|
new MonitoredItemState
|
|
{
|
|
DisplayName = "Item1",
|
|
StartNodeId = new NodeId(1),
|
|
ServerId = 10,
|
|
ClientId = 20
|
|
},
|
|
new MonitoredItemState
|
|
{
|
|
DisplayName = "Item2",
|
|
StartNodeId = new NodeId(2),
|
|
ServerId = 11,
|
|
ClientId = 21
|
|
}
|
|
],
|
|
CurrentPublishingInterval = 1000.0,
|
|
CurrentKeepAliveCount = 10,
|
|
CurrentLifetimeCount = 30,
|
|
Timestamp = timestamp
|
|
};
|
|
|
|
SubscriptionState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.DisplayName, Is.EqualTo("TestSubscription"));
|
|
Assert.That(decoded.PublishingInterval, Is.EqualTo(1000));
|
|
Assert.That(decoded.KeepAliveCount, Is.EqualTo(10u));
|
|
Assert.That(decoded.LifetimeCount, Is.EqualTo(30u));
|
|
Assert.That(decoded.MaxNotificationsPerPublish, Is.EqualTo(100u));
|
|
Assert.That(decoded.PublishingEnabled, Is.True);
|
|
Assert.That(decoded.Priority, Is.EqualTo(5));
|
|
Assert.That(decoded.TimestampsToReturn, Is.EqualTo(TimestampsToReturn.Source));
|
|
Assert.That(decoded.MaxMessageCount, Is.EqualTo(20));
|
|
Assert.That(decoded.MinLifetimeInterval, Is.EqualTo(5000u));
|
|
Assert.That(decoded.DisableMonitoredItemCache, Is.True);
|
|
Assert.That(decoded.SequentialPublishing, Is.True);
|
|
Assert.That(decoded.RepublishAfterTransfer, Is.True);
|
|
Assert.That(decoded.TransferId, Is.EqualTo(42u));
|
|
Assert.That(decoded.MonitoredItems.Count, Is.EqualTo(2));
|
|
Assert.That(decoded.MonitoredItems[0].DisplayName, Is.EqualTo("Item1"));
|
|
Assert.That(decoded.MonitoredItems[1].DisplayName, Is.EqualTo("Item2"));
|
|
Assert.That(decoded.CurrentPublishingInterval, Is.EqualTo(1000.0));
|
|
Assert.That(decoded.CurrentKeepAliveCount, Is.EqualTo(10u));
|
|
Assert.That(decoded.CurrentLifetimeCount, Is.EqualTo(30u));
|
|
Assert.That(decoded.Timestamp, Is.EqualTo(timestamp));
|
|
}
|
|
|
|
[Test]
|
|
public void SubscriptionStateRoundTripWithNullMonitoredItems()
|
|
{
|
|
var original = new SubscriptionState
|
|
{
|
|
DisplayName = "EmptySub"
|
|
};
|
|
|
|
SubscriptionState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.MonitoredItems.IsEmpty, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithMinimalFields()
|
|
{
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "TestSession",
|
|
CheckDomain = false,
|
|
Timestamp = new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = new NodeId(1000),
|
|
AuthenticationToken = new NodeId(2000),
|
|
Identity = null,
|
|
ConfiguredEndpoint = null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.SessionName, Is.EqualTo("TestSession"));
|
|
Assert.That(decoded.CheckDomain, Is.False);
|
|
Assert.That(decoded.SessionId, Is.EqualTo(new NodeId(1000)));
|
|
Assert.That(decoded.AuthenticationToken, Is.EqualTo(new NodeId(2000)));
|
|
Assert.That(decoded.Identity, Is.Null);
|
|
Assert.That(decoded.Subscriptions.IsEmpty, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithConfiguredEndpoint()
|
|
{
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
ServerCertificate = [],
|
|
UserIdentityTokens =
|
|
[
|
|
new UserTokenPolicy { TokenType = UserTokenType.Anonymous }
|
|
]
|
|
};
|
|
|
|
var endpointConfig = new EndpointConfiguration
|
|
{
|
|
OperationTimeout = 60000,
|
|
UseBinaryEncoding = true,
|
|
MaxMessageSize = 4194304,
|
|
MaxBufferSize = 65535,
|
|
ChannelLifetime = 300000,
|
|
SecurityTokenLifetime = 3600000,
|
|
MaxArrayLength = 65535,
|
|
MaxByteStringLength = 1048576,
|
|
MaxStringLength = 1048576,
|
|
MaxEncodingNestingLevels = 200,
|
|
MaxDecoderRecoveries = 10
|
|
};
|
|
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfig)
|
|
{
|
|
UpdateBeforeConnect = true,
|
|
BinaryEncodingSupport = BinaryEncodingSupport.Required,
|
|
SelectedUserTokenPolicyIndex = 0
|
|
};
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "EndpointSession",
|
|
ConfiguredEndpoint = configuredEndpoint,
|
|
CheckDomain = true,
|
|
Timestamp = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = new NodeId("session1", 1),
|
|
AuthenticationToken = new NodeId("auth1", 1)
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.SessionName, Is.EqualTo("EndpointSession"));
|
|
Assert.That(decoded.CheckDomain, Is.True);
|
|
Assert.That(decoded.ConfiguredEndpoint, Is.Not.Null);
|
|
Assert.That(decoded.ConfiguredEndpoint.Description.EndpointUrl,
|
|
Is.EqualTo("opc.tcp://localhost:4840"));
|
|
Assert.That(decoded.ConfiguredEndpoint.Description.SecurityMode,
|
|
Is.EqualTo(MessageSecurityMode.None));
|
|
Assert.That(decoded.ConfiguredEndpoint.UpdateBeforeConnect, Is.True);
|
|
Assert.That(decoded.ConfiguredEndpoint.BinaryEncodingSupport,
|
|
Is.EqualTo(BinaryEncodingSupport.Required));
|
|
Assert.That(decoded.ConfiguredEndpoint.Configuration!.OperationTimeout,
|
|
Is.EqualTo(60000));
|
|
Assert.That(decoded.ConfiguredEndpoint.Configuration.UseBinaryEncoding, Is.True);
|
|
Assert.That(decoded.ConfiguredEndpoint.Configuration.MaxMessageSize,
|
|
Is.EqualTo(4194304));
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithNullEndpointConfiguration()
|
|
{
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
};
|
|
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDescription, null);
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "NullConfigSession",
|
|
ConfiguredEndpoint = configuredEndpoint,
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.ConfiguredEndpoint, Is.Not.Null);
|
|
Assert.That(decoded.ConfiguredEndpoint.Configuration, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithSubscriptions()
|
|
{
|
|
var timestamp = new DateTime(2025, 6, 15, 8, 0, 0, DateTimeKind.Utc);
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "SubSession",
|
|
Timestamp = timestamp,
|
|
SessionId = new NodeId(100),
|
|
AuthenticationToken = new NodeId(200),
|
|
Subscriptions = new List<SubscriptionState> {
|
|
new() {
|
|
DisplayName = "Sub1",
|
|
PublishingInterval = 500,
|
|
MonitoredItems =
|
|
[
|
|
new MonitoredItemState
|
|
{
|
|
DisplayName = "MI1",
|
|
StartNodeId = new NodeId(10),
|
|
ServerId = 1
|
|
}
|
|
]
|
|
}
|
|
}
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.Subscriptions.IsEmpty, Is.False);
|
|
Assert.That(decoded.Subscriptions.Count, Is.EqualTo(1));
|
|
Assert.That(decoded.Subscriptions[0].DisplayName, Is.EqualTo("Sub1"));
|
|
Assert.That(decoded.Subscriptions[0].PublishingInterval, Is.EqualTo(500));
|
|
Assert.That(decoded.Subscriptions[0].MonitoredItems.Count, Is.EqualTo(1));
|
|
Assert.That(decoded.Subscriptions[0].MonitoredItems[0].DisplayName, Is.EqualTo("MI1"));
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithEmptySubscriptions()
|
|
{
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "NoSubs",
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.Subscriptions.IsEmpty, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithAnonymousIdentity()
|
|
{
|
|
var identity = new UserIdentity();
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "AnonSession",
|
|
Identity = identity,
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = new NodeId(1),
|
|
AuthenticationToken = new NodeId(2)
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.Identity, Is.Not.Null);
|
|
Assert.That(decoded.Identity.TokenType, Is.EqualTo(UserTokenType.Anonymous));
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithUserNameIdentity()
|
|
{
|
|
var identity = new UserIdentity("testuser", System.Text.Encoding.UTF8.GetBytes("testpass"));
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "UserSession",
|
|
Identity = identity,
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = new NodeId(1),
|
|
AuthenticationToken = new NodeId(2)
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.Identity, Is.Not.Null);
|
|
Assert.That(decoded.Identity.TokenType, Is.EqualTo(UserTokenType.UserName));
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithReverseConnect()
|
|
{
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
};
|
|
|
|
var configuredEndpoint = new ConfiguredEndpoint(null!, endpointDescription)
|
|
{
|
|
ReverseConnect = new ReverseConnectEndpoint
|
|
{
|
|
Enabled = true,
|
|
ServerUri = "urn:testserver",
|
|
Thumbprint = "AABBCCDD"
|
|
}
|
|
};
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "ReverseSession",
|
|
ConfiguredEndpoint = configuredEndpoint,
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.ConfiguredEndpoint, Is.Not.Null);
|
|
Assert.That(decoded.ConfiguredEndpoint.ReverseConnect, Is.Not.Null);
|
|
Assert.That(decoded.ConfiguredEndpoint.ReverseConnect.Enabled, Is.True);
|
|
Assert.That(decoded.ConfiguredEndpoint.ReverseConnect.ServerUri, Is.EqualTo("urn:testserver"));
|
|
Assert.That(decoded.ConfiguredEndpoint.ReverseConnect.Thumbprint, Is.EqualTo("AABBCCDD"));
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithoutReverseConnect()
|
|
{
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
};
|
|
|
|
var configuredEndpoint = new ConfiguredEndpoint(null!, endpointDescription)
|
|
{
|
|
ReverseConnect = null
|
|
};
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "NoReverseSession",
|
|
ConfiguredEndpoint = configuredEndpoint,
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.ConfiguredEndpoint, Is.Not.Null);
|
|
Assert.That(decoded.ConfiguredEndpoint.ReverseConnect?.Enabled, Is.Not.True);
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithServerNonce()
|
|
{
|
|
byte[] nonceData = [0x01, 0x02, 0x03, 0x04, 0x05];
|
|
var nonce = Nonce.CreateNonce(SecurityPolicies.None, nonceData);
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "NonceSession",
|
|
ServerNonce = ByteString.From(nonce.Data),
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.ServerNonce.IsNull, Is.False);
|
|
Assert.That(decoded.ServerNonce.ToArray(), Is.EqualTo(nonceData));
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripWithNullNonces()
|
|
{
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "NullNonceSession",
|
|
ServerNonce = default,
|
|
ServerEccEphemeralKey = default,
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.ServerNonce.IsNull, Is.True);
|
|
Assert.That(decoded.ServerEccEphemeralKey.IsNull, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripPreservesUserIdentityTokenPolicy()
|
|
{
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "PolicySession",
|
|
UserIdentityTokenPolicy = "http://opcfoundation.org/UA/SecurityPolicy#None",
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.UserIdentityTokenPolicy,
|
|
Is.EqualTo("http://opcfoundation.org/UA/SecurityPolicy#None"));
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationCreateFromStream()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "StreamSession",
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = new NodeId(999),
|
|
AuthenticationToken = new NodeId(888)
|
|
};
|
|
|
|
byte[] data;
|
|
using (var ms = new MemoryStream())
|
|
{
|
|
using (var encoder = new BinaryEncoder(ms, m_context, true))
|
|
{
|
|
encoder.WriteStringArray(null, m_context.NamespaceUris.ToArray());
|
|
encoder.WriteStringArray(null, m_context.ServerUris.ToArray());
|
|
original.Encode(encoder);
|
|
}
|
|
data = ms.ToArray();
|
|
}
|
|
|
|
using var readStream = new MemoryStream(data);
|
|
var decoded = SessionConfiguration.Create(readStream, telemetry);
|
|
|
|
Assert.That(decoded, Is.Not.Null);
|
|
Assert.That(decoded.SessionName, Is.EqualTo("StreamSession"));
|
|
Assert.That(decoded.SessionId, Is.EqualTo(new NodeId(999)));
|
|
Assert.That(decoded.AuthenticationToken, Is.EqualTo(new NodeId(888)));
|
|
}
|
|
|
|
[Test]
|
|
public void EndpointConfigurationRoundTripWithAllFields()
|
|
{
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
};
|
|
|
|
var endpointConfig = new EndpointConfiguration
|
|
{
|
|
OperationTimeout = 30000,
|
|
UseBinaryEncoding = true,
|
|
MaxMessageSize = 2097152,
|
|
MaxBufferSize = 32768,
|
|
ChannelLifetime = 150000,
|
|
SecurityTokenLifetime = 1800000,
|
|
MaxArrayLength = 32768,
|
|
MaxByteStringLength = 524288,
|
|
MaxStringLength = 524288,
|
|
MaxEncodingNestingLevels = 100,
|
|
MaxDecoderRecoveries = 5
|
|
};
|
|
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfig);
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "ConfigTest",
|
|
ConfiguredEndpoint = configuredEndpoint,
|
|
Timestamp = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
SessionId = NodeId.Null,
|
|
AuthenticationToken = NodeId.Null
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
EndpointConfiguration cfg = decoded.ConfiguredEndpoint!.Configuration!;
|
|
Assert.That(cfg!.OperationTimeout, Is.EqualTo(30000));
|
|
Assert.That(cfg.UseBinaryEncoding, Is.True);
|
|
Assert.That(cfg.MaxMessageSize, Is.EqualTo(2097152));
|
|
Assert.That(cfg.MaxBufferSize, Is.EqualTo(32768));
|
|
Assert.That(cfg.ChannelLifetime, Is.EqualTo(150000));
|
|
Assert.That(cfg.SecurityTokenLifetime, Is.EqualTo(1800000));
|
|
Assert.That(cfg.MaxArrayLength, Is.EqualTo(32768));
|
|
Assert.That(cfg.MaxByteStringLength, Is.EqualTo(524288));
|
|
Assert.That(cfg.MaxStringLength, Is.EqualTo(524288));
|
|
}
|
|
|
|
[Test]
|
|
public void MonitoredItemStateRoundTripWithEmptyTriggeredItems()
|
|
{
|
|
var original = new MonitoredItemState
|
|
{
|
|
DisplayName = "EmptyTriggers",
|
|
TriggeredItems = []
|
|
};
|
|
|
|
MonitoredItemState decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.TriggeredItems.Count, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void SessionConfigurationRoundTripFullWithMultipleSubscriptions()
|
|
{
|
|
var endpointDescription = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://server:4840",
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
|
};
|
|
|
|
var configuredEndpoint = new ConfiguredEndpoint(
|
|
null, endpointDescription, EndpointConfiguration.Create());
|
|
|
|
var timestamp = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
var original = new SessionConfiguration
|
|
{
|
|
SessionName = "FullSession",
|
|
Identity = new UserIdentity(),
|
|
ConfiguredEndpoint = configuredEndpoint,
|
|
CheckDomain = true,
|
|
Timestamp = timestamp,
|
|
SessionId = new NodeId(Guid.NewGuid()),
|
|
AuthenticationToken = new NodeId(Guid.NewGuid()),
|
|
UserIdentityTokenPolicy = "policy1",
|
|
Subscriptions = new List<SubscriptionState> {
|
|
new() {
|
|
DisplayName = "Sub1",
|
|
PublishingInterval = 1000,
|
|
MonitoredItems =
|
|
[
|
|
new MonitoredItemState { DisplayName = "MI1" },
|
|
new MonitoredItemState { DisplayName = "MI2" }
|
|
]
|
|
},
|
|
new() {
|
|
DisplayName = "Sub2",
|
|
PublishingInterval = 2000,
|
|
MonitoredItems = []
|
|
}
|
|
}
|
|
};
|
|
|
|
SessionConfiguration decoded = RoundTrip(original);
|
|
|
|
Assert.That(decoded.SessionName, Is.EqualTo("FullSession"));
|
|
Assert.That(decoded.CheckDomain, Is.True);
|
|
Assert.That(decoded.Timestamp, Is.EqualTo(timestamp));
|
|
Assert.That(decoded.UserIdentityTokenPolicy, Is.EqualTo("policy1"));
|
|
Assert.That(decoded.Subscriptions.Count, Is.EqualTo(2));
|
|
Assert.That(decoded.Subscriptions[0].MonitoredItems.Count, Is.EqualTo(2));
|
|
Assert.That(decoded.Subscriptions[1].MonitoredItems, Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void SubscriptionStateSaveLoadRoundTripMimicsSessionSaveLoad()
|
|
{
|
|
// Create a subscription state with monitored items (same as Session.Save does)
|
|
var monitoredItem = new MonitoredItemState
|
|
{
|
|
DisplayName = "TestItem",
|
|
StartNodeId = new NodeId(42, 2),
|
|
NodeClass = NodeClass.Variable,
|
|
AttributeId = Attributes.Value,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
SamplingInterval = -1,
|
|
QueueSize = 1,
|
|
DiscardOldest = true,
|
|
ServerId = 100,
|
|
ClientId = 200
|
|
};
|
|
|
|
var original = new SubscriptionState
|
|
{
|
|
DisplayName = "TestSub",
|
|
PublishingInterval = 1000,
|
|
KeepAliveCount = 5,
|
|
LifetimeCount = 15,
|
|
PublishingEnabled = true,
|
|
TransferId = 999,
|
|
MonitoredItems = [monitoredItem],
|
|
CurrentPublishingInterval = 1000.0,
|
|
CurrentKeepAliveCount = 5,
|
|
CurrentLifetimeCount = 15
|
|
};
|
|
|
|
// Encode exactly like Session.Save does
|
|
using var ms = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(ms, m_context, true))
|
|
{
|
|
// Session.Save writes: nsUris, serverUris, count, then each state.Encode
|
|
encoder.WriteStringArray(null, m_context.NamespaceUris.ToArrayOf());
|
|
encoder.WriteStringArray(null, m_context.ServerUris.ToArrayOf());
|
|
encoder.WriteInt32(null, 1); // count = 1
|
|
original.Encode(encoder);
|
|
}
|
|
|
|
TestContext.Out.WriteLine($"Encoded {ms.Length} bytes");
|
|
|
|
// Decode exactly like Session.Load does
|
|
ms.Position = 0;
|
|
using var decoder = new BinaryDecoder(ms, m_context);
|
|
ArrayOf<string?> nsUris = decoder.ReadStringArray(null);
|
|
ArrayOf<string?> serverUris = decoder.ReadStringArray(null);
|
|
int count = decoder.ReadInt32(null);
|
|
|
|
Assert.That(count, Is.EqualTo(1), "subscription count");
|
|
|
|
var decoded = new SubscriptionState();
|
|
decoded.Decode(decoder);
|
|
|
|
TestContext.Out.WriteLine($"Decoded MonitoredItems.Count = {decoded.MonitoredItems.Count}");
|
|
TestContext.Out.WriteLine($"Decoded DisplayName = {decoded.DisplayName}");
|
|
TestContext.Out.WriteLine($"Decoded TransferId = {decoded.TransferId}");
|
|
|
|
Assert.That(decoded.DisplayName, Is.EqualTo("TestSub"));
|
|
Assert.That(decoded.TransferId, Is.EqualTo(999u));
|
|
Assert.That(decoded.MonitoredItems.Count, Is.EqualTo(1), "MonitoredItems should have 1 item");
|
|
Assert.That(decoded.MonitoredItems[0].DisplayName, Is.EqualTo("TestItem"));
|
|
Assert.That(decoded.MonitoredItems[0].ServerId, Is.EqualTo(100u));
|
|
}
|
|
|
|
[Test]
|
|
public void SubscriptionStateSaveLoadWithoutClientTypesRegistered()
|
|
{
|
|
// Use a plain context WITHOUT AddOpcUaClientDataTypes - like Session.Load does
|
|
var plainContext = ServiceMessageContext.Create(NUnitTelemetryContext.Create());
|
|
// DO NOT call plainContext.Factory.Builder.AddOpcUaClientDataTypes();
|
|
|
|
var monitoredItem = new MonitoredItemState
|
|
{
|
|
DisplayName = "TestItem",
|
|
StartNodeId = new NodeId(42, 2),
|
|
ServerId = 100,
|
|
ClientId = 200
|
|
};
|
|
|
|
var original = new SubscriptionState
|
|
{
|
|
DisplayName = "TestSub",
|
|
TransferId = 999,
|
|
MonitoredItems = [monitoredItem]
|
|
};
|
|
|
|
using var ms = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(ms, plainContext, true))
|
|
{
|
|
encoder.WriteStringArray(null, plainContext.NamespaceUris.ToArrayOf());
|
|
encoder.WriteStringArray(null, plainContext.ServerUris.ToArrayOf());
|
|
encoder.WriteInt32(null, 1);
|
|
original.Encode(encoder);
|
|
}
|
|
|
|
TestContext.Out.WriteLine($"Encoded {ms.Length} bytes");
|
|
|
|
ms.Position = 0;
|
|
using var decoder = new BinaryDecoder(ms, plainContext);
|
|
decoder.ReadStringArray(null);
|
|
decoder.ReadStringArray(null);
|
|
int count = decoder.ReadInt32(null);
|
|
Assert.That(count, Is.EqualTo(1));
|
|
|
|
var decoded = new SubscriptionState();
|
|
decoded.Decode(decoder);
|
|
|
|
TestContext.Out.WriteLine($"MonitoredItems.Count = {decoded.MonitoredItems.Count}");
|
|
Assert.That(decoded.MonitoredItems.Count, Is.EqualTo(1),
|
|
"MonitoredItems lost without client types - this is the reconnect bug!");
|
|
Assert.That(decoded.MonitoredItems[0].ServerId, Is.EqualTo(100u));
|
|
}
|
|
|
|
[Test]
|
|
public void SubscriptionStateDecodeDebugByteLevelVerification()
|
|
{
|
|
// Create realistic data matching what a real subscription produces
|
|
var items = new List<MonitoredItemState>();
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
items.Add(new MonitoredItemState
|
|
{
|
|
DisplayName = $"MonitoredItem {i}",
|
|
StartNodeId = new NodeId((uint)(1880 + i), 3),
|
|
NodeClass = NodeClass.Variable,
|
|
AttributeId = Attributes.Value,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
SamplingInterval = -1,
|
|
QueueSize = 10,
|
|
DiscardOldest = true,
|
|
ServerId = (uint)(100 + i),
|
|
ClientId = (uint)(200 + i)
|
|
});
|
|
}
|
|
|
|
var original = new SubscriptionState
|
|
{
|
|
DisplayName = "Subscription",
|
|
PublishingInterval = 1000,
|
|
KeepAliveCount = 5,
|
|
LifetimeCount = 15,
|
|
PublishingEnabled = true,
|
|
RepublishAfterTransfer = true,
|
|
TransferId = 12345,
|
|
MonitoredItems = items,
|
|
CurrentPublishingInterval = 1000.0,
|
|
CurrentKeepAliveCount = 5,
|
|
CurrentLifetimeCount = 15
|
|
};
|
|
|
|
// Use a plain context (like Session uses)
|
|
var context = ServiceMessageContext.Create(NUnitTelemetryContext.Create());
|
|
|
|
using var ms = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(ms, context, true))
|
|
{
|
|
encoder.WriteInt32(null, 1); // count
|
|
original.Encode(encoder);
|
|
}
|
|
|
|
byte[] bytes = ms.ToArray();
|
|
TestContext.Out.WriteLine($"Total bytes: {bytes.Length}");
|
|
// Print first 100 bytes as hex
|
|
TestContext.Out.WriteLine("First 100 bytes: " +
|
|
BitConverter.ToString(bytes, 0, Math.Min(100, bytes.Length)));
|
|
|
|
ms.Position = 0;
|
|
using var decoder = new BinaryDecoder(ms, context);
|
|
int count = decoder.ReadInt32(null);
|
|
Assert.That(count, Is.EqualTo(1));
|
|
|
|
long posBeforeDecode = ms.Position;
|
|
TestContext.Out.WriteLine($"Position before Decode: {posBeforeDecode}");
|
|
|
|
var decoded = new SubscriptionState();
|
|
decoded.Decode(decoder);
|
|
|
|
long posAfterDecode = ms.Position;
|
|
TestContext.Out.WriteLine($"Position after Decode: {posAfterDecode}");
|
|
TestContext.Out.WriteLine($"Bytes consumed: {posAfterDecode - posBeforeDecode}");
|
|
|
|
TestContext.Out.WriteLine($"Decoded DisplayName: {decoded.DisplayName}");
|
|
TestContext.Out.WriteLine($"Decoded TransferId: {decoded.TransferId}");
|
|
TestContext.Out.WriteLine($"Decoded MonitoredItems.Count: {decoded.MonitoredItems.Count}");
|
|
for (int i = 0; i < decoded.MonitoredItems.Count; i++)
|
|
{
|
|
TestContext.Out.WriteLine($" Item[{i}]: {decoded.MonitoredItems[i].DisplayName} ServerId={decoded.MonitoredItems[i].ServerId}");
|
|
}
|
|
|
|
Assert.That(decoded.DisplayName, Is.EqualTo("Subscription"));
|
|
Assert.That(decoded.TransferId, Is.EqualTo(12345u));
|
|
Assert.That(decoded.MonitoredItems.Count, Is.EqualTo(10), "10 monitored items expected");
|
|
Assert.That(decoded.MonitoredItems[0].ServerId, Is.EqualTo(100u));
|
|
Assert.That(decoded.MonitoredItems[9].ServerId, Is.EqualTo(109u));
|
|
}
|
|
|
|
private T RoundTrip<T>(T original) where T : IEncodeable, new()
|
|
{
|
|
using var ms = new MemoryStream();
|
|
using (var encoder = new BinaryEncoder(ms, m_context, true))
|
|
{
|
|
original.Encode(encoder);
|
|
}
|
|
|
|
ms.Position = 0;
|
|
using var decoder = new BinaryDecoder(ms, m_context);
|
|
var decoded = new T();
|
|
decoded.Decode(decoder);
|
|
return decoded;
|
|
}
|
|
}
|
|
}
|