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()
|
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
|
||||||
WriteIndented = false,
|
WriteIndented = false,
|
||||||
};
|
};
|
||||||
@@ -408,26 +409,36 @@ else
|
|||||||
// GalaxyDriverOptions top-level
|
// GalaxyDriverOptions top-level
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 30;
|
public int ProbeTimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r) => new()
|
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||||
{
|
{
|
||||||
GatewayEndpoint = r.Gateway.Endpoint,
|
// Null-coalesce each nested record to its default so that persisted configs
|
||||||
GatewayApiKeySecretRef = r.Gateway.ApiKeySecretRef,
|
// that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
|
||||||
GatewayUseTls = r.Gateway.UseTls,
|
// don't match the camelCase deserializer) don't cause a NullReferenceException.
|
||||||
GatewayCaCertificatePath = r.Gateway.CaCertificatePath,
|
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||||
GatewayConnectTimeoutSeconds = r.Gateway.ConnectTimeoutSeconds,
|
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||||
GatewayDefaultCallTimeoutSeconds = r.Gateway.DefaultCallTimeoutSeconds,
|
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||||
GatewayStreamTimeoutSeconds = r.Gateway.StreamTimeoutSeconds,
|
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||||
MxClientName = r.MxAccess.ClientName,
|
return new()
|
||||||
MxPublishingIntervalMs = r.MxAccess.PublishingIntervalMs,
|
{
|
||||||
MxWriteUserId = r.MxAccess.WriteUserId,
|
GatewayEndpoint = gw.Endpoint,
|
||||||
MxEventPumpChannelCapacity = r.MxAccess.EventPumpChannelCapacity,
|
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
|
||||||
RepositoryDiscoverPageSize = r.Repository.DiscoverPageSize,
|
GatewayUseTls = gw.UseTls,
|
||||||
RepositoryWatchDeployEvents = r.Repository.WatchDeployEvents,
|
GatewayCaCertificatePath = gw.CaCertificatePath,
|
||||||
ReconnectInitialBackoffMs = r.Reconnect.InitialBackoffMs,
|
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
|
||||||
ReconnectMaxBackoffMs = r.Reconnect.MaxBackoffMs,
|
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
|
||||||
ReconnectReplayOnSessionLost = r.Reconnect.ReplayOnSessionLost,
|
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
|
||||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
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(
|
public GalaxyDriverOptions ToRecord() => new(
|
||||||
Gateway: new GalaxyGatewayOptions(
|
Gateway: new GalaxyGatewayOptions(
|
||||||
|
|||||||
+142
@@ -2,18 +2,29 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
|
|
||||||
public sealed class GalaxyDriverPageFormSerializationTests
|
public sealed class GalaxyDriverPageFormSerializationTests
|
||||||
{
|
{
|
||||||
|
// Matches GalaxyDriverPage._jsonOpts (camelCase, no PropertyNameCaseInsensitive).
|
||||||
private static readonly JsonSerializerOptions _opts = new()
|
private static readonly JsonSerializerOptions _opts = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
WriteIndented = false,
|
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]
|
[Fact]
|
||||||
public void RoundTrip_PreservesKnownFields()
|
public void RoundTrip_PreservesKnownFields()
|
||||||
{
|
{
|
||||||
@@ -92,4 +103,135 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
|||||||
back.ProbeTimeoutSeconds.ShouldBe(20);
|
back.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
back.Gateway.Endpoint.ShouldBe("https://localhost:5001");
|
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