Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs
T
Joseph Doherty ae91e6b7f7 feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)
#816 finding: the SDK checks that the server certificate names the host only
when a session is created with checkDomain:true. The driver, the AdminUI
browser and Client.Shared all called the 8-argument
DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no
host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine
over an IP in #813). When enabled the SDK (Session.OpenAsync ->
ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost
— the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector
rewrote the advertised one — against the cert's DNS/IP SANs; secured
sessions only, and the auto-accept handler accepts a mismatch.

Policy: certificateValidation.validateServerHostName, tri-state. Unset =
OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the
pre-#816 behaviour, so nothing changes on upgrade; true = validate the
configured host; false = explicit NAT/port-publish opt-out. Threaded into the
driver, the AdminUI browser (same JSON) and the AdminUI form (a select that
keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName
and the CLI --validate-host-name. A driver refusal names the opt-out.

#818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified:
same thumbprint across --force-recreate), its PUBLIC cert is committed as
Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run
every secure browse on a throwaway PKI (internal browser seam): refusal with
nothing trusted, positive with the cert trusted (unset/false host check), and
a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is
OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch);
OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT
container's public cert. ServerHostNameValidationTests pin the driver against
the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in
v2-integration (440 -> 446), documented in scripts/ci/README.md.
2026-11-11 01:33:00 -05:00

322 lines
14 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");
result.UserCertificatePath.ShouldBe(@"C:\certs\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>
/// #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");
}
}