Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaEquipmentWalkerIntegrationTests.cs
Joseph Doherty 2d97f241c0 ADR-001 wire-in — EquipmentNodeWalker runs inside OpcUaApplicationHost before driver DiscoverAsync, closing tasks #212 + #213. Completes the in-server half of the ADR-001 Option A story: Task A (PR #153) shipped the pure-function walker in Core.OpcUa; Task B (PR #154) shipped the NodeScopeResolver + ScopePathIndexBuilder + evaluator-level authz proof. This PR lands the BuildAddressSpaceAsync wire-in the walker was always meant to plug into + a full-stack OPC UA client-browse integration test that proves the UNS folder skeleton is actually visible to real UA clients end-to-end, not just to the RecordingBuilder test double. OpcUaApplicationHost gains an optional ctor parameter equipmentContentLookup of type Func<string, EquipmentNamespaceContent?>? — when supplied + non-null for a driver instance, EquipmentNodeWalker.Walk is invoked against that driver's node manager BEFORE GenericDriverNodeManager.BuildAddressSpaceAsync streams the driver's native DiscoverAsync output on top. Walker-first ordering matters: the UNS Area/Line/Equipment folder skeleton + Identification sub-folders + the five identifier properties (decision #121) are in place so driver-native references (driver-specific tag paths) land ALONGSIDE the UNS tree rather than racing it. Callers that don't supply a lookup (every existing pre-ADR-001 test + the v1 upgrade path) get identical behavior — the null-check is the backward-compat seam per the opt-in design sketched in ADR-001. The lookup delegate is driver-instance-scoped, not server-scoped, so a single server with multiple drivers can serve e.g. one Equipment-kind namespace (Galaxy proxy with a full UNS) alongside several native-kind namespaces (Modbus / AB CIP / TwinCAT / FOCAS that do not have their own UNS because decisions #116-#121 scope UNS to Equipment-kind only). SealedBootstrap.Start will wire this lookup against the Config-DB snapshot loader in a follow-up — the lookup plumbing lands first so that wiring reduces to one-line composition rather than a ctor-signature churn. New OpcUaEquipmentWalkerIntegrationTests spins up a real OtOpcUaServer on a non-default port with an EmptyDriver that registers with zero native content + a lookup that returns a seeded EquipmentNamespaceContent (one area warsaw / one line line-a / one equipment oven-3 / one tag Temperature). An OPC UA client session connects anonymously against the un-secured endpoint, browses the standard hierarchy, + asserts: (a) area folder warsaw contains line-a folder as a child; (b) line folder line-a contains oven-3 folder as a child; (c) equipment folder oven-3 contains EquipmentId + EquipmentUuid + MachineCode identifier properties — ZTag + SAPID correctly absent because the fixture leaves them null per decision #121 skip-when-null behavior; (d) the bound Tag emits a Variable node under the equipment folder with NodeId == Tag.TagConfig (the wire-level driver address) + the client can ReadValue against it end-to-end through the DriverNodeManager dispatch path. Because the EmptyDriver's DiscoverAsync is a no-op the test proves UNS content came from the walker, not the driver — the original ADR-001 question "what actually owns the browse tree" now has a mechanical answer visible at the OPC UA wire level. Test class uses its own port (48500+rand) + per-test PKI root so it runs in parallel with the existing OpcUaServerIntegrationTests fixture (48400+rand) without binding or cert collisions. Server project builds 0 errors; Server.Tests 181/181 (was 179, +2 new full-stack walker tests). Task #212 + #213 closed; the follow-up SealedBootstrap wiring is the natural next pickup because the ctor plumbing lands here + that becomes a narrow downstream PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 03:09:37 -04:00

206 lines
9.5 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when
/// <see cref="OpcUaApplicationHost"/> is given an <c>equipmentContentLookup</c> that
/// returns a non-null <see cref="EquipmentNamespaceContent"/>, the walker runs BEFORE
/// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) +
/// identifier properties are materialized into the driver's namespace + visible to an
/// OPC UA client via standard browse.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime
{
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest";
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}");
private const string DriverId = "galaxy-prod";
private DriverHost _driverHost = null!;
private OpcUaApplicationHost _server = null!;
public async ValueTask InitializeAsync()
{
_driverHost = new DriverHost();
await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None);
var content = BuildFixture();
var options = new OpcUaServerOptions
{
EndpointUrl = _endpoint,
ApplicationName = "OtOpcUaWalkerTest",
ApplicationUri = "urn:OtOpcUa:Server:WalkerTest",
PkiStoreRoot = _pkiRoot,
AutoAcceptUntrustedClientCertificates = true,
HealthEndpointsEnabled = false,
};
_server = new OpcUaApplicationHost(
options, _driverHost, new DenyAllUserAuthenticator(),
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance,
equipmentContentLookup: id => id == DriverId ? content : null);
await _server.StartAsync(CancellationToken.None);
}
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync();
await _driverHost.DisposeAsync();
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
}
[Fact]
public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse()
{
using var session = await OpenSessionAsync();
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex);
var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex);
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a");
BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3");
var equipmentChildren = BrowseChildren(session, equipmentFolder);
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId");
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid");
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode");
}
[Fact]
public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client()
{
using var session = await OpenSessionAsync();
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
var tagNode = new NodeId("plcaddr-temperature", nsIndex);
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature");
var dv = session.ReadValue(tagNode);
dv.ShouldNotBeNull();
}
private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node)
{
session.Browse(null, null, node, 0, BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences, true,
(uint)NodeClass.Object | (uint)NodeClass.Variable,
out _, out var refs);
return refs;
}
private static EquipmentNamespaceContent BuildFixture()
{
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 };
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
var oven = new Equipment
{
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
MachineCode = "MC-oven-3",
};
var tempTag = new Tag
{
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1",
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
Name = "Temperature", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
};
return new EquipmentNamespaceContent(
Areas: new[] { area },
Lines: new[] { line },
Equipment: new[] { oven },
Tags: new[] { tempTag });
}
private async Task<ISession> OpenSessionAsync()
{
var cfg = new ApplicationConfiguration
{
ApplicationName = "OtOpcUaWalkerTestClient",
ApplicationUri = "urn:OtOpcUa:WalkerTestClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_pkiRoot, "client-own"),
SubjectName = "CN=OtOpcUaWalkerTestClient",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
};
await cfg.Validate(ApplicationType.Client);
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
var endpointConfig = EndpointConfiguration.Create(cfg);
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000,
new UserIdentity(new AnonymousIdentityToken()), null);
}
/// <summary>
/// Driver that registers into the host + implements DiscoverAsync as a no-op. The
/// walker is the sole source of address-space content; if the UNS folders appear
/// under browse, they came from the wire-in (not from the driver's own discovery).
/// </summary>
private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable
{
public EmptyDriver(string id) { DriverInstanceId = id; }
public string DriverInstanceId { get; }
public string DriverType => "EmptyForWalkerTest";
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> result =
fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray();
return Task.FromResult(result);
}
}
}