Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs
T
Joseph Doherty dc0a24f224
v2-ci / unit-tests (push) Failing after 1h8m43s
feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:

1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
   held ONLY in the encrypted Secrets store
   (otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
   no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
   FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
   env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
   operator_user. Refusals are excluded from the resilience retry/breaker.

- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
  nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
  RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
  is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
  ("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
  account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
  acknowledger scrubs it from every gateway message it echoes and withholds
  any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
  Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
  path is fire-and-forget after the Part 9 method returns).

Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.

Closes #749
2026-11-06 16:23:00 -05:00

263 lines
11 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);
}
}