b8208b3312
Tests: - OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire, offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse and read; UNS variable Organizes-references its raw node; single-source fan-out parity (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId -> GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds. - Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline. Docs (dual-namespace reality): - CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph. - docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf). - docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples. - docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath. - docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
327 lines
17 KiB
C#
327 lines
17 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// v3 Batch 4 (B4-WP5) — the over-the-wire proof of the dual-namespace address space. Boots the real
|
|
/// <see cref="OtOpcUaSdkServer"/> (exactly as <c>SubscriptionSurvivalTests</c> / the multi-notifier
|
|
/// alarm test do), materialises a raw device folder + a raw tag Variable (<c>ns=Raw, s=<RawPath></c>)
|
|
/// and an equipment folder + a UNS reference Variable (<c>ns=UNS, s=<Area/Line/Equip/Eff></c>) through
|
|
/// the PRODUCTION <see cref="SdkAddressSpaceSink"/>, wires the <c>Organizes</c> UNS→Raw edge, then drives a
|
|
/// real OPC UA client to assert:
|
|
/// <list type="number">
|
|
/// <item>BOTH namespace URIs (<see cref="V3NodeIds.RawNamespaceUri"/> +
|
|
/// <see cref="V3NodeIds.UnsNamespaceUri"/>) are registered + distinct (they replaced the single
|
|
/// <c>.../ns</c>).</item>
|
|
/// <item>Both subtrees browse + read: the raw Variable at its RawPath NodeId and the UNS Variable at its
|
|
/// equipment-path NodeId.</item>
|
|
/// <item>The UNS Variable <c>Organizes</c>-references its backing raw node.</item>
|
|
/// <item>Single-source fan-out parity: one driver publish (modelled as a
|
|
/// <see cref="SdkAddressSpaceSink.WriteValue"/> to each NodeId with identical value/quality/timestamp)
|
|
/// is read back IDENTICALLY on both NodeIds.</item>
|
|
/// <item>HistoryRead via EITHER NodeId returns <c>GoodNoData</c> under the shared historian tagname (the
|
|
/// <c>NullHistorianDataSource</c> default — the offline parity path).</item>
|
|
/// <item>The <c>WriteOperate</c> gate is symmetric across both NodeIds: an anonymous client write is
|
|
/// rejected identically on the raw and the UNS NodeId (fail-closed at the role gate — the POSITIVE
|
|
/// realm-qualified routing path is unit-covered in <c>DriverHostActorWriteRoutingTests</c> /
|
|
/// <c>NodeManagerWriteRevertTests</c>, which drive a role-carrying identity the node manager
|
|
/// directly).</item>
|
|
/// </list>
|
|
/// Heavy in-process server+client integration — runs in the serial integration pass, and is fully
|
|
/// offline-safe (no Docker / SQL / gateway; the historian read defaults to the Null source).
|
|
/// </summary>
|
|
public sealed class DualNamespaceAddressSpaceTests
|
|
{
|
|
private const string ServerUri = "urn:OtOpcUa.DualNamespaceAddressSpace";
|
|
|
|
// Raw device tree (ns=Raw): Folder → tag Variable, both keyed by RawPath.
|
|
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
|
private const string RawTagPath = "Plant/Modbus/dev1/Speed";
|
|
// UNS equipment tree (ns=UNS): equipment Folder → reference Variable keyed by the equipment path.
|
|
private const string EquipFolder = "filling/line1/station1";
|
|
private const string UnsVarPath = "filling/line1/station1/Speed";
|
|
// Both NodeIds register the SAME historian tagname (the raw tag's RawPath).
|
|
private const string HistorianTagname = RawTagPath;
|
|
|
|
[Fact]
|
|
public async Task Both_namespaces_registered_and_both_subtrees_browse_read_and_organize()
|
|
{
|
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-browse-{Guid.NewGuid():N}");
|
|
var port = AllocateFreePort();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
try
|
|
{
|
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
|
|
await using var _ = host;
|
|
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
MaterialiseDualTree(sink);
|
|
// One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload.
|
|
var ts = DateTime.UtcNow;
|
|
sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
|
|
sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
|
|
|
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
|
|
|
// (1) both namespaces registered + distinct.
|
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
|
rawNs.ShouldBeGreaterThan((ushort)0, "the Raw namespace must be registered");
|
|
unsNs.ShouldBeGreaterThan((ushort)0, "the UNS namespace must be registered");
|
|
rawNs.ShouldNotBe(unsNs, "the two namespaces must have distinct indices");
|
|
|
|
var rawNodeId = new NodeId(RawTagPath, rawNs);
|
|
var unsNodeId = new NodeId(UnsVarPath, unsNs);
|
|
|
|
// (2) both subtrees browse + read.
|
|
var rawValue = await session.ReadValueAsync(rawNodeId, ct);
|
|
var unsValue = await session.ReadValueAsync(unsNodeId, ct);
|
|
StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good");
|
|
StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good");
|
|
Convert.ToSingle(rawValue.Value).ShouldBe(42.5f);
|
|
Convert.ToSingle(unsValue.Value).ShouldBe(42.5f);
|
|
|
|
// (3) the UNS Variable Organizes-references its backing raw node.
|
|
var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes);
|
|
var organizesRaw = refs.Any(r =>
|
|
r.ReferenceTypeId == ReferenceTypeIds.Organizes &&
|
|
r.NodeId.NamespaceIndex == rawNs &&
|
|
r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal));
|
|
organizesRaw.ShouldBeTrue(
|
|
$"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " +
|
|
$"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]");
|
|
}
|
|
finally
|
|
{
|
|
SafeDelete(pkiRoot + "-srv");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Single_source_fans_to_both_nodeids_with_identical_value_quality_and_timestamp()
|
|
{
|
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-fanout-{Guid.NewGuid():N}");
|
|
var port = AllocateFreePort();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
try
|
|
{
|
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Fanout", ct);
|
|
await using var _ = host;
|
|
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
MaterialiseDualTree(sink);
|
|
|
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
|
|
|
// The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with
|
|
// identical value/quality/timestamp — the fan-out drift invariant.
|
|
var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc);
|
|
sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
|
|
sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
|
|
|
|
var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct);
|
|
var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct);
|
|
|
|
Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value));
|
|
unsValue.StatusCode.ShouldBe(rawValue.StatusCode);
|
|
unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp);
|
|
unsValue.SourceTimestamp.ShouldBe(ts);
|
|
}
|
|
finally
|
|
{
|
|
SafeDelete(pkiRoot + "-srv");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoryRead_via_either_nodeid_returns_GoodNoData_under_the_shared_tagname()
|
|
{
|
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-hist-{Guid.NewGuid():N}");
|
|
var port = AllocateFreePort();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
try
|
|
{
|
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".History", ct);
|
|
await using var _ = host;
|
|
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
MaterialiseDualTree(sink); // both variables historized under the SAME tagname (HistorianTagname)
|
|
|
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
|
|
|
var rawStatus = await HistoryReadRawStatusAsync(session, new NodeId(RawTagPath, rawNs), ct);
|
|
var unsStatus = await HistoryReadRawStatusAsync(session, new NodeId(UnsVarPath, unsNs), ct);
|
|
|
|
// NullHistorianDataSource default: a historized node's HistoryRead surfaces GoodNoData (never an
|
|
// error) — identical for the raw and the UNS NodeId under the one registered historian tagname.
|
|
rawStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "raw NodeId HistoryRead");
|
|
unsStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "uns NodeId HistoryRead");
|
|
}
|
|
finally
|
|
{
|
|
SafeDelete(pkiRoot + "-srv");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteOperate_gate_is_symmetric_across_both_nodeids()
|
|
{
|
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-write-{Guid.NewGuid():N}");
|
|
var port = AllocateFreePort();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
try
|
|
{
|
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Write", ct);
|
|
await using var _ = host;
|
|
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
MaterialiseDualTree(sink); // both variables writable
|
|
|
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
|
|
|
var rawWrite = await WriteValueStatusAsync(session, new NodeId(RawTagPath, rawNs), 7.0f, ct);
|
|
var unsWrite = await WriteValueStatusAsync(session, new NodeId(UnsVarPath, unsNs), 7.0f, ct);
|
|
|
|
// The anonymous session carries no WriteOperate role, so the node manager's fail-closed write gate
|
|
// rejects BOTH writes identically — the gate applies uniformly to the raw and the UNS NodeId (a
|
|
// UNS write is neither more nor less privileged than the raw write it fans from).
|
|
rawWrite.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "raw NodeId write (no WriteOperate role)");
|
|
unsWrite.ShouldBe(rawWrite, "uns NodeId write rejected identically to the raw NodeId");
|
|
}
|
|
finally
|
|
{
|
|
SafeDelete(pkiRoot + "-srv");
|
|
}
|
|
}
|
|
|
|
/// <summary>Materialise the raw device folder + raw tag Variable, the equipment folder + UNS reference
|
|
/// Variable (both historized under the SAME tagname, both writable), and the Organizes UNS→Raw edge.</summary>
|
|
private static void MaterialiseDualTree(SdkAddressSpaceSink sink)
|
|
{
|
|
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
sink.EnsureVariable(RawTagPath, RawDeviceFolder, "Speed", "Float", writable: true,
|
|
realm: AddressSpaceRealm.Raw, historianTagname: HistorianTagname);
|
|
|
|
sink.EnsureFolder(EquipFolder, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
|
sink.EnsureVariable(UnsVarPath, EquipFolder, "Speed", "Float", writable: true,
|
|
realm: AddressSpaceRealm.Uns, historianTagname: HistorianTagname);
|
|
|
|
// Organizes UNS→Raw: the UNS variable references its backing raw node (the fan-out source).
|
|
sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes");
|
|
}
|
|
|
|
private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)>
|
|
BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType)
|
|
{
|
|
// Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct.
|
|
var (_, cp, refs) = await session.BrowseAsync(
|
|
null, null, node, 0u, BrowseDirection.Forward, referenceType,
|
|
includeSubtypes: true, nodeClassMask: 0u);
|
|
return (cp, refs ?? new ReferenceDescriptionCollection());
|
|
}
|
|
|
|
private static async Task<StatusCode> HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct)
|
|
{
|
|
var details = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
NumValuesPerNode = 10,
|
|
IsReadModified = false,
|
|
ReturnBounds = false,
|
|
};
|
|
var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } };
|
|
var response = await session.HistoryReadAsync(
|
|
null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct);
|
|
response.Results.ShouldNotBeNull();
|
|
response.Results.Count.ShouldBe(1);
|
|
return response.Results[0].StatusCode;
|
|
}
|
|
|
|
private static async Task<StatusCode> WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct)
|
|
{
|
|
var writeCollection = new WriteValueCollection
|
|
{
|
|
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) },
|
|
};
|
|
var response = await session.WriteAsync(null, writeCollection, ct);
|
|
response.Results.ShouldNotBeNull();
|
|
response.Results.Count.ShouldBe(1);
|
|
return response.Results[0];
|
|
}
|
|
|
|
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
|
|
int port, string pkiRoot, string appUri, CancellationToken ct)
|
|
{
|
|
var options = new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = appUri,
|
|
ApplicationUri = appUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = pkiRoot,
|
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
};
|
|
var server = new OtOpcUaSdkServer();
|
|
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
|
await host.StartAsync(server, ct);
|
|
return (server, host);
|
|
}
|
|
|
|
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
|
{
|
|
var appConfig = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUa.DualNamespaceClient",
|
|
ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
};
|
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
|
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
|
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
|
|
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
|
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
|
sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000,
|
|
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
|
}
|
|
|
|
private static int AllocateFreePort()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
|
|
private static void SafeDelete(string dir)
|
|
{
|
|
if (Directory.Exists(dir))
|
|
{
|
|
try { Directory.Delete(dir, recursive: true); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
}
|
|
}
|