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, HealthEndpointsEnabled = false, }; _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); } } }