From 2f00c74bbb470145c8cde690bf10cc7210e590cb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 15:29:49 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2032=20=E2=80=94=20Multi-driver?= =?UTF-8?q?=20integration=20test.=20Closes=20LMX=20follow-up=20#6=20with?= =?UTF-8?q?=20Server.Tests/MultipleDriverInstancesIntegrationTests.cs:=20r?= =?UTF-8?q?egisters=20two=20StubDriver=20instances=20(alpha=20+=20beta)=20?= =?UTF-8?q?with=20distinct=20DriverInstanceIds=20on=20one=20DriverHost,=20?= =?UTF-8?q?boots=20the=20full=20OpcUaApplicationHost,=20and=20exercises=20?= =?UTF-8?q?three=20behaviors=20end-to-end=20via=20a=20real=20OPC=20UA=20cl?= =?UTF-8?q?ient=20session.=20(1)=20Each=20driver's=20namespace=20URI=20res?= =?UTF-8?q?olves=20to=20a=20distinct=20index=20in=20the=20client's=20Names?= =?UTF-8?q?paceUris=20(alpha=20=E2=86=92=20urn:OtOpcUa:alpha,=20beta=20?= =?UTF-8?q?=E2=86=92=20urn:OtOpcUa:beta)=20=E2=80=94=20proves=20DriverNode?= =?UTF-8?q?Manager's=20namespaceUris-per-driver=20base-ctor=20wiring=20act?= =?UTF-8?q?ually=20lands=20two=20separate=20INodeManager=20registrations.?= =?UTF-8?q?=20(2)=20Browsing=20one=20subtree=20returns=20only=20that=20dri?= =?UTF-8?q?ver's=20folder;=20the=20other=20driver's=20folder=20does=20NOT?= =?UTF-8?q?=20leak=20into=20the=20wrong=20subtree.=20This=20is=20the=20tes?= =?UTF-8?q?t=20that=20catches=20a=20cross-driver=20routing=20regression=20?= =?UTF-8?q?the=20v1=20single-driver=20code=20path=20couldn't=20surface=20?= =?UTF-8?q?=E2=80=94=20if=20a=20future=20refactor=20flattens=20both=20driv?= =?UTF-8?q?ers=20into=20a=20shared=20namespace,=20the=20'shouldNotContain'?= =?UTF-8?q?=20assertion=20fails=20cleanly.=20(3)=20Reads=20route=20to=20th?= =?UTF-8?q?e=20owning=20driver=20by=20namespace=20=E2=80=94=20alpha's=20Re?= =?UTF-8?q?adAsync=20returns=2042=20while=20beta's=20returns=2099;=20a=20m?= =?UTF-8?q?isroute=20would=20surface=20as=2099=20showing=20up=20on=20an=20?= =?UTF-8?q?alpha=20node=20id=20or=20vice=20versa.=20StubDriver=20is=20para?= =?UTF-8?q?meterized=20on=20(DriverInstanceId,=20folderName,=20readValue)?= =?UTF-8?q?=20so=20the=20same=20class=20constructs=20both=20instances=20wi?= =?UTF-8?q?thout=20copy-paste.=20No=20production=20code=20changes=20?= =?UTF-8?q?=E2=80=94=20pure=20additive=20test.=20Server.Tests=20Integratio?= =?UTF-8?q?n:=203=20new=20tests=20pass;=20existing=20OpcUaServerIntegratio?= =?UTF-8?q?nTests=20stays=20green=20(single-driver=20case=20still=20exerci?= =?UTF-8?q?sed=20there).=20Full=20Server.Tests=20Unit=20still=2043=20/=200?= =?UTF-8?q?.=20Deferred:=20multi-driver=20alarm-event=20case=20(two=20driv?= =?UTF-8?q?ers=20each=20raising=20a=20GalaxyAlarmEvent,=20assert=20each=20?= =?UTF-8?q?condition=20lands=20on=20its=20owning=20instance's=20condition?= =?UTF-8?q?=20node)=20=E2=80=94=20needs=20a=20stub=20IAlarmSource=20and=20?= =?UTF-8?q?is=20worth=20its=20own=20focused=20PR.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/lmx-followups.md | 24 ++- ...MultipleDriverInstancesIntegrationTests.cs | 191 ++++++++++++++++++ 2 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/MultipleDriverInstancesIntegrationTests.cs diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md index 6d6aa82..11ca514 100644 --- a/docs/v2/lmx-followups.md +++ b/docs/v2/lmx-followups.md @@ -91,18 +91,22 @@ no single end-to-end smoke test. subscribes to one of its attributes, writes a value back, and asserts the write round-tripped through MXAccess. Skip when ArchestrA isn't running. -## 6. Second driver instance on the same server +## 6. Second driver instance on the same server — **DONE (PR 32)** -**Status**: `DriverHost.RegisterAsync` supports multiple drivers; the OPC UA -server creates one `DriverNodeManager` per driver and isolates their -subtrees under distinct namespace URIs. Not proven with two active -`GalaxyProxyDriver` instances pointing at different Galaxies. +`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two +drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the +full OPC UA server, and asserts three behaviors: (1) each driver's namespace +URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's +NamespaceUris, (2) browsing one subtree returns that driver's folder and +does NOT leak the other driver's folder, (3) reads route to the correct +driver — the alpha instance returns 42 while beta returns 99, so a misroute +would surface at the assertion layer. -**To do**: -- Integration test that registers two driver instances, each with a distinct - `DriverInstanceId` + endpoint in its own session, asserts nodes from both - appear under the correct subtrees, alarm events land on the correct - instance's condition nodes. +Deferred: the alarm-event multi-driver parity case (two drivers each raising +a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's +condition node). Alarm tracking already has its own integration test +(`AlarmSubscription*`); the multi-driver alarm case would need a stub +`IAlarmSource` that's worth its own focused PR. ## 7. Host-status per-AppEngine granularity → Admin UI dashboard diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/MultipleDriverInstancesIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/MultipleDriverInstancesIntegrationTests.cs new file mode 100644 index 0000000..cd93e14 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/MultipleDriverInstancesIntegrationTests.cs @@ -0,0 +1,191 @@ +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.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// Closes LMX follow-up #6 — proves that two instances registered +/// on the same land in isolated namespaces and their reads +/// route to the correct driver. The existing +/// only exercises a single-driver topology; this sibling fixture registers two. +/// +/// +/// Each driver gets its own namespace URI of the form urn:OtOpcUa:{DriverInstanceId} +/// (per DriverNodeManager's base-class namespaceUris argument). A client +/// that browses one namespace must see only that driver's subtree, and a read against a +/// variable in one namespace must return that driver's value, not the other's — this is +/// what stops a cross-driver routing regression from going unnoticed when the v1 +/// single-driver code path gets new knobs. +/// +[Trait("Category", "Integration")] +public sealed class MultipleDriverInstancesIntegrationTests : IAsyncLifetime +{ + private static readonly int Port = 48500 + Random.Shared.Next(0, 99); + private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaMultiDriverTest"; + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multi-{Guid.NewGuid():N}"); + + private DriverHost _driverHost = null!; + private OpcUaApplicationHost _server = null!; + + public async ValueTask InitializeAsync() + { + _driverHost = new DriverHost(); + await _driverHost.RegisterAsync(new StubDriver("alpha", folderName: "AlphaFolder", readValue: 42), + "{}", CancellationToken.None); + await _driverHost.RegisterAsync(new StubDriver("beta", folderName: "BetaFolder", readValue: 99), + "{}", CancellationToken.None); + + var options = new OpcUaServerOptions + { + EndpointUrl = _endpoint, + ApplicationName = "OtOpcUaMultiDriverTest", + ApplicationUri = "urn:OtOpcUa:Server:MultiDriverTest", + PkiStoreRoot = _pkiRoot, + AutoAcceptUntrustedClientCertificates = true, + }; + + _server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(), + NullLoggerFactory.Instance, NullLogger.Instance); + 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 Both_drivers_register_under_their_own_urn_namespace() + { + using var session = await OpenSessionAsync(); + + var alphaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha"); + var betaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:beta"); + + alphaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'alpha' must register its namespace URI"); + betaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'beta' must register its namespace URI"); + alphaNs.ShouldNotBe(betaNs, "each driver owns its own namespace"); + } + + [Fact] + public async Task Each_driver_subtree_exposes_only_its_own_folder() + { + using var session = await OpenSessionAsync(); + + var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha"); + var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta"); + + var alphaRoot = new NodeId("alpha", alphaNs); + session.Browse(null, null, alphaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences, + true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var alphaRefs); + alphaRefs.ShouldContain(r => r.BrowseName.Name == "AlphaFolder", + "alpha's subtree must contain alpha's folder"); + alphaRefs.ShouldNotContain(r => r.BrowseName.Name == "BetaFolder", + "alpha's subtree must NOT see beta's folder — cross-driver leak would hide subscription-routing bugs"); + + var betaRoot = new NodeId("beta", betaNs); + session.Browse(null, null, betaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences, + true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var betaRefs); + betaRefs.ShouldContain(r => r.BrowseName.Name == "BetaFolder"); + betaRefs.ShouldNotContain(r => r.BrowseName.Name == "AlphaFolder"); + } + + [Fact] + public async Task Reads_route_to_the_correct_driver_by_namespace() + { + using var session = await OpenSessionAsync(); + + var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha"); + var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta"); + + var alphaValue = session.ReadValue(new NodeId("AlphaFolder.Var1", alphaNs)); + var betaValue = session.ReadValue(new NodeId("BetaFolder.Var1", betaNs)); + + alphaValue.Value.ShouldBe(42, "alpha driver's ReadAsync returns 42 — a misroute would surface as 99"); + betaValue.Value.ShouldBe(99, "beta driver's ReadAsync returns 99 — a misroute would surface as 42"); + } + + private async Task OpenSessionAsync() + { + var cfg = new ApplicationConfiguration + { + ApplicationName = "OtOpcUaMultiDriverTestClient", + ApplicationUri = "urn:OtOpcUa:MultiDriverTestClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(_pkiRoot, "client-own"), + SubjectName = "CN=OtOpcUaMultiDriverTestClient", + }, + 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, "OtOpcUaMultiDriverTestClientSession", 60000, + new UserIdentity(new AnonymousIdentityToken()), null); + } + + /// + /// Driver stub that returns a caller-specified folder + variable + read value so two + /// instances in the same server can be told apart at the assertion layer. + /// + private sealed class StubDriver(string driverInstanceId, string folderName, int readValue) + : IDriver, ITagDiscovery, IReadable + { + public string DriverInstanceId => driverInstanceId; + public string DriverType => "Stub"; + + 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) + { + var folder = builder.Folder(folderName, folderName); + folder.Variable("Var1", "Var1", new DriverAttributeInfo( + $"{folderName}.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false)); + return Task.CompletedTask; + } + + public Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + IReadOnlyList result = + fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray(); + return Task.FromResult(result); + } + } +}