fcdc44f9f7
- AttributesAsync was a stub returning nothing; it now reads NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and maps them through UpstreamAttributeMapping, the same mapping discovery registers with (moved into the shared .Session leaf; the driver forwards to it). Objects have no value attributes. - Certificate auth was refused "in v1". The browse now builds its identity through OpcUaUserIdentityFactory (shared .Session leaf, the driver forwards to it), including the secret: resolution of Password / UserCertificatePassword through the host's ISecretResolver (optional ctor param, DI-injected). Fail-closed: a secret: ref with no store throws instead of being sent as the literal (the browse used to send a secret: Password verbatim). The identity is built before any network I/O. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
104 lines
4.6 KiB
C#
104 lines
4.6 KiB
C#
using Moq;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 R06:O-5 — the live picker's side-panel <c>AttributesAsync</c> was a stub returning nothing,
|
|
/// so the operator saw no DataType / array / access information for an upstream variable. It now reads the
|
|
/// variable's attributes and maps them through the same <see cref="UpstreamAttributeMapping"/> the runtime
|
|
/// driver's discovery uses.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientBrowseSessionAttributesTests
|
|
{
|
|
private const string Speed = "ns=0;s=Speed";
|
|
|
|
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
|
{
|
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
|
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
|
mock.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
|
new ReadResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = [.. nodes.Select(n => n.AttributeId switch
|
|
{
|
|
Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)),
|
|
Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
|
|
Attributes.DataType => new DataValue(new Variant(dataType)),
|
|
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
|
|
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
|
|
_ => new DataValue(StatusCodes.BadAttributeIdInvalid),
|
|
})],
|
|
DiagnosticInfos = [],
|
|
});
|
|
return mock;
|
|
}
|
|
|
|
private static OpcUaClientBrowseSession NewSession(Mock<ISession> mock)
|
|
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
|
|
|
|
/// <summary>A writable Double array variable reports Float64 / array / Operate.</summary>
|
|
[Fact]
|
|
public async Task A_variable_reports_its_mapped_data_type_array_shape_and_access()
|
|
{
|
|
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Double, valueRank: 1, userAccess: 0x03);
|
|
await using var session = NewSession(mock);
|
|
|
|
var attrs = await session.AttributesAsync(Speed, TestContext.Current.CancellationToken);
|
|
|
|
var a = attrs.ShouldHaveSingleItem();
|
|
a.Name.ShouldBe("Speed");
|
|
a.DriverDataType.ShouldBe("Float64");
|
|
a.IsArray.ShouldBeTrue();
|
|
a.SecurityClass.ShouldBe("Operate");
|
|
a.IsAlarm.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>A read-only scalar Int32 variable reports Int32 / scalar / ViewOnly.</summary>
|
|
[Fact]
|
|
public async Task A_read_only_scalar_reports_view_only()
|
|
{
|
|
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
|
await using var session = NewSession(mock);
|
|
|
|
var a = (await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
a.DriverDataType.ShouldBe("Int32");
|
|
a.IsArray.ShouldBeFalse();
|
|
a.SecurityClass.ShouldBe("ViewOnly");
|
|
}
|
|
|
|
/// <summary>
|
|
/// An Object (folder) has no value attributes to show. The positive control is the variable case above,
|
|
/// through the same mock and read.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_object_node_has_no_attributes()
|
|
{
|
|
var mock = SessionReturning(NodeClass.Object, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
|
await using var session = NewSession(mock);
|
|
|
|
(await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>An unresolvable node id is refused like <c>ExpandAsync</c> does.</summary>
|
|
[Fact]
|
|
public async Task An_unresolvable_node_id_throws()
|
|
{
|
|
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
|
await using var session = NewSession(mock);
|
|
|
|
await Should.ThrowAsync<ArgumentException>(() =>
|
|
session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken));
|
|
}
|
|
}
|