mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
Fail closed when reconnect cannot preserve endpoint security (#4809)
# Description It was reported that endpoint refresh during reconnect (`UpdateBeforeConnect` / reconnect endpoint update) could silently change a connection's security configuration (e.g. from `SignAndEncrypt` to a different mode/policy) without the application knowing. Investigation showed the hard downgrade paths had already been fixed, but the legacy `SessionReconnectHandler` still had a composite-score fallback that could select a *different* security mode/policy when the original combination was no longer offered by the (unauthenticated) discovery reply. A stronger mode could offset a weaker or deprecated policy in that score. Managed failover/alternate-network endpoints also had no explicit mode/policy guard. Changes: - **`SessionReconnectHandler`**: removed `SelectFallbackEndpoint` and the alternate-policy fallback. If the exact mode/policy is unavailable, reconnect fails (logged as an error) instead of changing security. - **`ManagedSession`**: added a mode/policy mismatch check; mismatched alternate-network endpoints, reactivation targets and failover/recreation targets are rejected with `BadSecurityModeRejected` / `BadSecurityPolicyRejected` before a channel is opened. - Endpoint refresh for **certificate rotation is retained**; new server certificates are still subject to normal trust validation. - **Docs** (`docs/Sessions.md`): documents exact mode/policy preservation during recovery and that `UpdateBeforeConnect` does not authorize security negotiation. Changing policy requires explicit reconfiguration and a new connection. - **Tests**: replaced fallback-selection tests with a fail-closed legacy reconnect test; added managed alternate-endpoint policy mismatch and failover mode mismatch tests. Validation: - `dotnet test tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj -f net10.0 --filter "FullyQualifiedName~SessionReconnectHandlerTests|FullyQualifiedName~ManagedSessionTests"` — passed (48/48) - `dotnet test tests/Opc.Ua.Core.Tests/Opc.Ua.Core.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ConfiguredEndpointTests"` — passed (46/46) - Full UA.slnx suite and net48 tests: not run locally. ## Related Issues - Reported security concern: reconnect endpoint refresh could change the session's security configuration without notifying the application. ## 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. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] 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. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -361,6 +361,161 @@ namespace Opc.Ua.Client.Tests.ManagedSession
|
||||
Assert.That(capturedEndpoint, Is.SameAs(alternateEndpoint));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleConnectAsyncSkipsIncompatibleAlternateBeforeNextCompatibleEndpointAsync()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
||||
ConfiguredEndpoint primaryEndpoint = CreateEndpoint();
|
||||
primaryEndpoint.Description.SecurityMode = MessageSecurityMode.SignAndEncrypt;
|
||||
primaryEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
|
||||
ConfiguredEndpoint incompatibleEndpoint = CreateEndpoint();
|
||||
incompatibleEndpoint.Description.EndpointUrl = "opc.tcp://incompatible:4840";
|
||||
incompatibleEndpoint.Description.SecurityMode = MessageSecurityMode.SignAndEncrypt;
|
||||
incompatibleEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss;
|
||||
ConfiguredEndpoint compatibleEndpoint = CreateEndpoint();
|
||||
compatibleEndpoint.Description.EndpointUrl = "opc.tcp://compatible:4840";
|
||||
compatibleEndpoint.Description.SecurityMode = MessageSecurityMode.SignAndEncrypt;
|
||||
compatibleEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
|
||||
|
||||
var attemptedEndpoints = new List<ConfiguredEndpoint>();
|
||||
var sessionFactory = new Mock<ISessionFactory>();
|
||||
sessionFactory.SetupGet(factory => factory.Telemetry).Returns(telemetry);
|
||||
sessionFactory.Setup(factory => factory.WithSubscriptionEngine(
|
||||
It.IsAny<ISubscriptionEngineFactory>(),
|
||||
It.IsAny<TimeProvider?>()))
|
||||
.Returns(sessionFactory.Object);
|
||||
sessionFactory.Setup(factory => factory.CreateAsync(
|
||||
It.IsAny<ApplicationConfiguration>(),
|
||||
It.IsAny<ConfiguredEndpoint>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<uint>(),
|
||||
It.IsAny<IUserIdentity>(),
|
||||
It.IsAny<ArrayOf<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((
|
||||
ApplicationConfiguration _,
|
||||
ConfiguredEndpoint endpoint,
|
||||
bool _,
|
||||
bool _,
|
||||
string _,
|
||||
uint _,
|
||||
IUserIdentity _,
|
||||
ArrayOf<string> _,
|
||||
CancellationToken _) =>
|
||||
{
|
||||
attemptedEndpoints.Add(endpoint);
|
||||
StatusCode statusCode = ReferenceEquals(endpoint, primaryEndpoint)
|
||||
? StatusCodes.BadSecureChannelClosed
|
||||
: StatusCodes.BadCertificateUntrusted;
|
||||
return Task.FromException<ISession>(new ServiceResultException(statusCode));
|
||||
});
|
||||
|
||||
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
||||
configuration,
|
||||
primaryEndpoint,
|
||||
null,
|
||||
telemetry,
|
||||
new NetworkRedundancyOptions
|
||||
{
|
||||
AlternateEndpoints = new ArrayOf<ConfiguredEndpoint>(
|
||||
new[] { incompatibleEndpoint, compatibleEndpoint }.AsMemory())
|
||||
},
|
||||
sessionFactory: sessionFactory.Object);
|
||||
|
||||
ServiceResult result = await InvokeHandleConnectAsync(managedSession)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadCertificateUntrusted));
|
||||
Assert.That(attemptedEndpoints, Is.EqualTo(new[] { primaryEndpoint, compatibleEndpoint }));
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task HandleReconnectAsyncSkipsAlternateWithDifferentSecurityPolicyAsync(
|
||||
bool hasCompatibleAlternate)
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
||||
ConfiguredEndpoint primaryEndpoint = CreateEndpoint();
|
||||
ConfiguredEndpoint alternateEndpoint = CreateEndpoint();
|
||||
alternateEndpoint.Description.EndpointUrl = "opc.tcp://alternate:4840";
|
||||
alternateEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
|
||||
ConfiguredEndpoint compatibleEndpoint = CreateEndpoint();
|
||||
compatibleEndpoint.Description.EndpointUrl = "opc.tcp://compatible:4840";
|
||||
IServiceMessageContext messageContext = configuration.CreateMessageContext();
|
||||
|
||||
var managedChannel = new Mock<IManagedTransportChannel>();
|
||||
managedChannel.SetupGet(channel => channel.MessageContext).Returns(messageContext);
|
||||
managedChannel.SetupGet(channel => channel.State).Returns(ChannelState.Closed);
|
||||
|
||||
ConfiguredEndpoint? capturedEndpoint = null;
|
||||
var channelManager = new Mock<IClientChannelManager>();
|
||||
#if NET8_0_OR_GREATER
|
||||
channelManager.Setup(manager => manager.ReconnectAsync(
|
||||
managedChannel.Object,
|
||||
It.IsAny<IRetryBudget>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
#else
|
||||
channelManager.Setup(manager => manager.ReconnectAsync(
|
||||
managedChannel.Object, It.IsAny<CancellationToken>()))
|
||||
#endif
|
||||
.Throws(new ServiceResultException(StatusCodes.BadSecureChannelClosed));
|
||||
channelManager.Setup(manager => manager.GetAsync(
|
||||
It.IsAny<ConfiguredEndpoint>(),
|
||||
It.IsAny<Func<IManagedTransportChannel, IReconnectParticipant>>(),
|
||||
It.IsAny<ITransportWaitingConnection>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfiguredEndpoint, Func<IManagedTransportChannel, IReconnectParticipant>,
|
||||
ITransportWaitingConnection?, CancellationToken>(
|
||||
(endpoint, _, _, _) => capturedEndpoint = endpoint)
|
||||
.Throws(new ServiceResultException(StatusCodes.BadUnexpectedError));
|
||||
|
||||
using var innerSession = new Session(
|
||||
managedChannel.Object,
|
||||
configuration,
|
||||
primaryEndpoint,
|
||||
engineFactory: DefaultSubscriptionEngineFactory.Instance);
|
||||
innerSession.BindManagedChannel(channelManager.Object, managedChannel.Object);
|
||||
|
||||
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
||||
configuration,
|
||||
primaryEndpoint,
|
||||
innerSession,
|
||||
telemetry,
|
||||
new NetworkRedundancyOptions
|
||||
{
|
||||
AlternateEndpoints = hasCompatibleAlternate
|
||||
? new ArrayOf<ConfiguredEndpoint>(
|
||||
new[] { alternateEndpoint, compatibleEndpoint }.AsMemory())
|
||||
: new ArrayOf<ConfiguredEndpoint>(new[] { alternateEndpoint }.AsMemory())
|
||||
});
|
||||
|
||||
ServiceResult result = await InvokeHandleReconnectAsync(
|
||||
managedSession,
|
||||
new RetryBudget(TimeSpan.FromSeconds(30)))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
result.StatusCode,
|
||||
Is.EqualTo(hasCompatibleAlternate
|
||||
? StatusCodes.BadUnexpectedError
|
||||
: StatusCodes.BadSecurityPolicyRejected));
|
||||
if (hasCompatibleAlternate)
|
||||
{
|
||||
Assert.That(capturedEndpoint, Is.SameAs(compatibleEndpoint));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(capturedEndpoint, Is.Null);
|
||||
}
|
||||
Assert.That(
|
||||
primaryEndpoint.Description.SecurityPolicyUri,
|
||||
Is.EqualTo(SecurityPolicies.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleFailoverAsyncUsesInnerSessionCurrentEndpointForTargetSelectionAsync()
|
||||
{
|
||||
@@ -370,6 +525,9 @@ namespace Opc.Ua.Client.Tests.ManagedSession
|
||||
initialEndpoint.Description.EndpointUrl = "opc.tcp://initial:4840";
|
||||
ConfiguredEndpoint activeEndpoint = CreateEndpoint();
|
||||
activeEndpoint.Description.EndpointUrl = "opc.tcp://active:4840";
|
||||
ConfiguredEndpoint failoverEndpoint = CreateEndpoint();
|
||||
failoverEndpoint.Description.SecurityMode = MessageSecurityMode.Sign;
|
||||
failoverEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
|
||||
IServiceMessageContext messageContext = configuration.CreateMessageContext();
|
||||
|
||||
var managedChannel = new Mock<IManagedTransportChannel>();
|
||||
@@ -382,7 +540,7 @@ namespace Opc.Ua.Client.Tests.ManagedSession
|
||||
It.IsAny<ConfiguredEndpoint>()))
|
||||
.Callback<ServerRedundancyInfo, ConfiguredEndpoint>(
|
||||
(_, currentEndpoint) => capturedCurrentEndpoint = currentEndpoint)
|
||||
.Returns((ConfiguredEndpoint?)null);
|
||||
.Returns(failoverEndpoint);
|
||||
|
||||
using var innerSession = new Session(
|
||||
managedChannel.Object,
|
||||
@@ -408,8 +566,9 @@ namespace Opc.Ua.Client.Tests.ManagedSession
|
||||
new RetryBudget(TimeSpan.FromSeconds(30)))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
|
||||
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSecurityModeRejected));
|
||||
Assert.That(capturedCurrentEndpoint, Is.SameAs(activeEndpoint));
|
||||
Assert.That(activeEndpoint.Description.SecurityMode, Is.EqualTo(MessageSecurityMode.None));
|
||||
redundancyHandler.Verify(h => h.SelectFailoverTarget(
|
||||
It.IsAny<ServerRedundancyInfo>(),
|
||||
It.IsAny<ConfiguredEndpoint>()),
|
||||
@@ -1498,6 +1657,28 @@ namespace Opc.Ua.Client.Tests.ManagedSession
|
||||
#pragma warning restore CA1873
|
||||
}
|
||||
|
||||
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for reconnect-path tests that need the private handler.
|
||||
private static Task<ServiceResult> InvokeHandleConnectAsync(
|
||||
Client.ManagedSession managedSession)
|
||||
{
|
||||
MethodInfo? method = typeof(Client.ManagedSession).GetMethod(
|
||||
"HandleConnectAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
null,
|
||||
[typeof(CancellationToken)],
|
||||
null);
|
||||
|
||||
Assert.That(method, Is.Not.Null);
|
||||
|
||||
var task = (Task<ServiceResult>?)method!.Invoke(
|
||||
managedSession,
|
||||
[CancellationToken.None]);
|
||||
|
||||
Assert.That(task, Is.Not.Null);
|
||||
return task!;
|
||||
}
|
||||
#pragma warning restore IDE0051, RCS1213
|
||||
|
||||
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for reconnect-path tests that need the private handler.
|
||||
private static Task<ServiceResult> InvokeHandleReconnectAsync(
|
||||
Client.ManagedSession managedSession,
|
||||
@@ -1548,15 +1729,16 @@ namespace Opc.Ua.Client.Tests.ManagedSession
|
||||
private static Client.ManagedSession CreateManagedSessionWithInner(
|
||||
ApplicationConfiguration configuration,
|
||||
ConfiguredEndpoint endpoint,
|
||||
Session innerSession,
|
||||
Session? innerSession,
|
||||
ITelemetryContext telemetry,
|
||||
NetworkRedundancyOptions? networkRedundancy = null,
|
||||
IServerRedundancyHandler? redundancyHandler = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
ISessionFactory? sessionFactory = null)
|
||||
{
|
||||
ILogger<Client.ManagedSession> logger = telemetry.CreateLogger<Client.ManagedSession>();
|
||||
var sessionFactory = new Mock<ISessionFactory>();
|
||||
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
||||
var defaultSessionFactory = new Mock<ISessionFactory>();
|
||||
defaultSessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
||||
var reconnectPolicy = new ReconnectPolicy(new ReconnectPolicyOptions
|
||||
{
|
||||
MaxTotalReconnectTime = TimeSpan.FromSeconds(30)
|
||||
@@ -1595,7 +1777,7 @@ namespace Opc.Ua.Client.Tests.ManagedSession
|
||||
[
|
||||
configuration,
|
||||
endpoint,
|
||||
sessionFactory.Object,
|
||||
sessionFactory ?? defaultSessionFactory.Object,
|
||||
reconnectPolicy,
|
||||
redundancyHandler,
|
||||
logger,
|
||||
|
||||
@@ -38,7 +38,7 @@ using Opc.Ua.Tests;
|
||||
namespace Opc.Ua.Client.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit tests for SessionReconnectHandler, focusing on endpoint fallback behavior
|
||||
/// Unit tests for SessionReconnectHandler, focusing on endpoint refresh behavior
|
||||
/// when the server's endpoint configuration changes.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
@@ -274,32 +274,28 @@ namespace Opc.Ua.Client.Tests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Verifies that a failed exact-security endpoint refresh prevents
|
||||
/// recreation under a different security configuration.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task DoReconnectAsync_WhenEndpointUpdateSucceedsAfterFallback_SessionIsRecreated()
|
||||
public async Task DoReconnectAsync_WhenEndpointSecurityConfigurationIsUnavailable_DoesNotRecreateSession()
|
||||
{
|
||||
// Arrange
|
||||
var mockNewSession = new Mock<ISession>();
|
||||
var mockFactory = new Mock<ISessionFactory>();
|
||||
|
||||
ConfiguredEndpoint configuredEndpoint = CreateConfiguredEndpoint(
|
||||
MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicies.Aes256_Sha256_RsaPss);
|
||||
SecurityPolicies.Basic256Sha256);
|
||||
|
||||
Mock<ISession> mockSession = CreateMockSession(configuredEndpoint, mockFactory);
|
||||
mockFactory.Setup(f => f.RecreateAsync(mockSession.Object, It.IsAny<CancellationToken>()))
|
||||
.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(
|
||||
var discoveredEndpoint = new EndpointDescription
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
|
||||
};
|
||||
using var handler = new ControlledDiscoverySessionReconnectHandler(
|
||||
m_telemetry,
|
||||
(endpoint, connection) => Task.CompletedTask);
|
||||
discoveredEndpoint);
|
||||
|
||||
SetSessionHandlerField(handler, "m_reconnectFailed", true);
|
||||
SetSessionHandlerField(handler, "m_updateFromServer", true);
|
||||
@@ -309,126 +305,17 @@ namespace Opc.Ua.Client.Tests
|
||||
bool result = await InvokeDoReconnectAsync(handler).ConfigureAwait(false);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.True);
|
||||
Assert.That(handler.Session, Is.SameAs(mockNewSession.Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fallback must never downgrade a secured endpoint, even if the
|
||||
/// (unauthenticated) discovery reply advertises a weaker endpoint with a
|
||||
/// high SecurityLevel.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SelectFallbackEndpoint_OnlyWeakerEndpoints_ReturnsNull()
|
||||
{
|
||||
var original = new EndpointDescription
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
||||
};
|
||||
ArrayOf<EndpointDescription> 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<SessionReconnectHandlerTests>())!;
|
||||
|
||||
Assert.That(fallback, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fallback picks the strongest endpoint by the locally computed
|
||||
/// security level, ignoring the server advertised SecurityLevel.
|
||||
/// </summary>
|
||||
[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<SessionReconnectHandlerTests>())!;
|
||||
|
||||
Assert.That(fallback, Is.SameAs(aes256));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Even an unsecured original endpoint only falls back to a secured one.
|
||||
/// </summary>
|
||||
[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(result, Is.False);
|
||||
Assert.That(handler.Session, Is.SameAs(mockSession.Object));
|
||||
Assert.That(configuredEndpoint.Description.SecurityMode, Is.EqualTo(MessageSecurityMode.SignAndEncrypt));
|
||||
Assert.That(
|
||||
SessionReconnectHandler.SelectFallbackEndpoint(
|
||||
original,
|
||||
[none],
|
||||
m_telemetry.CreateLogger<SessionReconnectHandlerTests>()),
|
||||
Is.Null);
|
||||
Assert.That(
|
||||
SessionReconnectHandler.SelectFallbackEndpoint(
|
||||
original,
|
||||
[none, signed],
|
||||
m_telemetry.CreateLogger<SessionReconnectHandlerTests>()),
|
||||
Is.SameAs(signed));
|
||||
configuredEndpoint.Description.SecurityPolicyUri,
|
||||
Is.EqualTo(SecurityPolicies.Basic256Sha256));
|
||||
mockFactory.Verify(
|
||||
factory => factory.RecreateAsync(
|
||||
mockSession.Object,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
private static ConfiguredEndpoint CreateConfiguredEndpoint(
|
||||
@@ -516,5 +403,38 @@ namespace Opc.Ua.Client.Tests
|
||||
return _updateDelegate(endpoint, connection!);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControlledDiscoverySessionReconnectHandler : SessionReconnectHandler
|
||||
{
|
||||
private readonly EndpointDescription m_discoveredEndpoint;
|
||||
|
||||
public ControlledDiscoverySessionReconnectHandler(
|
||||
ITelemetryContext telemetry,
|
||||
EndpointDescription discoveredEndpoint)
|
||||
: base(telemetry)
|
||||
{
|
||||
m_discoveredEndpoint = discoveredEndpoint;
|
||||
}
|
||||
|
||||
internal override Task UpdateFromServerAsync(
|
||||
ConfiguredEndpoint endpoint,
|
||||
ITransportWaitingConnection? connection,
|
||||
MessageSecurityMode securityMode,
|
||||
string securityPolicyUri)
|
||||
{
|
||||
typeof(ConfiguredEndpoint)
|
||||
.GetProperty(nameof(ConfiguredEndpoint.DiscoveryEndpoints))!
|
||||
.SetValue(endpoint, new ArrayOf<EndpointDescription>(
|
||||
new[] { m_discoveredEndpoint }.AsMemory()));
|
||||
|
||||
if (securityPolicyUri != m_discoveredEndpoint.SecurityPolicyUri)
|
||||
{
|
||||
throw new ServiceResultException(StatusCodes.BadSecurityPolicyRejected);
|
||||
}
|
||||
|
||||
endpoint.Description.SecurityPolicyUri = securityPolicyUri;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user