No production code changes — pure additive test. Server.Tests Integration: 3 new tests pass; existing OpcUaServerIntegrationTests stays green (single-driver case still exercised there). Full Server.Tests Unit still 43 / 0. Deferred: multi-driver alarm-event case (two drivers each raising a GalaxyAlarmEvent, assert each condition lands on its owning instance's condition node) — needs a stub IAlarmSource and is worth its own focused PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
9.5 KiB
C#
192 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.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;
|
|
|
|
/// <summary>
|
|
/// Closes LMX follow-up #6 — proves that two <see cref="IDriver"/> instances registered
|
|
/// on the same <see cref="DriverHost"/> land in isolated namespaces and their reads
|
|
/// route to the correct driver. The existing <see cref="OpcUaServerIntegrationTests"/>
|
|
/// only exercises a single-driver topology; this sibling fixture registers two.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each driver gets its own namespace URI of the form <c>urn:OtOpcUa:{DriverInstanceId}</c>
|
|
/// (per <c>DriverNodeManager</c>'s base-class <c>namespaceUris</c> 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.
|
|
/// </remarks>
|
|
[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<OpcUaApplicationHost>.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<ISession> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
IReadOnlyList<DataValueSnapshot> result =
|
|
fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray();
|
|
return Task.FromResult(result);
|
|
}
|
|
}
|
|
}
|