e6ec5a0948
Owner decision 2026-11-11: certificateValidation.validateServerHostName stays opt-in (unset = off). The AdminUI toggle moves from the "Subscriptions, limits & discovery" panel to the Security panel beside mode/policy, with help text on the NAT/IP caveat, IPv6 zone ids and the loose first-label match. Binding unchanged (tri-state, "Default (off)" leaves the key absent). OpcUaClient.md states the decision; drivers rule updated; source-scan test pins placement + caveats. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
353 lines
16 KiB
C#
353 lines
16 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
|
|
|
public sealed class OpcUaClientDriverPageFormSerializationTests
|
|
{
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
};
|
|
|
|
[Fact]
|
|
public void RoundTrip_PreservesKnownFields()
|
|
{
|
|
var original = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://plc.internal:4840",
|
|
ApplicationUri = "urn:plc:OtOpcUa:GatewayClient",
|
|
SessionName = "MySession",
|
|
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
|
|
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
|
|
AuthType = OpcUaAuthType.Username,
|
|
Username = "operator",
|
|
Password = "s3cr3t",
|
|
PerEndpointConnectTimeout = TimeSpan.FromSeconds(5),
|
|
Timeout = TimeSpan.FromSeconds(20),
|
|
SessionTimeout = TimeSpan.FromSeconds(180),
|
|
KeepAliveInterval = TimeSpan.FromSeconds(10),
|
|
AutoAcceptCertificates = true,
|
|
BrowseRoot = "i=85",
|
|
MaxDiscoveredNodes = 5000,
|
|
MaxBrowseDepth = 6,
|
|
ProbeTimeoutSeconds = 20,
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(original, _opts);
|
|
var back = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _opts);
|
|
|
|
back.ShouldNotBeNull();
|
|
back.EndpointUrl.ShouldBe("opc.tcp://plc.internal:4840");
|
|
back.ApplicationUri.ShouldBe("urn:plc:OtOpcUa:GatewayClient");
|
|
back.SessionName.ShouldBe("MySession");
|
|
back.SecurityMode.ShouldBe(OpcUaSecurityMode.SignAndEncrypt);
|
|
back.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.Basic256Sha256);
|
|
back.AuthType.ShouldBe(OpcUaAuthType.Username);
|
|
back.Username.ShouldBe("operator");
|
|
back.Password.ShouldBe("s3cr3t");
|
|
back.PerEndpointConnectTimeout.ShouldBe(TimeSpan.FromSeconds(5));
|
|
back.Timeout.ShouldBe(TimeSpan.FromSeconds(20));
|
|
back.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(180));
|
|
back.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(10));
|
|
back.AutoAcceptCertificates.ShouldBeTrue();
|
|
back.BrowseRoot.ShouldBe("i=85");
|
|
back.MaxDiscoveredNodes.ShouldBe(5000);
|
|
back.MaxBrowseDepth.ShouldBe(6);
|
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
|
}
|
|
|
|
[Fact]
|
|
public void Deserialize_DropsUnknownFields()
|
|
{
|
|
var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":20}""";
|
|
|
|
var optsWithSkip = new JsonSerializerOptions(_opts)
|
|
{
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
};
|
|
|
|
var back = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(jsonWithExtra, optsWithSkip);
|
|
back.ShouldNotBeNull();
|
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormModel_RoundTrip_PreservesAllFields()
|
|
{
|
|
// 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 original = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://fallback:4840",
|
|
EndpointUrls = endpointUrls,
|
|
ApplicationUri = "urn:test:OtOpcUa:GatewayClient",
|
|
SessionName = "TestSession",
|
|
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
|
|
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
|
|
AuthType = OpcUaAuthType.Username,
|
|
Username = "opcuser",
|
|
Password = "p@ssw0rd",
|
|
UserCertificatePath = @"C:\certs\user.pfx",
|
|
UserCertificatePassword = "certpass",
|
|
PerEndpointConnectTimeout = TimeSpan.FromSeconds(4),
|
|
Timeout = TimeSpan.FromSeconds(12),
|
|
SessionTimeout = TimeSpan.FromSeconds(150),
|
|
KeepAliveInterval = TimeSpan.FromSeconds(7),
|
|
AutoAcceptCertificates = true,
|
|
BrowseRoot = "i=85",
|
|
MaxDiscoveredNodes = 3000,
|
|
MaxBrowseDepth = 5,
|
|
ProbeTimeoutSeconds = 25,
|
|
};
|
|
|
|
var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original);
|
|
var result = form.ToRecord(endpointUrls);
|
|
|
|
result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840");
|
|
result.EndpointUrls.Count.ShouldBe(2);
|
|
result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840");
|
|
result.EndpointUrls[1].ShouldBe("opc.tcp://backup:4840");
|
|
result.ApplicationUri.ShouldBe("urn:test:OtOpcUa:GatewayClient");
|
|
result.SessionName.ShouldBe("TestSession");
|
|
result.SecurityMode.ShouldBe(OpcUaSecurityMode.SignAndEncrypt);
|
|
result.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.Basic256Sha256);
|
|
result.AuthType.ShouldBe(OpcUaAuthType.Username);
|
|
result.Username.ShouldBe("opcuser");
|
|
result.Password.ShouldBe("p@ssw0rd");
|
|
// #836 review L-1: hidden (AuthType ≠ Certificate) ⇒ not persisted.
|
|
result.UserCertificatePath.ShouldBeNull();
|
|
OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original with { AuthType = OpcUaAuthType.Certificate, UserCertificatePath = "plant1/user.pfx" })
|
|
.ToRecord(endpointUrls).UserCertificatePath.ShouldBe("plant1/user.pfx");
|
|
result.UserCertificatePassword.ShouldBe("certpass");
|
|
result.PerEndpointConnectTimeout.ShouldBe(TimeSpan.FromSeconds(4));
|
|
result.Timeout.ShouldBe(TimeSpan.FromSeconds(12));
|
|
result.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(150));
|
|
result.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(7));
|
|
result.AutoAcceptCertificates.ShouldBeTrue();
|
|
result.BrowseRoot.ShouldBe("i=85");
|
|
result.MaxDiscoveredNodes.ShouldBe(3000);
|
|
result.MaxBrowseDepth.ShouldBe(5);
|
|
result.ProbeTimeoutSeconds.ShouldBe(25);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #273/#275/#277/#278: the typed form rewrites the whole record on save, so a key it does not model
|
|
/// is silently dropped (#576). Every new option must survive FromRecord -> ToRecord.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FormModel_RoundTrip_PreservesTuningLimitsRevocationAndDiscovery()
|
|
{
|
|
var original = new OpcUaClientDriverOptions
|
|
{
|
|
SubscriptionPublishingInterval = TimeSpan.FromMilliseconds(2500),
|
|
SubscriptionKeepAliveCount = 20,
|
|
SubscriptionLifetimeCount = 600,
|
|
SubscriptionMaxNotificationsPerPublish = 500,
|
|
SubscriptionPriority = 9,
|
|
CertificateValidation = new OpcUaCertificateValidationOptions
|
|
{
|
|
RejectUnknownRevocationStatus = true,
|
|
IssuerStorePath = "/pki/issuers",
|
|
},
|
|
DiscoveryUrl = "opc.tcp://lds:4840",
|
|
DiscoveryServerUri = "urn:plant:line1",
|
|
};
|
|
|
|
var back = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original).ToRecord([]);
|
|
|
|
back.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
|
|
back.SubscriptionKeepAliveCount.ShouldBe(20u);
|
|
back.SubscriptionLifetimeCount.ShouldBe(600u);
|
|
back.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
|
|
back.SubscriptionPriority.ShouldBe((byte)9);
|
|
back.DiscoveryServerUri.ShouldBe("urn:plant:line1");
|
|
back.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
|
|
back.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
|
|
back.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
|
|
|
|
// Defaults round-trip as defaults (blank override stays null, not 0 ms).
|
|
var def = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()).ToRecord([]);
|
|
def.SubscriptionPublishingInterval.ShouldBeNull();
|
|
def.SubscriptionKeepAliveCount.ShouldBe(10u);
|
|
def.SubscriptionLifetimeCount.ShouldBe(1000u);
|
|
def.DiscoveryUrl.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// #816: the server host-name check is tri-state. Unset must stay unset through the form (so a later change of
|
|
/// the driver default reaches configs nobody chose for), and an explicit opt-in / NAT opt-out must survive.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void FormModel_RoundTrip_PreservesHostNameValidationTriState(bool? value)
|
|
{
|
|
var formOpts = new JsonSerializerOptions(_opts) { Converters = { new JsonStringEnumConverter() } };
|
|
var original = new OpcUaClientDriverOptions
|
|
{
|
|
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = value },
|
|
};
|
|
|
|
var back = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original).ToRecord([]);
|
|
back.CertificateValidation.ValidateServerHostName.ShouldBe(value);
|
|
|
|
var reparsed = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(
|
|
JsonSerializer.Serialize(back, formOpts), formOpts)!;
|
|
reparsed.CertificateValidation.ValidateServerHostName.ShouldBe(value);
|
|
reparsed.CertificateValidation.EffectiveValidateServerHostName.ShouldBe(value ?? false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #837: the host-name check stays opt-in (unset = off) and sits in the Security panel with help text naming
|
|
/// the NAT/IP, IPv6-zone and loose-first-label caveats. Source scan (no bUnit in this repo).
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_host_name_check_is_an_opt_in_in_the_security_panel_with_its_caveats()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
|
dir = dir.Parent;
|
|
dir.ShouldNotBeNull("could not locate the repository root");
|
|
var razor = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI",
|
|
"Components", "Shared", "Drivers", "Forms", "OpcUaClientDriverForm.razor"));
|
|
|
|
razor.ShouldContain("<option value=\"\">Default (off)</option>");
|
|
|
|
var security = razor.IndexOf("<div class=\"panel-head\">Security</div>", StringComparison.Ordinal);
|
|
var next = razor.IndexOf("<div class=\"panel-head\">", security + 1, StringComparison.Ordinal);
|
|
security.ShouldBeGreaterThan(-1);
|
|
var panel = razor[security..next];
|
|
panel.ShouldContain("_form.OpcUa.ValidateServerHostName");
|
|
panel.ShouldContain("Opt-in");
|
|
panel.ShouldContain("NAT");
|
|
panel.ShouldContain("IPv6 zone ids");
|
|
panel.ShouldContain("Loose match");
|
|
razor.Split("_form.OpcUa.ValidateServerHostName").Length.ShouldBe(2, "the toggle must be rendered once");
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
var row = OpcUaClientDriverForm.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 ");
|
|
|
|
row.Url.ShouldBe(" opc.tcp://plc:4840 ");
|
|
row.ToUrl().ShouldBe("opc.tcp://plc:4840");
|
|
}
|
|
|
|
[Fact]
|
|
public void EndpointUrlRow_ValidateRow_RejectsBlank()
|
|
{
|
|
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>();
|
|
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " " };
|
|
|
|
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
|
|
|
|
error.ShouldBe("URL is required.");
|
|
}
|
|
|
|
[Fact]
|
|
public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme()
|
|
{
|
|
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>();
|
|
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "http://plc:4840" };
|
|
|
|
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
|
|
|
|
error.ShouldBe("Endpoint URL must start with opc.tcp://");
|
|
}
|
|
|
|
[Fact]
|
|
public void EndpointUrlRow_ValidateRow_RejectsDuplicate()
|
|
{
|
|
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>
|
|
{
|
|
new() { Url = "opc.tcp://primary:4840" },
|
|
new() { Url = "opc.tcp://backup:4840" },
|
|
};
|
|
// Adding a new row (editIndex null) duplicating the first — case-insensitive, whitespace-insensitive.
|
|
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " };
|
|
|
|
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
|
|
|
|
error.ShouldNotBeNull();
|
|
error.ShouldContain("Duplicate endpoint");
|
|
}
|
|
|
|
[Fact]
|
|
public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace()
|
|
{
|
|
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>
|
|
{
|
|
new() { Url = "opc.tcp://primary:4840" },
|
|
new() { Url = "opc.tcp://backup:4840" },
|
|
};
|
|
// Editing index 0 and keeping the same URL must not flag itself as a duplicate.
|
|
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "opc.tcp://primary:4840" };
|
|
|
|
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, 0);
|
|
|
|
error.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void EndpointUrls_ListRoundTrip_PreservesOrder()
|
|
{
|
|
// The page holds endpoints as a List<EndpointUrlRow>; loading from EndpointUrls and
|
|
// converting back must preserve order (the failover list is ordered, primary first).
|
|
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" };
|
|
|
|
var rows = endpointUrls
|
|
.Select(OpcUaClientDriverForm.EndpointUrlRow.FromUrl)
|
|
.ToList();
|
|
var roundTripped = rows.Select(r => r.ToUrl()).ToList();
|
|
|
|
roundTripped.Count.ShouldBe(3);
|
|
roundTripped[0].ShouldBe("opc.tcp://primary:4840");
|
|
roundTripped[1].ShouldBe("opc.tcp://secondary:4840");
|
|
roundTripped[2].ShouldBe("opc.tcp://tertiary:4840");
|
|
|
|
var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions());
|
|
var result = form.ToRecord(roundTripped);
|
|
result.EndpointUrls.Count.ShouldBe(3);
|
|
result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840");
|
|
result.EndpointUrls[2].ShouldBe("opc.tcp://tertiary:4840");
|
|
}
|
|
}
|