diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor
index 5c3b3e71..18c3ef35 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor
@@ -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(
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
index 77b4c70d..001fdcc8 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
@@ -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");
}
+
+ ///
+ /// Regression test: the seed SQL stores PascalCase JSON. With
+ /// PropertyNameCaseInsensitive = true the page must read the real values, not
+ /// fall back to defaults. FAILS against case-sensitive opts; PASSES with the fix.
+ ///
+ [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(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);
+ }
+
+ ///
+ /// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all)
+ /// must not throw — must
+ /// null-coalesce the missing section to its default value.
+ ///
+ [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(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);
+ }
+
+ ///
+ /// Confirms that still
+ /// round-trips correctly when all nested records are populated (non-regressed path).
+ ///
+ [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);
+ }
}