fix(adminui): Galaxy editor 500 — read DriverConfig case-insensitively + null-safe FromRecord
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
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.
This commit is contained in:
+30
-19
@@ -215,6 +215,7 @@ else
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
};
|
||||
@@ -408,26 +409,36 @@ else
|
||||
// GalaxyDriverOptions top-level
|
||||
public int ProbeTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r) => new()
|
||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||
{
|
||||
GatewayEndpoint = r.Gateway.Endpoint,
|
||||
GatewayApiKeySecretRef = r.Gateway.ApiKeySecretRef,
|
||||
GatewayUseTls = r.Gateway.UseTls,
|
||||
GatewayCaCertificatePath = r.Gateway.CaCertificatePath,
|
||||
GatewayConnectTimeoutSeconds = r.Gateway.ConnectTimeoutSeconds,
|
||||
GatewayDefaultCallTimeoutSeconds = r.Gateway.DefaultCallTimeoutSeconds,
|
||||
GatewayStreamTimeoutSeconds = r.Gateway.StreamTimeoutSeconds,
|
||||
MxClientName = r.MxAccess.ClientName,
|
||||
MxPublishingIntervalMs = r.MxAccess.PublishingIntervalMs,
|
||||
MxWriteUserId = r.MxAccess.WriteUserId,
|
||||
MxEventPumpChannelCapacity = r.MxAccess.EventPumpChannelCapacity,
|
||||
RepositoryDiscoverPageSize = r.Repository.DiscoverPageSize,
|
||||
RepositoryWatchDeployEvents = r.Repository.WatchDeployEvents,
|
||||
ReconnectInitialBackoffMs = r.Reconnect.InitialBackoffMs,
|
||||
ReconnectMaxBackoffMs = r.Reconnect.MaxBackoffMs,
|
||||
ReconnectReplayOnSessionLost = r.Reconnect.ReplayOnSessionLost,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
};
|
||||
// Null-coalesce each nested record to its default so that persisted configs
|
||||
// that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
|
||||
// don't match the camelCase deserializer) don't cause a NullReferenceException.
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||
return new()
|
||||
{
|
||||
GatewayEndpoint = gw.Endpoint,
|
||||
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
|
||||
GatewayUseTls = gw.UseTls,
|
||||
GatewayCaCertificatePath = gw.CaCertificatePath,
|
||||
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
|
||||
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
|
||||
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
|
||||
MxClientName = mx.ClientName,
|
||||
MxPublishingIntervalMs = mx.PublishingIntervalMs,
|
||||
MxWriteUserId = mx.WriteUserId,
|
||||
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
|
||||
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
|
||||
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
|
||||
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
|
||||
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
||||
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
public GalaxyDriverOptions ToRecord() => new(
|
||||
Gateway: new GalaxyGatewayOptions(
|
||||
|
||||
+142
@@ -2,18 +2,29 @@ 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()
|
||||
{
|
||||
@@ -92,4 +103,135 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user