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>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -168,10 +168,25 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of times <see cref="CallMethodAsync"/> was invoked so tests can assert
|
||||
/// acknowledgment workflows reached the session adapter.
|
||||
/// </summary>
|
||||
public int CallMethodCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, <see cref="CallMethodAsync"/> throws this exception — used to simulate
|
||||
/// a bad method call status surfacing as a <see cref="ServiceResultException"/>.
|
||||
/// </summary>
|
||||
public Exception? CallMethodException { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
CallMethodCount++;
|
||||
if (CallMethodException != null)
|
||||
throw CallMethodException;
|
||||
return Task.FromResult<IList<object>?>(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ public class ConnectionSettingsTests
|
||||
settings.SecurityMode.ShouldBe(SecurityMode.None);
|
||||
settings.SessionTimeoutSeconds.ShouldBe(60);
|
||||
settings.AutoAcceptCertificates.ShouldBeTrue();
|
||||
settings.CertificateStorePath.ShouldContain("OtOpcUaClient");
|
||||
settings.CertificateStorePath.ShouldContain("pki");
|
||||
// CertificateStorePath defaults to empty so constructing settings does not
|
||||
// touch disk; DefaultApplicationConfigurationFactory resolves the canonical
|
||||
// PKI path lazily on first connect (Client.Shared-010).
|
||||
settings.CertificateStorePath.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -996,6 +996,143 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
eventCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// --- AcknowledgeAlarm tests (Client.Shared-009) ---
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a successful acknowledge call returns <see cref="StatusCodes.Good"/>
|
||||
/// and reaches the session adapter's CallMethodAsync (Client.Shared-009).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_OnSuccess_ReturnsGood()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked");
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Client.Shared-009: a bad call result must surface as the returned
|
||||
/// <see cref="StatusCode"/> rather than escape as an uncaught
|
||||
/// <see cref="ServiceResultException"/>, so callers using
|
||||
/// <c>if (StatusCode.IsBad(result))</c> actually see the failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_OnServiceResultException_ReturnsBadStatusCode()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
CallMethodException = new ServiceResultException(
|
||||
StatusCodes.BadConditionAlreadyEnabled, "already acked")
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked");
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the ".Condition" suffix is appended when the caller supplies the
|
||||
/// source node, but left alone when the caller already passes the condition node —
|
||||
/// matches the documented contract.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_LeavesConditionSuffixAlone()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
await _service.AcknowledgeAlarmAsync("ns=2;s=Cond.Condition", new byte[] { 1, 2 }, "acked");
|
||||
|
||||
// Both call shapes reach the adapter once.
|
||||
session.CallMethodCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// --- Alarm fallback path (Client.Shared-011) ---
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Client.Shared-011: when standard AckedState/Id and ActiveState/Id
|
||||
/// fields are missing (null Value) but the SourceNode (ConditionId) field at index 12
|
||||
/// is populated, the client launches the Task.Run fallback that reads
|
||||
/// <c>InAlarm</c>/<c>Acked</c> from the condition node's Galaxy attributes. Verify
|
||||
/// the alarm event is delivered with the values from the supplemental reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnAlarmEvent_MissingAckedActiveButHasConditionNode_FallbackReadsAndRaisesEvent()
|
||||
{
|
||||
var fakeSub = new FakeSubscriptionAdapter();
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
NextSubscription = fakeSub,
|
||||
ReadResponseFunc = nodeId =>
|
||||
{
|
||||
var key = nodeId.ToString();
|
||||
if (key.EndsWith(".InAlarm"))
|
||||
return new DataValue(new Variant(true), StatusCodes.Good);
|
||||
if (key.EndsWith(".Acked"))
|
||||
return new DataValue(new Variant(false), StatusCodes.Good);
|
||||
if (key.EndsWith(".TimeAlarmOn"))
|
||||
return new DataValue(new Variant(new DateTime(2026, 1, 1, 12, 0, 0)), StatusCodes.Good);
|
||||
if (key.EndsWith(".DescAttrName"))
|
||||
return new DataValue(new Variant("Fallback message"), StatusCodes.Good);
|
||||
return new DataValue(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
AlarmEventArgs? received = null;
|
||||
var raised = new TaskCompletionSource();
|
||||
_service.AlarmEvent += (_, e) =>
|
||||
{
|
||||
received = e;
|
||||
raised.TrySetResult();
|
||||
};
|
||||
|
||||
await _service.SubscribeAlarmsAsync();
|
||||
|
||||
var handle = fakeSub.ActiveHandles.First();
|
||||
// AckedState/Id (8) and ActiveState/Id (9) are present but Variant.Value is null,
|
||||
// which triggers the supplemental Galaxy-attribute fallback; SourceNode (12) is set.
|
||||
var fields = new EventFieldList
|
||||
{
|
||||
EventFields =
|
||||
[
|
||||
new Variant(new byte[] { 1, 2, 3 }), // 0: EventId
|
||||
new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
|
||||
new Variant("Source1"), // 2: SourceName
|
||||
new Variant(DateTime.MinValue), // 3: Time
|
||||
new Variant(new LocalizedText("Initial")), // 4: Message
|
||||
new Variant((ushort)400), // 5: Severity
|
||||
new Variant("CondName"), // 6: ConditionName
|
||||
new Variant(true), // 7: Retain
|
||||
Variant.Null, // 8: AckedState/Id — missing
|
||||
Variant.Null, // 9: ActiveState/Id — missing
|
||||
new Variant(true), // 10: EnabledState/Id
|
||||
new Variant(false), // 11: SuppressedOrShelved
|
||||
new Variant("ns=2;s=ConditionId") // 12: SourceNode
|
||||
]
|
||||
};
|
||||
fakeSub.SimulateEvent(handle, fields);
|
||||
|
||||
// The fallback runs on a background Task.Run continuation — wait briefly for it.
|
||||
await Task.WhenAny(raised.Task, Task.Delay(500));
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received!.ActiveState.ShouldBeTrue(); // from InAlarm read
|
||||
received.AckedState.ShouldBeFalse(); // from Acked read
|
||||
received.ConditionNodeId.ShouldBe("ns=2;s=ConditionId");
|
||||
received.Message.ShouldBe("Fallback message"); // from DescAttrName read
|
||||
}
|
||||
|
||||
// --- Failover tests ---
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user