271b96a644
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges the stored DriverConfig with the typed device config). The server (DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and BrowserSessionService) honours the named owner only when every endpoint value (new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver stores; otherwise the owner is null and its instance-named secrets are refused. Every Test Connect / browse whose config carries an in-scope secret:/env: reference is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a structured Information line: user, driver, endpoint hosts, outcome). L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets AuthType to non-Certificate (string or ordinal); the deploy gate also checks the runtime-merged driver+device config so the switch cannot be split from the path; the form no longer persists the hidden field. L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open failures into its error message. L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory. Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
14 KiB
C#
325 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");
|
|
// #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>
|
|
/// #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");
|
|
}
|
|
}
|