fix(review): full code-review remediation — 5 High + Medium/Low across 16 modules
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.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
@@ -177,4 +178,48 @@ public class SiteCallAuditPurgeTests : TestKit
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. SiteCallAudit-007: purge timer arms even when the reconciliation
|
||||
// collaborators are ABSENT (production ctor, no IPullSiteCallsClient /
|
||||
// ISiteEnumerator registered). Proves the decoupling — a host that omits
|
||||
// the reconciliation client still purges, so the central SiteCalls table
|
||||
// cannot grow unbounded.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PurgeTick_ProductionCtor_NoReconciliationCollaborators_StillPurges()
|
||||
{
|
||||
var repo = new RecordingRepo { RowsDeletedPerCall = 3 };
|
||||
|
||||
// Build a DI container that registers the repository the production
|
||||
// ctor resolves per-tick, but deliberately registers NEITHER
|
||||
// IPullSiteCallsClient NOR ISiteEnumerator. GetService returns null for
|
||||
// both, so the actor's reconciliation tick is gated off — but the purge
|
||||
// tick must still arm (SiteCallAudit-007).
|
||||
var provider = new ServiceCollection()
|
||||
.AddScoped<ISiteCallAuditRepository>(_ => repo)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var options = FastPurgeOptions(retentionDays: 30);
|
||||
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
||||
provider,
|
||||
options,
|
||||
NullLogger<SiteCallAuditActor>.Instance)));
|
||||
|
||||
// No reconciliation collaborators were registered, yet the purge tick
|
||||
// must still fire on the production path.
|
||||
AwaitAssert(
|
||||
() => Assert.True(repo.PurgeThresholds.Count >= 1,
|
||||
"purge timer must arm even when the reconciliation collaborators are absent "
|
||||
+ $"(SiteCallAudit-007), got {repo.PurgeThresholds.Count} purge calls"),
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var threshold = repo.PurgeThresholds[0];
|
||||
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
|
||||
Assert.True(
|
||||
Math.Abs((threshold - expected).TotalMinutes) < 1.0,
|
||||
$"purge threshold {threshold:o} should be within 1 minute of {expected:o}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,30 @@ public class SiteCallAuditReconciliationTests : TestKit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull client that ALWAYS returns the same saturated response
|
||||
/// (<c>MoreAvailable=true</c>) regardless of the <c>since</c> cursor —
|
||||
/// simulates the SiteCallAudit-009 single-timestamp no-progress pin: a backlog
|
||||
/// larger than the batch size all sharing one exact <c>UpdatedAtUtc</c>, so
|
||||
/// the inclusive max-timestamp cursor never advances. Records every call so
|
||||
/// the test can assert the within-tick drain is BOUNDED (the actor must not
|
||||
/// spin the dispatcher forever on this pathological input).
|
||||
/// </summary>
|
||||
private sealed class SaturatedPinPullClient : IPullSiteCallsClient
|
||||
{
|
||||
private readonly IReadOnlyList<SiteCall> _rows;
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public SaturatedPinPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows;
|
||||
|
||||
public Task<PullSiteCallsResponse> PullAsync(
|
||||
string siteId, DateTime sinceUtc, int batchSize, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(new PullSiteCallsResponse(_rows, MoreAvailable: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recording repository that captures every <see cref="UpsertAsync"/> call
|
||||
/// (keyed by id, last-write-wins on the captured row). The reconciliation
|
||||
@@ -301,4 +325,115 @@ public class SiteCallAuditReconciliationTests : TestKit
|
||||
// so it upserts nothing on its own.
|
||||
Assert.Equal(0, repo.UpsertCallCount);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 5. SiteCallAudit-009: MoreAvailable drives a within-tick continuation
|
||||
// drain — a multi-page backlog whose timestamps advance is fully drained
|
||||
// in ONE tick rather than one page per tick.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ReconciliationTick_MoreAvailable_DrainsMultiplePagesWithinOneTick()
|
||||
{
|
||||
var siteId = "siteA";
|
||||
var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc);
|
||||
var t3 = new DateTime(2026, 5, 20, 10, 2, 0, DateTimeKind.Utc);
|
||||
var p1a = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t1);
|
||||
var p1b = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t2);
|
||||
var p2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t3);
|
||||
|
||||
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
|
||||
// Page 1 saturates (MoreAvailable: true) → the actor continues pulling
|
||||
// within the SAME tick; page 2 is the final page (MoreAvailable: false).
|
||||
// The continuation pull's `since` must be t2 (page-1 max), proving the
|
||||
// cursor advanced page-to-page inside one tick rather than across ticks.
|
||||
var client = new ScriptedPullClient().Script(siteId,
|
||||
new PullSiteCallsResponse(new[] { p1a, p1b }, MoreAvailable: true),
|
||||
new PullSiteCallsResponse(new[] { p2 }, MoreAvailable: false));
|
||||
var repo = new RecordingRepo();
|
||||
|
||||
// Slow tick so the multi-page drain CANNOT be the natural tick cadence —
|
||||
// it must be the within-tick continuation loop. Long enough that only the
|
||||
// first tick fires in the assert window.
|
||||
var options = new SiteCallAuditOptions
|
||||
{
|
||||
ReconciliationIntervalOverride = TimeSpan.FromSeconds(2),
|
||||
ReconciliationBatchSize = 2,
|
||||
};
|
||||
|
||||
CreateActor(sites, client, repo, options);
|
||||
|
||||
AwaitAssert(
|
||||
() =>
|
||||
{
|
||||
// All three rows reconciled — including the page-2 row that only a
|
||||
// within-tick continuation pull could have fetched.
|
||||
Assert.True(repo.Upserted.ContainsKey(p1a.TrackedOperationId));
|
||||
Assert.True(repo.Upserted.ContainsKey(p1b.TrackedOperationId));
|
||||
Assert.True(repo.Upserted.ContainsKey(p2.TrackedOperationId),
|
||||
"the page-2 row must be reconciled within the same tick via the MoreAvailable continuation drain");
|
||||
},
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
// Exactly two pulls happened (page 1 + the continuation page 2) and the
|
||||
// second pull's `since` cursor advanced to the page-1 max (t2).
|
||||
Assert.True(client.Calls.Count >= 2, $"expected >= 2 pulls within the tick, got {client.Calls.Count}");
|
||||
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
|
||||
Assert.Equal(t2, client.Calls[1].SinceUtc);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 6. SiteCallAudit-009: single-timestamp saturation pin does NOT spin —
|
||||
// a saturated batch whose max UpdatedAtUtc never advances past `since`
|
||||
// breaks the within-tick drain after one page (no unbounded re-pull),
|
||||
// and still upserts the rows it saw.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ReconciliationTick_SingleTimestampSaturation_DoesNotSpin_MakesNoProgressGracefully()
|
||||
{
|
||||
var siteId = "siteA";
|
||||
// A burst sharing ONE exact UpdatedAtUtc that saturates the batch — the
|
||||
// inclusive max-timestamp cursor cannot advance, so an unbounded
|
||||
// continuation loop would re-pull this identical window forever.
|
||||
var ts = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var r1 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
|
||||
var r2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
|
||||
|
||||
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
|
||||
var client = new SaturatedPinPullClient(new[] { r1, r2 });
|
||||
var repo = new RecordingRepo();
|
||||
|
||||
// Long interval so AT MOST one tick fires in the assert window — lets us
|
||||
// bound the WITHIN-tick pull count. A no-progress pin must break after a
|
||||
// single page, NOT loop up to MaxReconciliationPagesPerTick (50).
|
||||
var options = new SiteCallAuditOptions
|
||||
{
|
||||
ReconciliationIntervalOverride = TimeSpan.FromSeconds(2),
|
||||
ReconciliationBatchSize = 2,
|
||||
};
|
||||
|
||||
CreateActor(sites, client, repo, options);
|
||||
|
||||
AwaitAssert(
|
||||
() => Assert.True(client.CallCount >= 1, "the first reconciliation tick should have pulled"),
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
// The rows it saw were still upserted (idempotent mirror refresh).
|
||||
Assert.True(repo.Upserted.ContainsKey(r1.TrackedOperationId));
|
||||
Assert.True(repo.Upserted.ContainsKey(r2.TrackedOperationId));
|
||||
|
||||
// Critical SiteCallAudit-009 invariant: the within-tick drain BROKE on the
|
||||
// no-progress pin rather than looping to the 50-page ceiling. With a 2s
|
||||
// tick interval, only the first tick has fired in the window, so the pull
|
||||
// count reflects ONE tick's within-loop behaviour. A correct break yields
|
||||
// 1 pull for that tick; we allow a small margin for a possible second tick
|
||||
// edge, but it must be far below the 50-page within-tick ceiling.
|
||||
Assert.True(client.CallCount < 10,
|
||||
$"a single-timestamp saturation pin must break the within-tick drain, not spin to the "
|
||||
+ $"page ceiling; got {client.CallCount} pulls (an unbounded within-tick loop would be 50+)");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user