e3a27422a1
v2-ci / build (push) Failing after 39s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
GalaxyDriverPage deserialized DriverConfig with case-sensitive camelCase opts, but the persisted/seeded config is PascalCase (the runtime reads it case-insensitively). So all four nested option records read as null -> FromRecord NRE (HTTP 500) on edit, and the form would have shown defaults instead of the real config (risking a clobber on save). Fix: add PropertyNameCaseInsensitive=true (matches the runtime) so real values load, plus null-coalesce the nested records in FromRecord as defense-in-depth. Regression test asserts the seeded PascalCase config loads its real values.
238 lines
9.6 KiB
C#
238 lines
9.6 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
|
|
|
public sealed class GalaxyDriverPageFormSerializationTests
|
|
{
|
|
// Matches GalaxyDriverPage._jsonOpts (camelCase, no PropertyNameCaseInsensitive).
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false,
|
|
};
|
|
|
|
// Matches the page's _jsonOpts exactly: camelCase + case-insensitive read + UnmappedMemberHandling.Skip.
|
|
private static readonly JsonSerializerOptions _pageOpts = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
WriteIndented = false,
|
|
};
|
|
|
|
[Fact]
|
|
public void RoundTrip_PreservesKnownFields()
|
|
{
|
|
var original = new GalaxyDriverOptions(
|
|
Gateway: new GalaxyGatewayOptions(
|
|
Endpoint: "https://gw.internal:5001",
|
|
ApiKeySecretRef: "env:MY_API_KEY",
|
|
UseTls: true,
|
|
CaCertificatePath: "C:\\certs\\ca.pem",
|
|
ConnectTimeoutSeconds: 15,
|
|
DefaultCallTimeoutSeconds: 45,
|
|
StreamTimeoutSeconds: 0),
|
|
MxAccess: new GalaxyMxAccessOptions(
|
|
ClientName: "OtOpcUa-Primary",
|
|
PublishingIntervalMs: 500,
|
|
WriteUserId: 1,
|
|
EventPumpChannelCapacity: 100_000),
|
|
Repository: new GalaxyRepositoryOptions(
|
|
DiscoverPageSize: 2000,
|
|
WatchDeployEvents: false),
|
|
Reconnect: new GalaxyReconnectOptions(
|
|
InitialBackoffMs: 1000,
|
|
MaxBackoffMs: 60_000,
|
|
ReplayOnSessionLost: false))
|
|
{
|
|
ProbeTimeoutSeconds = 45,
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(original, _opts);
|
|
var back = JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _opts);
|
|
|
|
back.ShouldNotBeNull();
|
|
back.Gateway.Endpoint.ShouldBe("https://gw.internal:5001");
|
|
back.Gateway.ApiKeySecretRef.ShouldBe("env:MY_API_KEY");
|
|
back.Gateway.UseTls.ShouldBeTrue();
|
|
back.Gateway.CaCertificatePath.ShouldBe("C:\\certs\\ca.pem");
|
|
back.Gateway.ConnectTimeoutSeconds.ShouldBe(15);
|
|
back.Gateway.DefaultCallTimeoutSeconds.ShouldBe(45);
|
|
back.Gateway.StreamTimeoutSeconds.ShouldBe(0);
|
|
back.MxAccess.ClientName.ShouldBe("OtOpcUa-Primary");
|
|
back.MxAccess.PublishingIntervalMs.ShouldBe(500);
|
|
back.MxAccess.WriteUserId.ShouldBe(1);
|
|
back.MxAccess.EventPumpChannelCapacity.ShouldBe(100_000);
|
|
back.Repository.DiscoverPageSize.ShouldBe(2000);
|
|
back.Repository.WatchDeployEvents.ShouldBeFalse();
|
|
back.Reconnect.InitialBackoffMs.ShouldBe(1000);
|
|
back.Reconnect.MaxBackoffMs.ShouldBe(60_000);
|
|
back.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
|
|
back.ProbeTimeoutSeconds.ShouldBe(45);
|
|
}
|
|
|
|
[Fact]
|
|
public void Deserialize_DropsUnknownFields()
|
|
{
|
|
// Minimal JSON that sets only ProbeTimeoutSeconds and an unknown field.
|
|
// The nested records must supply their required positional args for JSON deserialization
|
|
// to succeed — provide them here so the test exercises the Unknown-field skip path.
|
|
var jsonWithExtra = """
|
|
{
|
|
"unknownField": "old-value",
|
|
"gateway": { "endpoint": "https://localhost:5001", "apiKeySecretRef": "dev:test" },
|
|
"mxAccess": { "clientName": "OtOpcUa" },
|
|
"repository": {},
|
|
"reconnect": {},
|
|
"probeTimeoutSeconds": 20
|
|
}
|
|
""";
|
|
|
|
var optsWithSkip = new JsonSerializerOptions(_opts)
|
|
{
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
};
|
|
|
|
var back = JsonSerializer.Deserialize<GalaxyDriverOptions>(jsonWithExtra, optsWithSkip);
|
|
back.ShouldNotBeNull();
|
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
|
back.Gateway.Endpoint.ShouldBe("https://localhost:5001");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression test: the seed SQL stores PascalCase JSON. With
|
|
/// <c>PropertyNameCaseInsensitive = true</c> the page must read the real values, not
|
|
/// fall back to defaults. FAILS against case-sensitive opts; PASSES with the fix.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Seeded_pascalcase_config_loads_real_values()
|
|
{
|
|
// Exact JSON from docker-dev/seed/seed-clusters.sql (lines 130-151).
|
|
var seededJson = """
|
|
{
|
|
"Gateway": {
|
|
"Endpoint": "http://10.100.0.48:5120",
|
|
"ApiKeySecretRef": "env:GALAXY_MXGW_API_KEY",
|
|
"UseTls": false,
|
|
"ConnectTimeoutSeconds": 10,
|
|
"DefaultCallTimeoutSeconds": 30
|
|
},
|
|
"MxAccess": {
|
|
"ClientName": "OtOpcUa-MAIN-docker-dev",
|
|
"PublishingIntervalMs": 1000
|
|
},
|
|
"Repository": {
|
|
"DiscoverPageSize": 5000,
|
|
"WatchDeployEvents": true
|
|
},
|
|
"Reconnect": {
|
|
"InitialBackoffMs": 500,
|
|
"MaxBackoffMs": 30000,
|
|
"ReplayOnSessionLost": true
|
|
}
|
|
}
|
|
""";
|
|
|
|
// Deserialize with page-mirrored opts (camelCase + case-insensitive, as fixed).
|
|
var driverOpts = JsonSerializer.Deserialize<GalaxyDriverOptions>(seededJson, _pageOpts);
|
|
driverOpts.ShouldNotBeNull();
|
|
|
|
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!);
|
|
|
|
// Assert REAL seeded values — not defaults.
|
|
form.GatewayEndpoint.ShouldBe("http://10.100.0.48:5120");
|
|
form.GatewayApiKeySecretRef.ShouldBe("env:GALAXY_MXGW_API_KEY");
|
|
form.GatewayUseTls.ShouldBeFalse();
|
|
form.MxClientName.ShouldBe("OtOpcUa-MAIN-docker-dev");
|
|
form.RepositoryDiscoverPageSize.ShouldBe(5000);
|
|
form.ReconnectInitialBackoffMs.ShouldBe(500);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all)
|
|
/// must not throw — <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> must
|
|
/// null-coalesce the missing section to its default value.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromRecord_with_omitted_section_uses_defaults()
|
|
{
|
|
// Only gateway section present — Reconnect intentionally absent.
|
|
var partialJson = """
|
|
{
|
|
"gateway": {
|
|
"endpoint": "opc://x",
|
|
"apiKeySecretRef": "env:K"
|
|
}
|
|
}
|
|
""";
|
|
|
|
var driverOpts = JsonSerializer.Deserialize<GalaxyDriverOptions>(partialJson, _pageOpts);
|
|
driverOpts.ShouldNotBeNull();
|
|
|
|
// FromRecord must not throw even though Reconnect (and other sections) is null.
|
|
var form = Should.NotThrow(() => GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!));
|
|
|
|
// Omitted Reconnect section falls back to GalaxyReconnectOptions() defaults.
|
|
var defaultRc = new GalaxyReconnectOptions();
|
|
form.ReconnectInitialBackoffMs.ShouldBe(defaultRc.InitialBackoffMs);
|
|
form.ReconnectMaxBackoffMs.ShouldBe(defaultRc.MaxBackoffMs);
|
|
form.ReconnectReplayOnSessionLost.ShouldBe(defaultRc.ReplayOnSessionLost);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> still
|
|
/// round-trips correctly when all nested records are populated (non-regressed path).
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromRecord_with_fully_populated_options_round_trips()
|
|
{
|
|
var original = new GalaxyDriverOptions(
|
|
Gateway: new GalaxyGatewayOptions(
|
|
Endpoint: "https://gw.example.com:5001",
|
|
ApiKeySecretRef: "env:MY_KEY",
|
|
UseTls: true,
|
|
CaCertificatePath: null,
|
|
ConnectTimeoutSeconds: 12,
|
|
DefaultCallTimeoutSeconds: 40,
|
|
StreamTimeoutSeconds: 0),
|
|
MxAccess: new GalaxyMxAccessOptions(
|
|
ClientName: "OtOpcUa-Test",
|
|
PublishingIntervalMs: 750,
|
|
WriteUserId: 2,
|
|
EventPumpChannelCapacity: 25_000),
|
|
Repository: new GalaxyRepositoryOptions(
|
|
DiscoverPageSize: 3000,
|
|
WatchDeployEvents: false),
|
|
Reconnect: new GalaxyReconnectOptions(
|
|
InitialBackoffMs: 800,
|
|
MaxBackoffMs: 45_000,
|
|
ReplayOnSessionLost: false))
|
|
{
|
|
ProbeTimeoutSeconds = 20,
|
|
};
|
|
|
|
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(original);
|
|
|
|
form.GatewayEndpoint.ShouldBe("https://gw.example.com:5001");
|
|
form.GatewayApiKeySecretRef.ShouldBe("env:MY_KEY");
|
|
form.GatewayUseTls.ShouldBeTrue();
|
|
form.GatewayConnectTimeoutSeconds.ShouldBe(12);
|
|
form.GatewayDefaultCallTimeoutSeconds.ShouldBe(40);
|
|
form.MxClientName.ShouldBe("OtOpcUa-Test");
|
|
form.MxPublishingIntervalMs.ShouldBe(750);
|
|
form.MxWriteUserId.ShouldBe(2);
|
|
form.MxEventPumpChannelCapacity.ShouldBe(25_000);
|
|
form.RepositoryDiscoverPageSize.ShouldBe(3000);
|
|
form.RepositoryWatchDeployEvents.ShouldBeFalse();
|
|
form.ReconnectInitialBackoffMs.ShouldBe(800);
|
|
form.ReconnectMaxBackoffMs.ShouldBe(45_000);
|
|
form.ReconnectReplayOnSessionLost.ShouldBeFalse();
|
|
form.ProbeTimeoutSeconds.ShouldBe(20);
|
|
}
|
|
}
|