/* ======================================================================== * 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.Reflection; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; using Opc.Ua.Tests; namespace Opc.Ua.Client.Tests { /// /// Unit tests for SessionReconnectHandler, focusing on endpoint fallback behavior /// when the server's endpoint configuration changes. /// [TestFixture] [Category("Client")] [Category("SessionReconnectHandler")] [SetCulture("en-us")] [SetUICulture("en-us")] public sealed class SessionReconnectHandlerTests { private ITelemetryContext m_telemetry; [SetUp] public void SetUp() { m_telemetry = NUnitTelemetryContext.Create(); } /// /// Verifies that the reconnect handler calls UpdateEndpointFromServerAsync /// when m_updateFromServer is set and session recreation succeeds. /// [Test] public async Task DoReconnectAsync_WhenUpdateFromServerIsSet_CallsUpdateEndpointFromServerAsync() { // Arrange int updateCallCount = 0; var mockNewSession = new Mock(); var mockFactory = new Mock(); ConfiguredEndpoint configuredEndpoint = CreateConfiguredEndpoint( MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256); Mock mockSession = CreateMockSession(configuredEndpoint, mockFactory); mockFactory.Setup(f => f.RecreateAsync(mockSession.Object, It.IsAny())) .ReturnsAsync(mockNewSession.Object); using var handler = new TestableSessionReconnectHandler( m_telemetry, (endpoint, connection) => { updateCallCount++; return Task.CompletedTask; }); SetSessionHandlerField(handler, "m_reconnectFailed", true); SetSessionHandlerField(handler, "m_updateFromServer", true); SetHandlerSession(handler, mockSession.Object); // Act bool result = await InvokeDoReconnectAsync(handler).ConfigureAwait(false); // Assert Assert.That(result, Is.True); Assert.That(updateCallCount, Is.EqualTo(1)); Assert.That(handler.Session, Is.SameAs(mockNewSession.Object)); } /// /// Verifies that DoReconnectAsync does not call UpdateEndpointFromServerAsync /// when m_updateFromServer is false. /// [Test] public async Task DoReconnectAsync_WhenUpdateFromServerIsFalse_SkipsEndpointUpdate() { // Arrange int updateCallCount = 0; var mockNewSession = new Mock(); var mockFactory = new Mock(); ConfiguredEndpoint configuredEndpoint = CreateConfiguredEndpoint( MessageSecurityMode.None, SecurityPolicies.None); Mock mockSession = CreateMockSession(configuredEndpoint, mockFactory); mockFactory.Setup(f => f.RecreateAsync(mockSession.Object, It.IsAny())) .ReturnsAsync(mockNewSession.Object); using var handler = new TestableSessionReconnectHandler( m_telemetry, (endpoint, connection) => { updateCallCount++; return Task.CompletedTask; }); SetSessionHandlerField(handler, "m_reconnectFailed", true); SetSessionHandlerField(handler, "m_updateFromServer", false); SetHandlerSession(handler, mockSession.Object); // Act bool result = await InvokeDoReconnectAsync(handler).ConfigureAwait(false); // Assert Assert.That(result, Is.True); Assert.That(updateCallCount, Is.Zero); } /// /// Verifies that when UpdateEndpointFromServerAsync fails with a ServiceResultException, /// the session recreation is skipped, m_updateFromServer is set for the next attempt, /// and false is returned. /// [Test] public async Task DoReconnectAsync_WhenUpdateEndpointFails_SetsUpdateFromServerAndReturnsFalse() { // Arrange var mockFactory = new Mock(); ConfiguredEndpoint configuredEndpoint = CreateConfiguredEndpoint( MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Aes256_Sha256_RsaPss); Mock mockSession = CreateMockSession(configuredEndpoint, mockFactory); using var handler = new TestableSessionReconnectHandler( m_telemetry, (endpoint, connection) => throw new ServiceResultException(StatusCodes.BadNoCommunication)); SetSessionHandlerField(handler, "m_reconnectFailed", true); SetSessionHandlerField(handler, "m_updateFromServer", true); SetHandlerSession(handler, mockSession.Object); // Act bool result = await InvokeDoReconnectAsync(handler).ConfigureAwait(false); // Assert Assert.That(result, Is.False); bool updateFromServer = GetSessionHandlerField(handler, "m_updateFromServer"); Assert.That(updateFromServer, Is.True); } /// /// Verifies that UpdateEndpointFromServerAsync is a protected virtual method /// that can be overridden to customize the endpoint update behavior. /// [Test] public void UpdateEndpointFromServerAsync_IsProtectedVirtual() { MethodInfo method = typeof(SessionReconnectHandler).GetMethod( "UpdateEndpointFromServerAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; Assert.That(method, Is.Not.Null); Assert.That(method.IsVirtual, Is.True); Assert.That(method.IsFamily, Is.True); // protected } /// /// Verifies that UpdateEndpointFromServerAsync accepts a ConfiguredEndpoint /// and a nullable ITransportWaitingConnection parameter (for reverse connect support). /// [Test] public void UpdateEndpointFromServerAsync_HasCorrectSignature() { MethodInfo method = typeof(SessionReconnectHandler).GetMethod( "UpdateEndpointFromServerAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; Assert.That(method, Is.Not.Null); ParameterInfo[] parameters = method.GetParameters(); Assert.That(parameters, Has.Length.EqualTo(2)); Assert.That(parameters[0].ParameterType, Is.EqualTo(typeof(ConfiguredEndpoint))); Assert.That(parameters[1].ParameterType, Is.EqualTo(typeof(ITransportWaitingConnection))); } /// /// Verifies that /// throws when given an /// that is not a /// (or a derived type). The handler drives /// /// and the underlying transport channel directly, which only /// makes sense for the legacy class. /// Facade implementations (e.g. ) /// run their own reconnect state machine and must not be /// passed in. /// [Test] public void BeginReconnect_WithNonSessionImplementation_ThrowsNotSupportedException() { var mockSession = new Mock(); mockSession.SetupGet(s => s.SessionId).Returns(NodeId.Null); using var handler = new SessionReconnectHandler(m_telemetry); Assert.That( () => handler.BeginReconnect( mockSession.Object, 1000, (_, _) => { }), Throws.TypeOf()); } /// /// Verifies that the reverse-connect overload also rejects /// non- implementations of /// with . /// [Test] public void BeginReconnect_ReverseConnect_WithNonSessionImplementation_ThrowsNotSupportedException() { var mockSession = new Mock(); mockSession.SetupGet(s => s.SessionId).Returns(NodeId.Null); using var handler = new SessionReconnectHandler(m_telemetry); Assert.That( () => handler.BeginReconnect( mockSession.Object, null, 1000, (_, _) => { }), Throws.TypeOf()); } /// /// Verifies that passing as the session /// remains supported as a cancellation signal and does not /// trigger the type check. /// [Test] public void BeginReconnect_WithNullSession_DoesNotThrowAndStaysReady() { using var handler = new SessionReconnectHandler(m_telemetry); SessionReconnectHandler.ReconnectState state = handler.BeginReconnect( null!, 1000, (_, _) => { }); Assert.That(state, Is.EqualTo(SessionReconnectHandler.ReconnectState.Ready)); } /// /// Verifies that when the endpoint update succeeds after a simulated /// BadSecurityPolicyRejected fallback (modeled via TestableSessionReconnectHandler), /// the session is recreated and the reconnect succeeds. /// This simulates a server restarting with a different endpoint configuration where /// the fallback selects the best available endpoint. /// [Test] public async Task DoReconnectAsync_WhenEndpointUpdateSucceedsAfterFallback_SessionIsRecreated() { // Arrange var mockNewSession = new Mock(); var mockFactory = new Mock(); ConfiguredEndpoint configuredEndpoint = CreateConfiguredEndpoint( MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Aes256_Sha256_RsaPss); Mock mockSession = CreateMockSession(configuredEndpoint, mockFactory); mockFactory.Setup(f => f.RecreateAsync(mockSession.Object, It.IsAny())) .ReturnsAsync(mockNewSession.Object); // Simulate: the handler successfully updates the endpoint // (the base class would have fallen back to best available endpoint here) using var handler = new TestableSessionReconnectHandler( m_telemetry, (endpoint, connection) => Task.CompletedTask); SetSessionHandlerField(handler, "m_reconnectFailed", true); SetSessionHandlerField(handler, "m_updateFromServer", true); SetHandlerSession(handler, mockSession.Object); // Act bool result = await InvokeDoReconnectAsync(handler).ConfigureAwait(false); // Assert Assert.That(result, Is.True); Assert.That(handler.Session, Is.SameAs(mockNewSession.Object)); } /// /// The fallback must never downgrade a secured endpoint, even if the /// (unauthenticated) discovery reply advertises a weaker endpoint with a /// high SecurityLevel. /// [Test] public void SelectFallbackEndpoint_OnlyWeakerEndpoints_ReturnsNull() { var original = new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }; ArrayOf discovered = [ new EndpointDescription { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None, SecurityLevel = 255 }, new EndpointDescription { SecurityMode = MessageSecurityMode.Sign, SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss, SecurityLevel = 255 }, new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic128Rsa15, SecurityLevel = 255 } ]; EndpointDescription fallback = SessionReconnectHandler.SelectFallbackEndpoint( original, discovered, m_telemetry.CreateLogger())!; Assert.That(fallback, Is.Null); } /// /// The fallback picks the strongest endpoint by the locally computed /// security level, ignoring the server advertised SecurityLevel. /// [Test] public void SelectFallbackEndpoint_PicksStrongestByLocalSecurityLevel() { var original = new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }; var weakerAdvertisedHigh = new EndpointDescription { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None, SecurityLevel = 255 }; var aes128 = new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep, SecurityLevel = 200 }; var aes256 = new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss, SecurityLevel = 1 }; EndpointDescription fallback = SessionReconnectHandler.SelectFallbackEndpoint( original, [weakerAdvertisedHigh, aes128, aes256], m_telemetry.CreateLogger())!; Assert.That(fallback, Is.SameAs(aes256)); } /// /// Even an unsecured original endpoint only falls back to a secured one. /// [Test] public void SelectFallbackEndpoint_NoneOriginal_NeverSelectsNone() { var original = new EndpointDescription { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None }; var none = new EndpointDescription { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None }; var signed = new EndpointDescription { SecurityMode = MessageSecurityMode.Sign, SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }; Assert.That( SessionReconnectHandler.SelectFallbackEndpoint( original, [none], m_telemetry.CreateLogger()), Is.Null); Assert.That( SessionReconnectHandler.SelectFallbackEndpoint( original, [none, signed], m_telemetry.CreateLogger()), Is.SameAs(signed)); } private static ConfiguredEndpoint CreateConfiguredEndpoint( MessageSecurityMode securityMode, string securityPolicyUri) { return new ConfiguredEndpoint(null!, new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840", SecurityMode = securityMode, SecurityPolicyUri = securityPolicyUri }); } private static Mock CreateMockSession( ConfiguredEndpoint configuredEndpoint, Mock mockFactory) { var mockSession = new Mock(); mockSession.SetupGet(s => s.ConfiguredEndpoint).Returns(configuredEndpoint); mockSession.SetupGet(s => s.SessionFactory).Returns(mockFactory.Object); mockSession.SetupGet(s => s.SessionId).Returns(NodeId.Null); return mockSession; } private static Task InvokeDoReconnectAsync(SessionReconnectHandler handler) { MethodInfo method = typeof(SessionReconnectHandler) .GetMethod("DoReconnectAsync", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("DoReconnectAsync method not found"); return (Task)method.Invoke(handler, null)!; } private static void SetSessionHandlerField( SessionReconnectHandler handler, string fieldName, object value) { FieldInfo field = typeof(SessionReconnectHandler) .GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException($"Field {fieldName} not found"); field.SetValue(handler, value); } private static void SetHandlerSession(SessionReconnectHandler handler, ISession session) { PropertyInfo prop = typeof(SessionReconnectHandler) .GetProperty("Session", BindingFlags.Public | BindingFlags.Instance) ?? throw new InvalidOperationException("Session property not found"); MethodInfo setter = prop.GetSetMethod(nonPublic: true) ?? throw new InvalidOperationException("Session property setter not found"); setter.Invoke(handler, [session]); } private static T GetSessionHandlerField(SessionReconnectHandler handler, string fieldName) { FieldInfo field = typeof(SessionReconnectHandler) .GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException($"Field {fieldName} not found"); return (T)field.GetValue(handler)!; } /// /// A testable subclass of SessionReconnectHandler that replaces the /// UpdateEndpointFromServerAsync with a user-supplied delegate, enabling /// unit tests to control the behavior without requiring network access. /// private sealed class TestableSessionReconnectHandler : SessionReconnectHandler { private readonly Func _updateDelegate; public TestableSessionReconnectHandler( ITelemetryContext telemetry, Func updateDelegate) : base(telemetry) { _updateDelegate = updateDelegate; } protected override Task UpdateEndpointFromServerAsync( ConfiguredEndpoint endpoint, ITransportWaitingConnection? connection = null) { return _updateDelegate(endpoint, connection!); } } } }