Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionAttributesTests.cs
T
Joseph Doherty fcdc44f9f7 feat(opcuaclient-browser): real AttributesAsync + certificate-auth browse (R06:O-5)
- 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
2026-10-22 15:33:08 -04:00

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));
}
}