Files
Marc Schier b1c0cd20fd Fix classic browse-path subscription restoration (#4814)
# 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.
2026-10-16 20:06:51 +02:00

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;
}
}
}