refactor(opcuaclient): remove vestigial TargetNamespaceKind + UnsMappingTable (#648)

Nothing read the mapping table beyond the startup validator; v3 UNS projection is
explicit via UnsTagReference. Legacy keys in stored DriverConfig are ignored (pinned).
Reverts the docker-dev opc-plc seed workaround from fdee129f.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:14:37 -04:00
parent 8801631739
commit dd05baedd6
16 changed files with 90 additions and 246 deletions
@@ -58,7 +58,7 @@ public sealed class OpcUaClientDriverBrowserTests
/// <summary>
/// AdminUI emits enum-valued driver-config fields (SecurityPolicy, SecurityMode,
/// AuthType, TargetNamespaceKind) as their <b>string</b> names (e.g.
/// AuthType) as their <b>string</b> names (e.g.
/// <c>"SecurityPolicy":"Basic256Sha256"</c>). Without a
/// <see cref="System.Text.Json.Serialization.JsonStringEnumConverter"/> on the browser's
/// <c>JsonSerializerOptions</c> the deserialization throws a
@@ -72,8 +72,5 @@ public static class OpcPlcProfile
AutoAcceptCertificates = true,
Timeout = TimeSpan.FromSeconds(10),
SessionTimeout = TimeSpan.FromSeconds(30),
// opc-plc's standard address space mirrors verbatim — treat it as SystemPlatform so
// §8 namespace validation passes without requiring a UNS mapping table.
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
};
}
@@ -72,9 +72,6 @@ public sealed class OpcUaClientFailoverTests
PerEndpointConnectTimeout = TimeSpan.FromMilliseconds(500),
Timeout = TimeSpan.FromMilliseconds(500),
AutoAcceptCertificates = true,
// SystemPlatform kind needs no UNS mapping table — keeps this failover test
// focused on the endpoint sweep rather than §8 namespace validation.
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
};
using var drv = new OpcUaClientDriver(opts, "opcua-failover");
@@ -79,10 +79,6 @@ public sealed class OpcUaClientMediumFindingsRegressionTests
EndpointUrl = "opc.tcp://127.0.0.1:1",
Timeout = TimeSpan.FromMilliseconds(300),
AutoAcceptCertificates = true,
// Equipment is the default TargetNamespaceKind; it requires a mapping table to
// pass ValidateNamespaceKind, which runs before BuildApplicationConfigurationAsync
// where the warning is emitted.
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
};
using var drv = new OpcUaClientDriver(opts, "opcua-autocert", logger);
@@ -6,68 +6,11 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Regression coverage for driver-specs.md §8 namespace handling — the
/// <c>TargetNamespaceKind</c> startup enforcement and the server-stable NodeId encoding
/// that <see cref="NamespaceMap"/> produces (findings Driver.OpcUaClient-004).
/// Regression coverage for the server-stable NodeId encoding that <see cref="NamespaceMap"/> produces (findings Driver.OpcUaClient-004).
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientNamespaceTests
{
// ---- Driver.OpcUaClient-004: TargetNamespaceKind startup enforcement ----
/// <summary>Verifies that Equipment namespace kind without mapping table is rejected.</summary>
[Fact]
public void ValidateNamespaceKind_Equipment_without_mapping_table_is_rejected()
{
// §8: an Equipment-kind instance gateways raw equipment data and MUST carry a
// config-driven UNS mapping table — remote nodes don't conform to UNS by default.
var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.Equipment };
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateNamespaceKind(opts))
.Message.ShouldContain("UnsMappingTable");
}
/// <summary>Verifies that Equipment namespace kind with mapping table passes.</summary>
[Fact]
public void ValidateNamespaceKind_Equipment_with_mapping_table_passes()
{
var opts = new OpcUaClientDriverOptions
{
TargetNamespaceKind = OpcUaTargetNamespaceKind.Equipment,
UnsMappingTable = new Dictionary<string, string> { ["Line1"] = "Site/AreaA/Line1" },
};
Should.NotThrow(() => OpcUaClientDriver.ValidateNamespaceKind(opts));
}
/// <summary>Verifies that SystemPlatform namespace kind with mapping table is rejected.</summary>
[Fact]
public void ValidateNamespaceKind_SystemPlatform_with_mapping_table_is_rejected()
{
// §8: processed data preserves its own hierarchy — a UNS mapping table is ambiguous.
var opts = new OpcUaClientDriverOptions
{
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
UnsMappingTable = new Dictionary<string, string> { ["x"] = "y" },
};
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateNamespaceKind(opts))
.Message.ShouldContain("SystemPlatform");
}
/// <summary>Verifies that SystemPlatform namespace kind without mapping table passes.</summary>
[Fact]
public void ValidateNamespaceKind_SystemPlatform_without_mapping_table_passes()
{
var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform };
Should.NotThrow(() => OpcUaClientDriver.ValidateNamespaceKind(opts));
}
/// <summary>Verifies that the default target namespace kind is Equipment.</summary>
[Fact]
public void Default_TargetNamespaceKind_is_Equipment()
{
// §8 comparison table: OPC UA Client default lane is Equipment.
new OpcUaClientDriverOptions().TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.Equipment);
}
// ---- Driver.OpcUaClient-004: server-stable NodeId encoding ----
/// <summary>Verifies that NamespaceMap.FromSession rejects null session.</summary>
@@ -13,21 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
public sealed class OpcUaClientReinitConfigAdoptionTests
{
/// <summary>
/// <c>ValidateNamespaceKind</c> runs at the top of init and rejects <c>TargetNamespaceKind=Equipment</c>
/// with an empty UNS mapping table. Reinitializing INTO that shape must therefore throw — a driver
/// still serving the constructor's valid options would validate those and succeed, which is exactly
/// how the discarded edit stayed invisible.
/// <c>ValidateTuning</c> runs at the top of init and rejects a non-absolute <c>discoveryUrl</c>.
/// Reinitializing INTO that shape must therefore throw — a driver still serving the constructor's
/// valid options would validate those and succeed, which is exactly how the discarded edit stayed
/// invisible.
/// </summary>
[Fact]
public async Task Reinitialize_adopts_a_changed_config()
{
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
"opc1",
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
"""{"endpointUrl":"opc.tcp://127.0.0.1:1"}""");
await Should.ThrowAsync<InvalidOperationException>(
() => driver.ReinitializeAsync(
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{}}""",
"""{"endpointUrl":"opc.tcp://127.0.0.1:1","discoveryUrl":"not a uri"}""",
CancellationToken.None),
"OpcUaClientDriver kept its constructor-supplied options after a reinitialize with a changed config (#516)");
}
@@ -36,15 +36,42 @@ public sealed class OpcUaClientReinitConfigAdoptionTests
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
{
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
"opc1",
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
// Built directly (the factory's parse would refuse the bad discoveryUrl up front).
using var driver = new OpcUaClientDriver(
new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://127.0.0.1:1", DiscoveryUrl = "not a uri" }, "opc1");
// "{}" keeps the (valid) constructor options, so ValidateNamespaceKind passes and the driver gets as
// far as the connect — which fails against an unreachable endpoint. Anything BUT the validation
// throw proves the constructor options survived.
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
(ex is InvalidOperationException ioe && ioe.Message.Contains("UnsMappingTable", StringComparison.OrdinalIgnoreCase))
.ShouldBeFalse("an empty document must keep the constructor-supplied options, not re-validate an empty config");
// The constructor options are invalid (bad discoveryUrl) but "{}" keeps them WITHOUT re-parsing;
// a re-parse of an empty config would yield valid defaults. So tuning validation must still see
// the constructor's bad discoveryUrl and throw — a driver that re-parsed "{}" would not.
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => driver.ReinitializeAsync("{}", CancellationToken.None));
ex.Message.ShouldContain("DiscoveryUrl");
}
/// <summary>
/// #648 — <c>targetNamespaceKind</c> / <c>unsMappingTable</c> were removed (vestigial in v3). DriverConfig
/// JSON stored before the removal still carries them, in every shape the old validator used to refuse
/// (Equipment with no table, Equipment with a table, SystemPlatform with a table). None may fail the
/// parse or the initialise: the unknown keys are ignored. Falsifiable — it fails if a namespace-kind
/// validator is reinstated, because the Equipment-without-table blob would throw again.
/// </summary>
[Theory]
[InlineData("""{"endpointUrl":"opc.tcp://127.0.0.1:1","targetNamespaceKind":"Equipment"}""")]
[InlineData("""{"endpointUrl":"opc.tcp://127.0.0.1:1","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""")]
[InlineData("""{"endpointUrl":"opc.tcp://127.0.0.1:1","targetNamespaceKind":"SystemPlatform","unsMappingTable":{"a":"b"}}""")]
[InlineData("""{"endpointUrl":"opc.tcp://127.0.0.1:1","targetNamespaceKind":1,"unsMappingTable":{}}""")]
public async Task Legacy_namespace_keys_are_ignored_by_parse_and_initialise(string legacyJson)
{
Should.NotThrow(() => OpcUaClientDriverFactoryExtensions.ParseOptions("opc1", legacyJson));
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance("opc1", legacyJson);
// Initialise gets past config validation and fails only at the connect (nothing listens on port 1).
// Anything other than a namespace-validation InvalidOperationException proves the keys were ignored.
var ex = await Record.ExceptionAsync(() => driver.InitializeAsync(legacyJson, CancellationToken.None));
(ex is InvalidOperationException ioe
&& (ioe.Message.Contains("UnsMappingTable", StringComparison.OrdinalIgnoreCase)
|| ioe.Message.Contains("TargetNamespaceKind", StringComparison.OrdinalIgnoreCase)))
.ShouldBeFalse("legacy namespace keys must be ignored, not validated");
}
}
@@ -39,7 +39,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
BrowseRoot = "i=85",
MaxDiscoveredNodes = 5000,
MaxBrowseDepth = 6,
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
ProbeTimeoutSeconds = 20,
};
@@ -64,7 +63,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
back.BrowseRoot.ShouldBe("i=85");
back.MaxDiscoveredNodes.ShouldBe(5000);
back.MaxBrowseDepth.ShouldBe(6);
back.TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.SystemPlatform);
back.ProbeTimeoutSeconds.ShouldBe(20);
}
@@ -86,16 +84,9 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
[Fact]
public void FormModel_RoundTrip_PreservesAllFields()
{
// Construct options with non-default values for every editable property plus a
// non-empty UnsMappingTable (read-only in the form, round-tripped via the original
// record). EndpointUrls is now edited via the CollectionEditor on the page and is
// Construct options with non-default values for every editable property. EndpointUrls is now edited via the CollectionEditor on the page and is
// threaded into ToRecord explicitly; see EndpointUrls_ListRoundTrip_PreservesOrder.
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://backup:4840" };
var unsMappingTable = new Dictionary<string, string>
{
["Line1/"] = "Site/Area1/Line1",
["Line2/"] = "Site/Area1/Line2",
};
var original = new OpcUaClientDriverOptions
{
@@ -119,8 +110,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
BrowseRoot = "i=85",
MaxDiscoveredNodes = 3000,
MaxBrowseDepth = 5,
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
UnsMappingTable = unsMappingTable,
ProbeTimeoutSeconds = 25,
};
@@ -149,10 +138,6 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
result.BrowseRoot.ShouldBe("i=85");
result.MaxDiscoveredNodes.ShouldBe(3000);
result.MaxBrowseDepth.ShouldBe(5);
result.TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.SystemPlatform);
result.UnsMappingTable.Count.ShouldBe(2);
result.UnsMappingTable["Line1/"].ShouldBe("Site/Area1/Line1");
result.UnsMappingTable["Line2/"].ShouldBe("Site/Area1/Line2");
result.ProbeTimeoutSeconds.ShouldBe(25);
}
@@ -199,6 +184,32 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
def.DiscoveryUrl.ShouldBeNull();
}
/// <summary>
/// #648: stored DriverConfig JSON from before <c>targetNamespaceKind</c>/<c>unsMappingTable</c> were removed
/// must still load into the form (the form's options use <c>UnmappedMemberHandling.Skip</c>) and the legacy
/// keys must NOT be re-emitted on save.
/// </summary>
[Fact]
public void LegacyNamespaceKeys_LoadIntoForm_AndAreNotReEmitted()
{
var formOpts = new JsonSerializerOptions(_opts)
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
const string legacy =
"""{"endpointUrl":"opc.tcp://plc:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"Line1/":"Site/Area1/Line1"}}""";
var opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(legacy, formOpts);
opts.ShouldNotBeNull();
opts.EndpointUrl.ShouldBe("opc.tcp://plc:4840");
var saved = JsonSerializer.Serialize(
OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(opts).ToRecord([]), formOpts);
saved.ShouldNotContain("targetNamespaceKind", Case.Insensitive);
saved.ShouldNotContain("unsMappingTable", Case.Insensitive);
}
[Fact]
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
{
@@ -58,7 +58,6 @@ public sealed class OpcUaClientDriverWireTests
Username = "apikey",
Password = ServerEventRig.KeyPassword,
AutoAcceptCertificates = true,
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
Timeout = TimeSpan.FromSeconds(10),
SessionTimeout = TimeSpan.FromSeconds(30),
KeepAliveInterval = TimeSpan.FromSeconds(1),
@@ -281,7 +280,6 @@ public sealed class OpcUaClientDriverWireTests
Username = "apikey",
Password = ServerEventRig.KeyPassword,
AutoAcceptCertificates = true,
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
Timeout = TimeSpan.FromSeconds(10),
SessionTimeout = TimeSpan.FromSeconds(30),
KeepAliveInterval = TimeSpan.FromSeconds(1),