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); + } }