Merge b647-opcua-nskind: retire OpcUaClient TargetNamespaceKind/UnsMappingTable (#648)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:16:41 -04:00
16 changed files with 90 additions and 246 deletions
+1 -1
View File
@@ -218,7 +218,7 @@ DECLARE @OpcPlcAlarmSourceNodeId nvarchar(400) = N''; -- e.g. N'ns=3;s=<source
IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-opcua')
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES ('MAIN-opcua', 'MAIN', NULL, 'opcplc', 'OpcUaClient',
N'{ "SecurityPolicy": "None", "SecurityMode": "None", "AuthType": "Anonymous", "AutoAcceptCertificates": true, "TargetNamespaceKind": "SystemPlatform" }', 1);
N'{ "SecurityPolicy": "None", "SecurityMode": "None", "AuthType": "Anonymous", "AutoAcceptCertificates": true }', 1);
IF NOT EXISTS (SELECT 1 FROM dbo.Device WHERE DeviceId = 'MAIN-opcua-dev')
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES ('MAIN-opcua-dev', 'MAIN-opcua', 'plc', 1, N'{ "EndpointUrl": "opc.tcp://10.100.0.35:50000" }');
+14 -13
View File
@@ -38,8 +38,7 @@ out-of-process isolation.
"EndpointUrl": "opc.tcp://plc.internal:4840",
"SecurityPolicy": "None",
"SecurityMode": "None",
"AuthType": "Anonymous",
"TargetNamespaceKind": "Equipment"
"AuthType": "Anonymous"
}
}
}
@@ -62,19 +61,21 @@ dangle. Stored NodeIds embed the server-stable namespace **URI** (not the
session-relative `ns=N` index) so a remote namespace-table reorder across a
restart doesn't silently re-point references at the wrong namespace.
### Namespace assignment
### Namespace assignment (retired, Gitea #648)
This is the only driver that gateways into **either** namespace kind, decided
per instance via `TargetNamespaceKind`:
Earlier versions required a per-instance `TargetNamespaceKind` (`Equipment` +
a mandatory `UnsMappingTable`, or `SystemPlatform` + an empty table). Both
options are **removed**: nothing ever read the mapping table beyond the startup
check, and in v3 the raw-to-UNS projection is explicit (`UnsTagReference`), so
the choice carried no behaviour — it only made a config without the key fail
`Initialize`. Remote nodes are authored as `/raw` tags bound by their upstream
`nodeId`; there is no namespace field on the driver, the AdminUI form or the
seed.
- `Equipment` — the remote server exposes raw equipment data; remote browse
paths are remapped to UNS via a required `UnsMappingTable`.
- `SystemPlatform` — the remote server exposes processed/derived data; the
remote hierarchy is preserved with no UNS conversion (and the mapping table
must be empty).
The choice is enforced at startup so a misconfiguration fails draft validation
rather than surfacing as a runtime surprise.
Stored `DriverConfig` JSON that still carries `targetNamespaceKind` or
`unsMappingTable` keeps working: the keys are unknown and ignored on parse and
initialise (the AdminUI form drops them on the next save). Pinned by
`OpcUaClientReinitConfigAdoptionTests.Legacy_namespace_keys_are_ignored_by_parse_and_initialise`.
## Capability surface
+2 -7
View File
@@ -857,12 +857,7 @@ Tier A (pure managed, OPC Foundation reference SDK). Universal protections cover
### Namespace Assignment
OPC UA Client is the only driver that supports **either** namespace kind, decided per driver instance via `DriverConfig.TargetNamespaceKind`:
- **Equipment**: when gatewaying a remote OPC UA server that exposes raw equipment data (e.g. another vendor's OPC UA-native PLC stack). The driver must remap remote browse paths to UNS via a config-driven mapping table — remote nodes don't conform to UNS by default. Each remote node group → an `Equipment` row with its own UNS Area/Line/Name and stable UUID.
- **SystemPlatform**: when gatewaying a remote OPC UA server that exposes processed/derived data (e.g. another System Platform endpoint). Hierarchy is preserved via `Tag.FolderPath` mirroring the remote browse path; no UNS conversion.
The driver enforces the namespace choice at startup — a misconfigured remote (raw signals routed to SystemPlatform, or processed data routed to Equipment without a UNS mapping) fails draft validation, not runtime.
> **Retired (Gitea #648).** The per-instance `TargetNamespaceKind` (Equipment + `UnsMappingTable` vs SystemPlatform) and its startup enforcement were removed as vestigial in v3: UNS projection is explicit via `UnsTagReference`, and nothing consumed the mapping table. Legacy `targetNamespaceKind` / `unsMappingTable` keys in stored config are ignored. See `docs/drivers/OpcUaClient.md`.
---
@@ -877,6 +872,6 @@ The driver enforces the namespace choice at startup — a misconfigured remote (
| Siemens S7 | S7netplus | MIT | B — in-process with guards | Equipment | .NET 10 x64 | No (polled) | Config DB | 3-30 per PLC | PUT/GET enabled (S7-1200/1500) |
| TwinCAT | Beckhoff.TwinCAT.Ads 6.x | Proprietary | B — in-process with guards | Equipment | .NET 10 x64 | **Yes (ADS)** | Symbol upload | 64-128 | AMS route configured |
| FOCAS | Fwlib64.dll (P/Invoke) + MessagePack IPC | FANUC SDK license | **C — out-of-process** | Equipment | .NET 10 x64 (Host + Proxy) | No (polled) | Built-in + Config DB | **5-10 per CNC** | FANUC SDK license, CNC Ethernet option, Focas.Host Windows service |
| OPC UA Client | OPC Foundation 1.5.x | GPL/RCL | A — in-process | Equipment OR SystemPlatform (per-instance) | .NET 10 x64 | **Yes (native)** | Browse remote | Varies | Certificate trust |
| OPC UA Client | OPC Foundation 1.5.x | GPL/RCL | A — in-process | Equipment | .NET 10 x64 | **Yes (native)** | Browse remote | Varies | Certificate trust |
Tier definitions and per-tier protections: see `driver-stability.md`. Namespace model and UNS naming rules: see `plan.md` §4 and `config-db-schema.md` (Equipment table). Equipment-namespace drivers populate `Equipment` rows whose UNS path comes from `ServerCluster.Enterprise/Site` + `Equipment.Area/Line/Name`; SystemPlatform-namespace drivers (Galaxy) preserve their own hierarchy via `Tag.FolderPath` as v1 LmxOpcUa expressed it.
@@ -21,7 +21,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
// Kept identical to OpcUaClientDriverFactoryExtensions.JsonOptions and
// OpcUaClientDriverProbe._opts so the browser parses a given DriverConfig JSON
// the same way as the factory and the probe. The JsonStringEnumConverter lets
// enum-valued knobs (SecurityPolicy / SecurityMode / AuthType / TargetNamespaceKind)
// enum-valued knobs (SecurityPolicy / SecurityMode / AuthType)
// be authored as their string names — the natural form for AdminUI-emitted JSON.
// JsonStringEnumConverter also accepts numeric ordinals, so existing numeric configs
// continue to work without migration.
@@ -153,36 +153,6 @@ public sealed record OpcUaClientDriverOptions
[Range(1, int.MaxValue)]
public int MaxBrowseDepth { get; init; } = 10;
/// <summary>
/// The namespace kind this driver instance gateways into. Per
/// <c>docs/v2/driver-specs.md</c> §8 "Namespace Assignment", the OPC UA Client driver
/// is the only driver that supports <b>either</b> kind, decided per instance:
/// <list type="bullet">
/// <item><see cref="OpcUaTargetNamespaceKind.Equipment"/> — gatewaying a remote
/// server that exposes raw equipment data; the driver remaps remote browse paths
/// to UNS.</item>
/// <item><see cref="OpcUaTargetNamespaceKind.SystemPlatform"/> — gatewaying a
/// remote server that exposes processed/derived data; hierarchy is preserved via
/// the remote browse path with no UNS conversion.</item>
/// </list>
/// The driver enforces the choice at startup — see
/// <c>OpcUaClientDriver.ValidateNamespaceKind</c> — so a misconfiguration fails
/// draft validation rather than surfacing as a runtime surprise.
/// </summary>
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; init; } = OpcUaTargetNamespaceKind.Equipment;
/// <summary>
/// Optional config-driven remote-to-UNS mapping table. Required when
/// <see cref="TargetNamespaceKind"/> is <see cref="OpcUaTargetNamespaceKind.Equipment"/>:
/// §8 mandates that a remote server gatewayed as Equipment carry a mapping table
/// because remote nodes do not conform to UNS by default. Keys are remote browse-path
/// prefixes; values are the UNS Area/Line/Name path the matching subtree maps onto.
/// For <see cref="OpcUaTargetNamespaceKind.SystemPlatform"/> the table must be empty —
/// processed data preserves its own hierarchy and a mapping table would be ambiguous.
/// </summary>
public IReadOnlyDictionary<string, string> UnsMappingTable { get; init; }
= new Dictionary<string, string>();
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
@@ -266,25 +236,6 @@ public sealed record OpcUaCertificateValidationOptions
public string? IssuerStorePath { get; init; }
}
/// <summary>
/// The namespace kind an OPC UA Client driver instance gateways its remote server into.
/// See <c>docs/v2/driver-specs.md</c> §8 "Namespace Assignment".
/// </summary>
public enum OpcUaTargetNamespaceKind
{
/// <summary>
/// Remote server exposes raw equipment data; remote browse paths are remapped to UNS
/// via <see cref="OpcUaClientDriverOptions.UnsMappingTable"/>.
/// </summary>
Equipment,
/// <summary>
/// Remote server exposes processed/derived data; hierarchy is preserved via the
/// remote browse path with no UNS conversion.
/// </summary>
SystemPlatform,
}
/// <summary>OPC UA message security mode.</summary>
public enum OpcUaSecurityMode
{
@@ -241,10 +241,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (HasConfigBody(driverConfigJson))
_options = OpcUaClientDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// Enforce the Equipment-vs-SystemPlatform choice at startup per driver-specs.md
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
// not as a runtime surprise.
ValidateNamespaceKind(_options);
ValidateTuning(_options);
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
@@ -643,42 +639,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return effective < TimeSpan.FromMilliseconds(50) ? 50 : (int)effective.TotalMilliseconds;
}
/// <summary>
/// Enforce the §8 "Namespace Assignment" rule at startup. An <c>Equipment</c>-kind
/// instance gateways raw equipment data and therefore needs a config-driven UNS
/// mapping table (remote nodes don't conform to UNS); a <c>SystemPlatform</c>-kind
/// instance gateways processed data whose hierarchy is preserved verbatim, so a
/// UNS mapping table is meaningless and rejected. Throwing here surfaces the
/// misconfiguration as a draft-validation failure rather than a runtime surprise.
/// </summary>
/// <param name="opts">Driver options containing namespace configuration.</param>
internal static void ValidateNamespaceKind(OpcUaClientDriverOptions opts)
{
switch (opts.TargetNamespaceKind)
{
case OpcUaTargetNamespaceKind.Equipment:
if (opts.UnsMappingTable is null || opts.UnsMappingTable.Count == 0)
throw new InvalidOperationException(
"OpcUaClient driver configured with TargetNamespaceKind=Equipment but no " +
"UnsMappingTable: §8 requires a config-driven remote-to-UNS mapping table " +
"because remote nodes do not conform to UNS by default. Provide a mapping " +
"table or set TargetNamespaceKind=SystemPlatform if the remote exposes " +
"processed data.");
break;
case OpcUaTargetNamespaceKind.SystemPlatform:
if (opts.UnsMappingTable is { Count: > 0 })
throw new InvalidOperationException(
"OpcUaClient driver configured with TargetNamespaceKind=SystemPlatform but " +
"a UnsMappingTable was supplied: processed data preserves its own hierarchy " +
"and a UNS mapping table is ambiguous here. Clear the mapping table or set " +
"TargetNamespaceKind=Equipment if the remote exposes raw equipment data.");
break;
default:
throw new ArgumentOutOfRangeException(
nameof(opts), opts.TargetNamespaceKind, "Unknown TargetNamespaceKind.");
}
}
/// <summary>
/// Build the user-identity token from the driver options. Split out of
/// <see cref="InitializeAsync"/> so the failover sweep reuses one identity across
@@ -23,7 +23,7 @@ public static class OpcUaClientDriverFactoryExtensions
// Match OpcUaClientDriverProbe exactly so factory + probe parse a config identically.
// The JsonStringEnumConverter lets enum-valued knobs (SecurityMode / SecurityPolicy /
// AuthType / TargetNamespaceKind) be authored as their string names — the natural form
// AuthType) be authored as their string names — the natural form
// for human-edited + AdminUI-emitted DriverConfig JSON.
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -161,30 +161,6 @@
</div>
</section>
@* Namespace mapping *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Namespace mapping</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Target namespace kind</label>
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
</div>
<div class="col-12">
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
</div>
</div>
</div>
</section>
@* Subscriptions, limits, revocation, discovery (#273/#275/#277/#278) *@
<section class="panel rise mt-3" style="animation-delay:.13s">
<div class="panel-head">Subscriptions, limits &amp; discovery</div>
@@ -272,7 +248,6 @@
private FormModel _form = new();
private List<EndpointUrlRow> _endpoints = [];
private string _unsMappingTableJson = "{}";
private string? _lastParsed;
protected override void OnParametersSet()
@@ -282,7 +257,6 @@
var opts = TryDeserialize(DriverConfigJson) ?? new OpcUaClientDriverOptions();
_form = new FormModel { OpcUa = OpcUaClientFormModel.FromRecord(opts) };
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
_unsMappingTableJson = JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
_lastParsed = DriverConfigJson;
}
}
@@ -369,7 +343,6 @@
public string? UserCertificatePath { get; set; }
public string? UserCertificatePassword { get; set; }
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
public int ProbeTimeoutSeconds { get; set; } = 15;
@@ -385,7 +358,6 @@
public string? DiscoveryUrl { get; set; }
public string? DiscoveryServerUri { get; set; }
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
{
@@ -408,7 +380,6 @@
Password = r.Password,
UserCertificatePath = r.UserCertificatePath,
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
SubscriptionPublishingIntervalMs = r.SubscriptionPublishingInterval is { } spi ? (int)spi.TotalMilliseconds : null,
SubscriptionKeepAliveCount = (int)Math.Min(r.SubscriptionKeepAliveCount, int.MaxValue),
@@ -419,7 +390,6 @@
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
DiscoveryUrl = r.DiscoveryUrl,
DiscoveryServerUri = r.DiscoveryServerUri,
_unsMappingTable = r.UnsMappingTable,
};
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
@@ -444,8 +414,6 @@
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
TargetNamespaceKind = TargetNamespaceKind,
UnsMappingTable = _unsMappingTable,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
SubscriptionPublishingInterval = SubscriptionPublishingIntervalMs is { } ms and > 0
? TimeSpan.FromMilliseconds(ms) : null,
@@ -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),