fd618cf1dc
Remediation from the full per-module code review at 4307c381 (findings recorded
separately in code-reviews/).
Highs fixed:
- DeploymentManager-025/SiteRuntime-031: stop broadcasting notification lists + SMTP
configs (incl. credentials) to sites; site purges already-persisted rows on apply
(enforces the central-only delivery design; clears plaintext SMTP creds at rest).
- DataConnectionLayer-023: guard the native-alarm subscribe path against the
mid-flight-unsubscribe adapter-feed leak (mirrors the DCL-021 tag-path fix).
- SiteEventLogging-024: normalize From/To query bounds to UTC (the -016 fix the
audit trail claimed but never committed).
- KpiHistory-001: add an in-flight guard to the recorder sample tick.
- ScriptAnalysis-001: harden the trust analyzer's TPA-absent fallback (resolve
forbidden anchors in the minimal reference set; warn on degraded mode) — anchors
added to validation references only, never the compile gate.
(InboundAPI-026 left to the feat/ipsen-movein effort per owner decision.)
Medium/Low: DM-026 deterministic deploy-status tiebreaker; SR-027/028/029/030
native-alarm leak/phantom-active/delete-during-redeploy fixes; AL-013/014/016;
TE-024 (folder-mutation audit rows now persisted)/025; SF-025 gauge-provider
clear-on-stop; ESG-025/026; SEC-023/024/025; SCA-007/008/009; plus doc/test
accuracy COM-023/024, HOST-025/026, HM-024/025, NS-027/028.
Full-solution build 0 warnings; ~3560 tests across 18 touched suites green.
382 lines
18 KiB
C#
382 lines
18 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// Task 15: NativeAlarmActor mirrors a source's native alarms — subscribe on start,
|
|
/// emit enriched AlarmStateChanged, snapshot atomic swap, retention, persistence.
|
|
/// </summary>
|
|
public class NativeAlarmActorTests : TestKit, IDisposable
|
|
{
|
|
private readonly string _dbFile;
|
|
private readonly SiteStorageService _storage;
|
|
private readonly SiteRuntimeOptions _options = new();
|
|
|
|
public NativeAlarmActorTests()
|
|
{
|
|
_dbFile = Path.Combine(Path.GetTempPath(), $"naa-{Guid.NewGuid():N}.db");
|
|
_storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger<SiteStorageService>.Instance);
|
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
|
}
|
|
|
|
private static ResolvedNativeAlarmSource Source() => new()
|
|
{
|
|
CanonicalName = "Pressure",
|
|
ConnectionName = "Opc",
|
|
SourceReference = "ns=2;s=T01"
|
|
};
|
|
|
|
private static NativeAlarmTransition Transition(
|
|
string sourceRef, AlarmTransitionKind kind, AlarmConditionState condition, DateTimeOffset? time = null) =>
|
|
new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition,
|
|
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
|
|
|
|
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, IServiceProvider? serviceProvider = null) =>
|
|
Spawn(instanceActor, dclManager, _options, serviceProvider);
|
|
|
|
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, SiteRuntimeOptions options,
|
|
IServiceProvider? serviceProvider = null) =>
|
|
ActorOf(Props.Create(() => new NativeAlarmActor(
|
|
Source(), "inst", instanceActor, dclManager, _storage, options,
|
|
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
|
|
|
|
[Fact]
|
|
public void SubscribeOnStart_SendsRequestForSourceBinding()
|
|
{
|
|
var dcl = CreateTestProbe();
|
|
Spawn(CreateTestProbe().Ref, dcl.Ref);
|
|
|
|
var req = dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
Assert.Equal("inst", req.InstanceUniqueName);
|
|
Assert.Equal("Opc", req.ConnectionName);
|
|
Assert.Equal("ns=2;s=T01", req.SourceReference);
|
|
}
|
|
|
|
[Fact]
|
|
public void Raise_EmitsEnrichedAlarmStateChanged()
|
|
{
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref);
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
|
|
var emitted = instance.ExpectMsg<AlarmStateChanged>();
|
|
Assert.Equal(AlarmKind.NativeOpcUa, emitted.Kind);
|
|
Assert.Equal("T01.Hi", emitted.SourceReference);
|
|
Assert.Equal(AlarmState.Active, emitted.State);
|
|
Assert.Equal(800, emitted.Condition.Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Raise_StampsNativeSourceCanonicalName_FromConfiguredBinding()
|
|
{
|
|
// DV-1: every emitted condition carries the canonical name of the source
|
|
// BINDING it belongs to, so the DebugView can nest live native conditions
|
|
// under their configured binding node. It must equal the binding's
|
|
// CanonicalName used to construct the actor (Source().CanonicalName).
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref);
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
|
|
var emitted = instance.ExpectMsg<AlarmStateChanged>();
|
|
Assert.Equal(Source().CanonicalName, emitted.NativeSourceCanonicalName);
|
|
Assert.Equal("Pressure", emitted.NativeSourceCanonicalName);
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal()
|
|
{
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref);
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
|
|
|
// A fresh snapshot that no longer contains T01.Hi means it cleared at the source.
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.SnapshotComplete,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
|
|
var cleared = instance.ExpectMsg<AlarmStateChanged>();
|
|
Assert.Equal("T01.Hi", cleared.SourceReference);
|
|
Assert.Equal(AlarmState.Normal, cleared.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void OlderTransition_IsIgnored()
|
|
{
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref);
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
var t0 = DateTimeOffset.UtcNow;
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
|
|
|
// An out-of-order (older) transition must not overwrite newer state.
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Clear,
|
|
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(-30))));
|
|
|
|
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
// ── M1.5: site event log `alarm` category ──────────────────────────────
|
|
|
|
[Fact]
|
|
public void Raise_EmitsAlarmSiteEvent()
|
|
{
|
|
var siteLog = new FakeSiteEventLogger();
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
var rows = siteLog.OfType("alarm");
|
|
Assert.Single(rows);
|
|
var row = rows[0];
|
|
Assert.Equal("Error", row.Severity); // severity 800 → Error
|
|
Assert.Equal("inst", row.InstanceId);
|
|
Assert.Equal("NativeAlarmActor:Pressure", row.Source);
|
|
}, TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_EmitsInfoAlarmSiteEvent()
|
|
{
|
|
var siteLog = new FakeSiteEventLogger();
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
var t0 = DateTimeOffset.UtcNow;
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
|
|
|
// Clear (inactive but not yet acked → stays mirrored, return-to-normal emit).
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Clear,
|
|
new AlarmConditionState(false, false, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5))));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Normal);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
var rows = siteLog.OfType("alarm");
|
|
Assert.Equal(2, rows.Count); // raise + clear
|
|
Assert.Equal("Error", rows[0].Severity);
|
|
Assert.Equal("Info", rows[1].Severity); // return-to-normal → Info
|
|
}, TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rehydration_DoesNotEmitSiteEvent()
|
|
{
|
|
// Pre-populate SQLite with an active condition so the actor rehydrates
|
|
// it on PreStart. Rehydration replays last-known state — it is NOT a
|
|
// live transition, so it must surface upward (for the DebugView) but
|
|
// must NOT re-log an `alarm` operational event.
|
|
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
|
|
await _storage.UpsertNativeAlarmAsync(
|
|
"inst", "Pressure", "T01.Hi",
|
|
JsonSerializer.Serialize(condition), DateTimeOffset.UtcNow);
|
|
|
|
var siteLog = new FakeSiteEventLogger();
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
|
|
|
// The rehydrated condition is surfaced upward...
|
|
var emitted = instance.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(2));
|
|
Assert.Equal("T01.Hi", emitted.SourceReference);
|
|
Assert.Equal(AlarmState.Active, emitted.State);
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
// ...but no `alarm` operational event is logged for it.
|
|
AwaitAssert(
|
|
() => Assert.Empty(siteLog.OfType("alarm")),
|
|
TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotSwap_ExistingActiveCondition_DoesNotReEmit()
|
|
{
|
|
var siteLog = new FakeSiteEventLogger();
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
// Live raise — the one and only `alarm` event we expect.
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
|
AwaitAssert(() => Assert.Single(siteLog.OfType("alarm")), TimeSpan.FromSeconds(2));
|
|
|
|
// A reconnect snapshot that RE-INCLUDES the same still-active condition is
|
|
// a re-sync, not a live transition. It must NOT re-log a second `alarm`
|
|
// event (regression for the spurious-reconnect-event bug).
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Snapshot,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.SnapshotComplete,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
|
|
|
// The snapshot still surfaces the condition upward (DebugView re-sync)...
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "T01.Hi" && m.State == AlarmState.Active);
|
|
|
|
// ...but the `alarm` event count stays at exactly 1 — no re-emit.
|
|
Thread.Sleep(200); // give any spurious fire-and-forget log time to land
|
|
Assert.Single(siteLog.OfType("alarm"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_EmitsInfoAlarmSiteEventMentioningAcknowledged()
|
|
{
|
|
var siteLog = new FakeSiteEventLogger();
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
var t0 = DateTimeOffset.UtcNow;
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
|
|
|
// Operator acknowledges the still-active condition. The Acknowledge
|
|
// branch of LogAlarmEvent logs Info and mentions "acknowledged".
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Acknowledge,
|
|
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800), t0.AddSeconds(5))));
|
|
instance.ExpectMsg<AlarmStateChanged>();
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
var rows = siteLog.OfType("alarm");
|
|
Assert.Equal(2, rows.Count); // raise + acknowledge
|
|
var ack = rows[1];
|
|
Assert.Equal("Info", ack.Severity);
|
|
Assert.Contains("acknowledged", ack.Message, StringComparison.OrdinalIgnoreCase);
|
|
}, TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
// ── SiteRuntime-028: cap eviction emits a return-to-normal for an active drop ──
|
|
|
|
[Fact]
|
|
public void EnforceCap_EvictsActiveOldestCondition_EmitsReturnToNormalAndDropSignal()
|
|
{
|
|
// SiteRuntime-028: a cap eviction that drops a still-Active condition without a
|
|
// return-to-normal leaves the Instance Actor (and central's stream / the
|
|
// operator Alarm Summary) showing a phantom Active alarm forever. With cap=1,
|
|
// raising a second condition evicts the oldest (still Active) one — which must
|
|
// produce a Normal AlarmStateChanged for the evicted SourceReference, plus the
|
|
// SiteRuntime-027 NativeAlarmDropped so the parent evicts its stale key.
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var options = new SiteRuntimeOptions { MirroredAlarmCapPerSource = 1 };
|
|
var actor = Spawn(instance.Ref, dcl.Ref, options);
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
var t0 = DateTimeOffset.UtcNow;
|
|
// Oldest active condition (will be evicted when the cap is exceeded).
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"OLD.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "OLD.Hi" && m.State == AlarmState.Active);
|
|
|
|
// Newer active condition pushes the set to 2 > cap(1) → OLD.Hi is evicted.
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"NEW.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0.AddSeconds(5))));
|
|
|
|
// The new condition is emitted active...
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "NEW.Hi" && m.State == AlarmState.Active);
|
|
// ...and the evicted oldest condition must be cleared (return-to-normal), not
|
|
// left phantom-active.
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "OLD.Hi" && m.State == AlarmState.Normal);
|
|
// ...and the parent is told to evict the stale _latestAlarmEvents key.
|
|
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "OLD.Hi");
|
|
}
|
|
|
|
// ── SiteRuntime-027: terminal retention drop signals the parent to evict its key ──
|
|
|
|
[Fact]
|
|
public void RetentionDrop_ResolvedCondition_EmitsReturnToNormalThenDropSignal()
|
|
{
|
|
// SiteRuntime-027: when a native condition resolves (inactive AND acknowledged)
|
|
// it drops out of the mirror. The Instance Actor must be told (NativeAlarmDropped)
|
|
// so its _latestAlarmEvents map does not retain a stale (Normal) entry forever —
|
|
// otherwise a source that mints a fresh SourceReference per occurrence leaks one
|
|
// entry per condition the instance has ever seen.
|
|
var instance = CreateTestProbe();
|
|
var dcl = CreateTestProbe();
|
|
var actor = Spawn(instance.Ref, dcl.Ref);
|
|
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
|
|
|
var t0 = DateTimeOffset.UtcNow;
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
|
|
|
// Resolved: inactive AND acknowledged → return-to-normal emitted, then dropped.
|
|
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
|
"T01.Hi", AlarmTransitionKind.Clear,
|
|
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5))));
|
|
|
|
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "T01.Hi" && m.State == AlarmState.Normal);
|
|
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "T01.Hi");
|
|
}
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
Shutdown();
|
|
if (File.Exists(_dbFile))
|
|
{
|
|
File.Delete(_dbFile);
|
|
}
|
|
}
|
|
}
|