Files
lmxopcua/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs
Joseph Doherty 2a6ac07111 fix(client-shared): resolve Low code-review findings (Client.Shared-003,004,009,010,011)
- Client.Shared-003: DefaultSessionAdapter.WriteValueAsync / CallMethodAsync
  guard against null/empty Results and throw ServiceResultException with
  the response's ServiceResult code instead of indexing into a missing
  list.
- Client.Shared-004: DefaultSessionAdapter.CloseAsync / HistoryReadRawAsync
  / HistoryReadAggregateAsync use the Session.*Async overloads and honour
  the caller's CancellationToken.
- Client.Shared-009: AcknowledgeAlarmAsync returns the underlying
  ServiceResultException.StatusCode on failure instead of always Good;
  IOpcUaClientService doc updated to describe the new contract.
- Client.Shared-010: ConnectionSettings.CertificateStorePath defaults to
  empty; DefaultApplicationConfigurationFactory resolves the canonical
  PKI path lazily, so per-failover ConnectionSettings copies don't hit
  the filesystem.
- Client.Shared-011: added the alarm-fallback regression test, extracted
  EndpointSelector as a pure static, and added EndpointSelectorTests
  covering security-mode match, Basic256Sha256 preference, fallback,
  diagnostics, hostname rewrite, and null/empty guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:13:21 -04:00

164 lines
6.1 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
/// <summary>
/// Regression tests for the pure best-endpoint selection logic extracted from
/// <see cref="DefaultEndpointDiscovery"/> (Client.Shared-011). The selector picks the best
/// endpoint matching the requested security mode (preferring Basic256Sha256) and rewrites
/// the discovered endpoint URL hostname to match the operator-supplied URL so internal DNS
/// hostnames in discovery responses do not leak into the session.
/// </summary>
public class EndpointSelectorTests
{
private static EndpointDescription Ep(MessageSecurityMode mode, string policy, string url)
{
return new EndpointDescription
{
EndpointUrl = url,
SecurityMode = mode,
SecurityPolicyUri = policy,
};
}
/// <summary>
/// Verifies that the selector returns the only endpoint matching the requested
/// security mode even when other endpoints with different modes are present.
/// </summary>
[Fact]
public void SelectBest_PicksMatchingSecurityMode()
{
var endpoints = new[]
{
Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://server:4840"),
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256, "opc.tcp://server:4840"),
Ep(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256, "opc.tcp://server:4840"),
};
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://server:4840", MessageSecurityMode.Sign);
best.SecurityMode.ShouldBe(MessageSecurityMode.Sign);
best.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
/// <summary>
/// Verifies that when multiple endpoints match the requested mode, Basic256Sha256 wins
/// over weaker policies — even when Basic256Sha256 is not the first encountered.
/// </summary>
[Fact]
public void SelectBest_PrefersBasic256Sha256WhenMultipleMatch()
{
var endpoints = new[]
{
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic128Rsa15, "opc.tcp://server:4840"),
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256, "opc.tcp://server:4840"),
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic256, "opc.tcp://server:4840"),
};
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://server:4840", MessageSecurityMode.Sign);
best.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
/// <summary>
/// Verifies that the selector falls back to the first matching endpoint when no
/// Basic256Sha256 endpoint is advertised for the requested security mode.
/// </summary>
[Fact]
public void SelectBest_FallsBackToFirstMatchWhenNoBasic256Sha256()
{
var endpoints = new[]
{
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic128Rsa15, "opc.tcp://server:4840"),
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic256, "opc.tcp://server:4840"),
};
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://server:4840", MessageSecurityMode.Sign);
best.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic128Rsa15);
}
/// <summary>
/// Verifies that no matching endpoint produces an InvalidOperationException whose
/// message lists the available security mode/policy combinations to aid diagnosis.
/// </summary>
[Fact]
public void SelectBest_NoMatchingMode_ThrowsWithDiagnostic()
{
var endpoints = new[]
{
Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://server:4840"),
};
var ex = Should.Throw<InvalidOperationException>(() =>
EndpointSelector.SelectBest(endpoints, "opc.tcp://server:4840", MessageSecurityMode.SignAndEncrypt));
ex.Message.ShouldContain("SignAndEncrypt");
ex.Message.ShouldContain("None"); // available endpoint listed in the message
}
/// <summary>
/// Verifies that the selector rewrites the discovery-returned hostname to the
/// operator-supplied hostname so internal DNS names in the response do not leak
/// into the resulting session.
/// </summary>
[Fact]
public void SelectBest_RewritesHostToMatchRequestedUrl()
{
var endpoints = new[]
{
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256,
"opc.tcp://internal-host:4840/UA/Server"),
};
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840",
MessageSecurityMode.Sign);
new Uri(best.EndpointUrl).Host.ShouldBe("external-host");
}
/// <summary>
/// Verifies that when the discovery host already matches the requested host the
/// endpoint URL is left untouched.
/// </summary>
[Fact]
public void SelectBest_HostsMatch_LeavesUrlUnchanged()
{
var endpoints = new[]
{
Ep(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256,
"opc.tcp://server:4840/UA/Server"),
};
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://server:4840",
MessageSecurityMode.Sign);
best.EndpointUrl.ShouldBe("opc.tcp://server:4840/UA/Server");
}
/// <summary>
/// Verifies that a null endpoints argument throws ArgumentNullException rather than
/// producing a confusing downstream NullReferenceException.
/// </summary>
[Fact]
public void SelectBest_NullEndpoints_Throws()
{
Should.Throw<ArgumentNullException>(() =>
EndpointSelector.SelectBest(null!, "opc.tcp://server:4840", MessageSecurityMode.None));
}
/// <summary>
/// Verifies that an empty endpointUrl produces ArgumentException so the caller gets
/// a clear contract violation rather than a downstream UriFormatException.
/// </summary>
[Fact]
public void SelectBest_EmptyEndpointUrl_Throws()
{
Should.Throw<ArgumentException>(() =>
EndpointSelector.SelectBest(Array.Empty<EndpointDescription>(), "", MessageSecurityMode.None));
}
}