9a87d7a328
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.
Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.
Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.
Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.
Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.
Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.
Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
171 lines
6.9 KiB
C#
171 lines
6.9 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
|
|
|
/// <summary>
|
|
/// WP2.1b — the MxGateway adapter's batch subscribe seam and the alarm-stream union
|
|
/// filter. The plain-vs-supervisory advise choice itself lives inside
|
|
/// <c>RealMxGatewayClient</c> (it needs a live MXAccess session); its bounded-pipeline
|
|
/// mechanism is pinned separately by <see cref="BatchSeamPrimitiveTests"/>.
|
|
/// </summary>
|
|
[Collection("DataConnectionManagerActor")]
|
|
public class MxGatewayBatchSeamTests
|
|
{
|
|
private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) =>
|
|
new(fake, NullLogger<MxGatewayDataConnection>.Instance);
|
|
|
|
private static Dictionary<string, string> Details() => new()
|
|
{
|
|
["Endpoint"] = "http://gw:5000",
|
|
["ApiKey"] = "key",
|
|
["ClientName"] = "client-a",
|
|
["WriteUserId"] = "0",
|
|
["ReadTimeoutMs"] = "2000",
|
|
};
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task UnsubscribeOldHandle_PreservesNewHandleRouting(bool batch)
|
|
{
|
|
var fake = new FakeMxGatewayClient();
|
|
await using var adapter = NewAdapter(fake);
|
|
await adapter.ConnectAsync(Details());
|
|
var received = new List<object?>();
|
|
var old = await adapter.SubscribeAsync("A.x", (_, value) => received.Add(value.Value));
|
|
await adapter.SubscribeAsync("A.x", (_, value) => received.Add(value.Value));
|
|
if (batch) await adapter.UnsubscribeBatchAsync([old]);
|
|
else await adapter.UnsubscribeAsync(old);
|
|
await WaitUntilAsync(() => fake.OnUpdate != null);
|
|
fake.OnUpdate!(new MxValueUpdate("A.x", 7, QualityCode.Good, DateTimeOffset.UtcNow));
|
|
Assert.Equal([7], received);
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
while (!condition()) await Task.Delay(10, timeout.Token);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubscribeBatch_IssuesExactlyOneBulkCallPerBatch()
|
|
{
|
|
var fake = new FakeMxGatewayClient();
|
|
var adapter = NewAdapter(fake);
|
|
await adapter.ConnectAsync(Details());
|
|
|
|
var results = await adapter.SubscribeBatchAsync(
|
|
["A.x", "A.y", "A.z"], (_, _) => { });
|
|
|
|
// ONE bulk RPC for the whole batch — replacing the historical AddItem + Advise
|
|
// pair PER TAG (6 RPCs for these three tags).
|
|
Assert.Single(fake.BulkSubscribeCalls);
|
|
Assert.Equal(3, fake.BulkSubscribeCalls[0].Count);
|
|
Assert.Equal(3, results.Count);
|
|
Assert.All(results, r => Assert.True(r.Success));
|
|
Assert.All(results, r => Assert.NotNull(r.SubscriptionId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubscribeBatch_ReportsPerTagFailuresWithoutFailingTheBatch()
|
|
{
|
|
var fake = new FakeMxGatewayClient();
|
|
fake.BulkSubscribeFailures.Add("A.y");
|
|
var adapter = NewAdapter(fake);
|
|
await adapter.ConnectAsync(Details());
|
|
|
|
var results = await adapter.SubscribeBatchAsync(["A.x", "A.y"], (_, _) => { });
|
|
|
|
Assert.True(results.Single(r => r.TagPath == "A.x").Success);
|
|
var failed = results.Single(r => r.TagPath == "A.y");
|
|
Assert.False(failed.Success);
|
|
Assert.Null(failed.SubscriptionId);
|
|
Assert.NotNull(failed.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribeBatch_ReleasesEveryIdInOneBulkCall()
|
|
{
|
|
var fake = new FakeMxGatewayClient();
|
|
var adapter = NewAdapter(fake);
|
|
await adapter.ConnectAsync(Details());
|
|
|
|
var results = await adapter.SubscribeBatchAsync(["A.x", "A.y"], (_, _) => { });
|
|
await adapter.UnsubscribeBatchAsync(results.Select(r => r.SubscriptionId!).ToList());
|
|
|
|
Assert.Single(fake.BulkUnsubscribeCalls);
|
|
Assert.Equal(2, fake.BulkUnsubscribeCalls[0].Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AlarmStream_UsesLongestCommonPrefix_AndRefreshesEveryNewSource()
|
|
{
|
|
var fake = new FakeMxGatewayClient();
|
|
var adapter = NewAdapter(fake);
|
|
await adapter.ConnectAsync(Details());
|
|
|
|
void NoTransition(NativeAlarmTransition _) { }
|
|
|
|
// First source → the stream opens scoped to it.
|
|
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, NoTransition);
|
|
await WaitForPrefixCountAsync(fake, 1);
|
|
Assert.Equal("Area1.Tank1", fake.AlarmStreamPrefixes[0]);
|
|
|
|
// A sibling under the same prefix widens the union → one restart on "Area1.Tank".
|
|
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, NoTransition);
|
|
await WaitForPrefixCountAsync(fake, 2);
|
|
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]);
|
|
|
|
// A covered new source still needs its standing-state snapshot.
|
|
await adapter.SubscribeAlarmsAsync("Area1.Tank3.Sub", null, NoTransition);
|
|
await WaitForPrefixCountAsync(fake, 3);
|
|
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[2]);
|
|
|
|
// A source outside the prefix widens it again — here down to gateway-wide.
|
|
await adapter.SubscribeAlarmsAsync("Zone9.Pump", null, NoTransition);
|
|
await WaitForPrefixCountAsync(fake, 4);
|
|
Assert.Null(fake.AlarmStreamPrefixes[3]); // "" → gateway-wide
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AlarmStream_UnsubscribeNeverRestartsTheStream()
|
|
{
|
|
var fake = new FakeMxGatewayClient();
|
|
var adapter = NewAdapter(fake);
|
|
await adapter.ConnectAsync(Details());
|
|
|
|
var first = await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
|
|
// Wait for the first stream to actually open before widening: the open runs on a
|
|
// Task.Run whose token the restart cancels, so a restart racing an unstarted task
|
|
// would leave the first prefix unrecorded.
|
|
await WaitForPrefixCountAsync(fake, 1);
|
|
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, _ => { });
|
|
await WaitForPrefixCountAsync(fake, 2);
|
|
|
|
// Dropping a source leaves the prefix too BROAD at worst — bandwidth, not
|
|
// correctness — so no restart is issued.
|
|
await adapter.UnsubscribeAlarmsAsync(first);
|
|
await Task.Delay(100);
|
|
Assert.Equal(2, fake.AlarmStreamPrefixes.Count);
|
|
}
|
|
|
|
private static async Task WaitForPrefixCountAsync(FakeMxGatewayClient fake, int expected)
|
|
{
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
lock (fake.AlarmStreamPrefixes)
|
|
{
|
|
if (fake.AlarmStreamPrefixes.Count >= expected)
|
|
return;
|
|
}
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
Assert.Fail($"alarm stream was opened {fake.AlarmStreamPrefixes.Count} time(s), expected {expected}");
|
|
}
|
|
}
|