using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Review 01 L1 / L4 / H2 — the node manager never materialises a node from a VALUE or ALARM write, and /// never lets one (realm, id) be held by two different node kinds. /// /// The lazy create-on-first-write path predates and had /// no production producer left — but it had a production consumer by accident: a driver publish /// (or the UNS fan-out) racing a surgical remove re-created the removed id as a flat, read-only, /// BaseDataType variable under the shared root, and because EnsureVariable early-returns /// on an existing id, a later re-add of the same tag silently kept the phantom's shape. /// /// public sealed class NodeManagerPhantomNodeTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-phantom-{Guid.NewGuid():N}"); /// A value write to an id nothing declared creates nothing. [Fact] public async Task WriteValue_for_an_undeclared_id_creates_no_node() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var before = nm.VariableCount; nm.WriteValue("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); nm.VariableCount.ShouldBe(before); nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull(); nm.GetManagerHandle(new NodeId("Plant/Modbus/dev1/Ghost", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))) .ShouldBeNull("the SDK must not resolve a node that was never declared"); } finally { await host.DisposeAsync(); } } /// /// A batch naming a removed id skips it (no node, no exception) while the declared ids in the same batch /// land — the positive control that proves the skip is a skip and not a dropped batch. /// [Fact] public async Task WriteValues_skips_undeclared_ids_and_still_applies_the_declared_ones() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureVariable("Plant/Modbus/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); var ts = DateTime.UtcNow; nm.WriteValues( [ new ValueWrite("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw), new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw), ]); nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull(); nm.TryGetVariable("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw)!.Value.ShouldBe(42); } finally { await host.DisposeAsync(); } } /// /// The L1 scenario end to end: a publish racing a surgical remove leaves NO node, and the re-add that /// follows gets the AUTHORED DataType, parent and writability rather than inheriting a phantom's. /// [Fact] public async Task A_publish_racing_a_surgical_remove_leaves_no_node_and_the_readd_gets_the_authored_shape() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; const string id = "Plant/Modbus/dev1/Speed"; nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw); nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Float", writable: false, realm: AddressSpaceRealm.Raw); nm.RemoveVariableNode(id, AddressSpaceRealm.Raw).ShouldBeTrue(); // The race: the driver's fan-out publishes the id the surgical remove just dropped. nm.WriteValue(id, 1.5f, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); nm.TryGetVariable(id, AddressSpaceRealm.Raw).ShouldBeNull("the racing publish must not resurrect the node"); // The re-add (a later deploy re-authors the tag with a different shape). nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Int32", writable: true, realm: AddressSpaceRealm.Raw); var v = nm.TryGetVariable(id, AddressSpaceRealm.Raw); v.ShouldNotBeNull(); v!.DataType.ShouldBe(DataTypeIds.Int32); v.Parent.ShouldBeSameAs(nm.TryGetFolder("Plant/Modbus/dev1", AddressSpaceRealm.Raw)); ((int)v.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe(0, "the re-added node is writable as authored"); v.OnWriteValue.ShouldNotBeNull("a writable node carries the inbound-write handler"); // Positive control: the re-added node is live — a publish lands on it. nm.WriteValue(id, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); v.Value.ShouldBe(7); } finally { await host.DisposeAsync(); } } /// A condition write for an id that was never materialised is a logged no-op (L4): no placeholder variable. [Fact] public async Task WriteAlarmCondition_for_an_unknown_id_creates_nothing() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); var before = nm.VariableCount; var snapshot = new AlarmConditionSnapshot( Active: false, Acknowledged: true, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 0, Message: string.Empty); // The applier used to write this against the EQUIPMENT id on every removal — a folder, never a condition. nm.WriteAlarmCondition("eq-1", snapshot, DateTime.UtcNow, AddressSpaceRealm.Uns); nm.VariableCount.ShouldBe(before); nm.TryGetVariable("eq-1").ShouldBeNull(); // The folder's SDK lookup entry survives (the phantom used to REPLACE it — AddPredefinedNode is AddOrUpdate). var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))); handle.ShouldNotBeNull(); handle!.Node.ShouldBeOfType(); } finally { await host.DisposeAsync(); } } /// H2 — a variable may not take an id a folder already holds in the same realm. [Fact] public async Task EnsureVariable_refuses_an_id_held_by_a_folder() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); nm.EnsureVariable("eq-1", null, "eq-1", "Int32", writable: false, realm: AddressSpaceRealm.Uns); nm.TryGetVariable("eq-1").ShouldBeNull(); nm.TryGetFolder("eq-1").ShouldNotBeNull(); var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))); handle!.Node.ShouldBeSameAs(nm.TryGetFolder("eq-1")); } finally { await host.DisposeAsync(); } } /// H2 — a folder may not take an id a variable already holds in the same realm. [Fact] public async Task EnsureFolder_refuses_an_id_held_by_a_variable() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureVariable("Plant/X", null, "X", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.EnsureFolder("Plant/X", null, "X", AddressSpaceRealm.Raw); nm.TryGetFolder("Plant/X", AddressSpaceRealm.Raw).ShouldBeNull(); var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("Plant/X", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))); handle!.Node.ShouldBeSameAs(nm.TryGetVariable("Plant/X", AddressSpaceRealm.Raw)); } finally { await host.DisposeAsync(); } } /// H2 — a condition may not take an id a variable already holds in the same realm. [Fact] public async Task MaterialiseAlarmCondition_refuses_an_id_held_by_a_variable() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw); nm.EnsureVariable("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw); nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true); nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldBeNull(); nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull(); } finally { await host.DisposeAsync(); } } /// The same bare id in the OTHER realm is a different node and is still allowed (the guard is realm-qualified). [Fact] public async Task The_guard_is_realm_qualified() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.EnsureFolder("shared", null, "shared", AddressSpaceRealm.Uns); nm.EnsureVariable("shared", null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.TryGetVariable("shared", AddressSpaceRealm.Raw).ShouldNotBeNull(); nm.TryGetFolder("shared", AddressSpaceRealm.Uns).ShouldNotBeNull(); } finally { await host.DisposeAsync(); } } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.PhantomTest", ApplicationUri = $"urn:OtOpcUa.PhantomTest:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); public void Dispose() { if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); } }