d32050da24
A stored ns=<index> reference is only meaningful against one server's namespace table. A server that adds, removes or reorders a namespace silently re-points every binding: best case BadNodeIdUnknown, worst case the index now names a different namespace holding a colliding identifier and the binding resolves to the wrong node with Good quality. Nothing could detect that, because ScadaBridge stored no namespace URI anywhere. OtOpcUa v3.0 makes this concrete: v2's sole custom namespace and v3's raw namespace both sit at index 2, so v2-era bindings resolve against v3 without error while meaning something else entirely. Adds OpcUaNodeReference as the single translation seam between stored references and the wire. Resolve() accepts both ns=<index>;s=<id> (existing bindings, which keep working unchanged) and the durable nsu=<uri>;s=<id>, mapping the URI to the live index at use time; it is wired into every parse site — subscribe, read, write, alarm-subscribe and browse. An unpublished URI now throws naming the URI rather than binding to whatever occupies that index, and a svr= reference to another server is rejected instead of being resolved against the wrong address space. The browser emits ToDurable(), so what the picker shows is what gets stored and newly-authored bindings are index-proof from the start. That also closes a round-trip gap: browse previously emitted ExpandedNodeId.ToString(), which for a URI- or server-index-carrying reference produced a string NodeId.Parse could not read back — the same method already resolved it correctly 57 lines later. Bindings stored before this change keep their ns= form and keep working; they are only as durable as the server's namespace order. Re-authoring against the picker is what makes them durable, and that re-bind still needs a live v3 rig. Refs: Gitea #14
166 lines
6.3 KiB
C#
166 lines
6.3 KiB
C#
using Opc.Ua;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
|
|
|
/// <summary>
|
|
/// Gitea #14: stored bindings hard-code a namespace <i>index</i>, which is only
|
|
/// meaningful against one server's namespace table. These tests pin the durable
|
|
/// <c>nsu=<uri></c> form and the back-compat of the existing <c>ns=</c> form.
|
|
/// </summary>
|
|
public class OpcUaNodeReferenceTests
|
|
{
|
|
// Mirrors an OtOpcUa v3 server: 0=UA (spec), 1=ApplicationUri, 2=raw, 3=uns.
|
|
private const string RawUri = "https://zb.com/otopcua/raw";
|
|
private const string UnsUri = "https://zb.com/otopcua/uns";
|
|
|
|
private static NamespaceTable V3Table()
|
|
{
|
|
var t = new NamespaceTable(); // index 0 = http://opcfoundation.org/UA/
|
|
t.Append("urn:localhost:OtOpcUa"); // 1
|
|
t.Append(RawUri); // 2
|
|
t.Append(UnsUri); // 3
|
|
return t;
|
|
}
|
|
|
|
// The v2 server: a single custom namespace, which also landed at index 2.
|
|
private static NamespaceTable V2Table()
|
|
{
|
|
var t = new NamespaceTable();
|
|
t.Append("urn:localhost:OtOpcUa"); // 1
|
|
t.Append("https://zb.com/otopcua/ns"); // 2
|
|
return t;
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_IndexForm_StillWorks()
|
|
{
|
|
// Back-compat: every binding in the field today is this shape.
|
|
var nodeId = OpcUaNodeReference.Resolve("ns=2;s=pymodbus/plc/HR200", V3Table());
|
|
|
|
Assert.Equal(2, nodeId.NamespaceIndex);
|
|
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_UriForm_MapsToLiveIndex()
|
|
{
|
|
var nodeId = OpcUaNodeReference.Resolve($"nsu={RawUri};s=pymodbus/plc/HR200", V3Table());
|
|
|
|
Assert.Equal(2, nodeId.NamespaceIndex);
|
|
Assert.Equal("pymodbus/plc/HR200", nodeId.Identifier);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_UriForm_TracksIndexAcrossServers()
|
|
{
|
|
// The point of the durable form: same reference, a server that orders its
|
|
// namespaces differently, still the right namespace.
|
|
var reordered = new NamespaceTable();
|
|
reordered.Append("urn:localhost:OtOpcUa"); // 1
|
|
reordered.Append("urn:something:else"); // 2 <- pushed raw along
|
|
reordered.Append(RawUri); // 3
|
|
|
|
var v3 = OpcUaNodeReference.Resolve($"nsu={RawUri};s=Tag", V3Table());
|
|
var moved = OpcUaNodeReference.Resolve($"nsu={RawUri};s=Tag", reordered);
|
|
|
|
Assert.Equal(2, v3.NamespaceIndex);
|
|
Assert.Equal(3, moved.NamespaceIndex); // followed the URI, not the index
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_IndexForm_SilentlyBindsToTheWrongNamespace_AcrossServerUpgrade()
|
|
{
|
|
// This is the #14 hazard, pinned as a characterisation test rather than a wish:
|
|
// v2's sole custom namespace and v3's raw namespace BOTH sit at index 2, so a
|
|
// v2-era ns=2 binding resolves happily against v3 while meaning something else
|
|
// entirely. Nothing in the index form can detect that — which is the whole
|
|
// argument for re-authoring bindings in the nsu= form.
|
|
const string v2Binding = "ns=2;s=Line1/Pump/Speed";
|
|
|
|
var againstV2 = OpcUaNodeReference.Resolve(v2Binding, V2Table());
|
|
var againstV3 = OpcUaNodeReference.Resolve(v2Binding, V3Table());
|
|
|
|
Assert.Equal(againstV2.NamespaceIndex, againstV3.NamespaceIndex);
|
|
Assert.Equal("https://zb.com/otopcua/ns", V2Table().GetString(againstV2.NamespaceIndex));
|
|
Assert.Equal(RawUri, V3Table().GetString(againstV3.NamespaceIndex)); // different namespace, no error
|
|
|
|
// The durable form fails loudly instead.
|
|
var ex = Assert.Throws<ArgumentException>(
|
|
() => OpcUaNodeReference.Resolve($"nsu=https://zb.com/otopcua/ns;s=Line1/Pump/Speed", V3Table()));
|
|
Assert.Contains("does not publish", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_UnknownNamespaceUri_Throws()
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(
|
|
() => OpcUaNodeReference.Resolve("nsu=https://example.invalid/nope;s=Tag", V3Table()));
|
|
|
|
Assert.Contains("NamespaceArray", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_ForeignServerReference_Throws()
|
|
{
|
|
var ex = Assert.Throws<ArgumentException>(
|
|
() => OpcUaNodeReference.Resolve($"svr=1;nsu={RawUri};s=Tag", V3Table()));
|
|
|
|
Assert.Contains("another server", ex.Message);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ;;; not a node id")]
|
|
public void Resolve_Unparseable_Throws(string reference)
|
|
{
|
|
Assert.ThrowsAny<ArgumentException>(() => OpcUaNodeReference.Resolve(reference, V3Table()));
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDurable_ServerNamespace_EmitsUriForm()
|
|
{
|
|
var browsed = new ExpandedNodeId("pymodbus/plc/HR200", 2, null, 0);
|
|
|
|
var durable = OpcUaNodeReference.ToDurable(browsed, V3Table());
|
|
|
|
Assert.Equal($"nsu={RawUri};s=pymodbus/plc/HR200", durable);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDurable_StandardNamespace_StaysShort()
|
|
{
|
|
// ns=0 is spec-fixed; qualifying it would be noise.
|
|
var durable = OpcUaNodeReference.ToDurable(new ExpandedNodeId(85u, 0), V3Table());
|
|
|
|
Assert.Equal("i=85", durable);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDurable_RoundTripsThroughResolve()
|
|
{
|
|
// The bug this closes: browse emitted ExpandedNodeId.ToString(), which for a
|
|
// URI-carrying reference produced a string NodeId.Parse could not read back.
|
|
var table = V3Table();
|
|
var browsed = new ExpandedNodeId("Area/Line/Equip/Speed", 0, UnsUri, 0);
|
|
|
|
var durable = OpcUaNodeReference.ToDurable(browsed, table);
|
|
var resolved = OpcUaNodeReference.Resolve(durable, table);
|
|
|
|
Assert.Equal(3, resolved.NamespaceIndex);
|
|
Assert.Equal("Area/Line/Equip/Speed", resolved.Identifier);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDurable_UriNotInTable_FallsBackRatherThanThrowing()
|
|
{
|
|
// Browsing is a discovery path — a namespace we cannot name should degrade to
|
|
// the raw string, not abort the browse.
|
|
var browsed = new ExpandedNodeId("Tag", 0, "https://example.invalid/nope", 0);
|
|
|
|
var durable = OpcUaNodeReference.ToDurable(browsed, V3Table());
|
|
|
|
Assert.Contains("Tag", durable);
|
|
}
|
|
}
|