- 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>
164 lines
6.1 KiB
C#
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));
|
|
}
|
|
}
|