Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
T
Joseph Doherty 4e492e6550 fix(galaxy): confirm unconfirmed native alarm acks against Galaxy's acknowledged transition (#869)
mxaccessgw answers AcknowledgeAlarm OK both when the AckMsg write was
applied and when it was only sent (no OnWriteComplete; the reply then
carries a diagnostic). EvaluateReply treated both as success, so the H6
live gate saw a condition go Acknowledged while Galaxy never applied it.

- EvaluateReply returns Applied (no diagnostic) or Unconfirmed (diagnostic)
  and logs the gateway diagnostic on every branch that carries one.
- GalaxyAckConfirmationTracker watches the alarm feed from before the RPC
  and, for an unconfirmed reply, waits up to AlarmAck.ConfirmTimeoutMs
  (default 10 s, 0 = old behaviour) for an Acknowledge/ActiveAcknowledged
  transition; on timeout it refuses (BadTimeout + diagnostic).
- New optional IAlarmAcknowledgeConfirmation: DriverInstanceActor awaits it
  after AcknowledgeAsync, off its mailbox and outside the resilience
  pipeline; a failure is the existing #760 AlarmAckFailed revert.
- AdminUI Galaxy form carries the new knob; docs updated.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 13:03:04 -05:00

289 lines
12 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
public sealed class GalaxyDriverPageFormSerializationTests
{
// Matches GalaxyDriverForm._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 = GalaxyDriverForm.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="GalaxyDriverForm.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(() => GalaxyDriverForm.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="GalaxyDriverForm.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 = GalaxyDriverForm.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);
}
/// <summary>
/// lmxopcua#749: the form must carry the AlarmAck fallback through FromRecord/ToRecord, or saving the
/// Galaxy driver page would silently drop the fallback account and every anonymous ack would refuse.
/// </summary>
[Fact]
public void FormModel_RoundTrips_AlarmAckFallback()
{
var original = new GalaxyDriverOptions(
new GalaxyGatewayOptions("https://gw:5001", "env:K"),
new GalaxyMxAccessOptions("OtOpcUa"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions())
{
AlarmAck = new GalaxyAlarmAckOptions("ack-svc", "PLANT", "secret:otopcua/galaxy/ack"),
};
var back = GalaxyDriverForm.GalaxyFormModel.FromRecord(original).ToRecord();
back.AlarmAck.ShouldBe(original.AlarmAck);
var json = JsonSerializer.Serialize(back, _pageOpts);
json.ShouldContain("\"fallbackPasswordSecretRef\":\"secret:otopcua/galaxy/ack\"");
json.ShouldNotContain("hasFallback");
JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _pageOpts)!.AlarmAck.ShouldBe(original.AlarmAck);
}
/// <summary>
/// lmxopcua#869: the ack-confirmation window must survive FromRecord/ToRecord and JSON, or saving the page
/// would silently reset an operator's setting (e.g. the 0 = do-not-wait opt-out) to the default.
/// </summary>
[Fact]
public void FormModel_RoundTrips_AlarmAckConfirmTimeout()
{
var original = new GalaxyDriverOptions(
new GalaxyGatewayOptions("https://gw:5001", "env:K"),
new GalaxyMxAccessOptions("OtOpcUa"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions())
{
AlarmAck = new GalaxyAlarmAckOptions { ConfirmTimeoutMs = 0 },
};
var back = GalaxyDriverForm.GalaxyFormModel.FromRecord(original).ToRecord();
back.AlarmAck.ConfirmTimeoutMs.ShouldBe(0);
var json = JsonSerializer.Serialize(back, _pageOpts);
json.ShouldContain("\"confirmTimeoutMs\":0");
json.ShouldNotContain("\"confirmTimeout\":");
JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _pageOpts)!.AlarmAck.ConfirmTimeoutMs.ShouldBe(0);
new GalaxyDriverForm.GalaxyFormModel().AckConfirmTimeoutMs.ShouldBe(GalaxyAlarmAckOptions.DefaultConfirmTimeoutMs);
}
}