using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// OpcUaServer-004 — a fresh whose CreateAddressSpace has NOT /// yet run (i.e. the server has not started) has a null _root. Every public address-space mutator /// (, , /// , , /// ) must now fail with a legible /// instead of a bare NRE out of ResolveParentFolder / /// CreateVariable. /// /// The node manager's ctor needs a real + /// , which only the SDK boot produces — so we boot a real /// , borrow those two from the LIVE (already-started) node manager /// (its public Server + Server.Configuration), then construct a SECOND, fresh node /// manager from them. That second manager never had CreateAddressSpace driven, so it /// reproduces the pre-start ordering hazard exactly. /// /// public sealed class NodeManagerPreStartGuardTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-prestartguard-{Guid.NewGuid():N}"); [Fact] public async Task EnsureFolder_before_CreateAddressSpace_throws_InvalidOperationException() { var (host, nm) = await BuildPreStartNodeManagerAsync(); try { var ex = Should.Throw(() => nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment")); ex.Message.ShouldContain("address space has not been created"); } finally { await host.DisposeAsync(); } } [Fact] public async Task EnsureVariable_before_CreateAddressSpace_throws_InvalidOperationException() { var (host, nm) = await BuildPreStartNodeManagerAsync(); try { Should.Throw(() => nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", writable: false)); } finally { await host.DisposeAsync(); } } [Fact] public async Task WriteValue_before_CreateAddressSpace_throws_InvalidOperationException() { var (host, nm) = await BuildPreStartNodeManagerAsync(); try { Should.Throw(() => nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow)); } finally { await host.DisposeAsync(); } } [Fact] public async Task MaterialiseAlarmCondition_before_CreateAddressSpace_throws_InvalidOperationException() { var (host, nm) = await BuildPreStartNodeManagerAsync(); try { Should.Throw(() => nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500)); } finally { await host.DisposeAsync(); } } /// Boot a real host, borrow the live node manager's real /// , then construct a SECOND node manager from it (with a /// fresh — the ctor only records it + sets namespaces) that has /// NEVER had CreateAddressSpace driven (so _root is null). The host is returned so the /// caller disposes it after exercising the guard. private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager NodeManager)> BuildPreStartNodeManagerAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.PreStartGuardTest", ApplicationUri = $"urn:OtOpcUa.PreStartGuardTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); var live = server.NodeManager!; // Borrow the SDK's real IServerInternal from the live manager and build a brand-new node manager — // CreateAddressSpace has not been driven on THIS instance, so _root is null and every mutator must // hit the EnsureAddressSpaceCreated guard. var fresh = new OtOpcUaNodeManager(live.Server, new ApplicationConfiguration()); return (host, fresh); } private static int AllocateFreePort() { using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }