Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowTraceCollectorTests.cs
T
Joseph Doherty e79452d2de fix(site-runtime): Flow Debugger inline trace copy is redacted and byte-capped at the collector (arch-review F1, site half)
Every emit site hands FlowTraceCollector the same AuditEvent it hands the
writer, but redaction and payload capping lived inside the writers, which
return a NEW filtered record — so the inline copy that rides
ScriptCallResult.TraceEvents / RouteToCallResponse.TraceEvents was the RAW
event: no per-target SQL-parameter redaction, no header scrubbing, no 8 KB /
64 KB truncation. Live and Replay of the same execution could therefore show
different payloads, and the 500-event-only cap left the reply free to breach
the 4 MB gRPC receive limit — which would fail a real call, since the same
reply carries the script's return value.

The collector now applies the host IAuditRedactor itself (outside the lock;
the writers keep their own idempotent Apply) and enforces a byte budget
(DefaultMaxBytes = 1 MB over the redacted DetailsJson) alongside the event
cap, flagging Truncated when either trips. It moves to Commons
(Types/Debugging) so the central FlowDebugRunner can share it.

The redactor is a singleton, so ScriptRunLauncher resolves it from the ROOT
provider and keeps constructing the collector before the try — the traced
ScriptRun row is emitted from the catch blocks too, including the
shed-at-dequeue path that throws before the DI scope exists.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 14:15:38 -04:00

148 lines
4.7 KiB
C#

using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>Flow Debugger design 2026-09-05 §5.4: the per-run inline trace sink.</summary>
public class FlowTraceCollectorTests
{
private static AuditEvent Evt(int i) => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "t",
Action = "ScriptTrace.AttributeWrite",
Outcome = AuditOutcome.Success,
Target = i.ToString(),
};
[Fact]
public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated()
{
var c = new FlowTraceCollector(maxEvents: 500);
Assert.False(c.Truncated);
for (var i = 0; i < 501; i++) c.Add(Evt(i));
var snap = c.Snapshot();
Assert.Equal(500, snap.Count);
Assert.True(c.Truncated);
Assert.Equal("0", snap[0].Target);
Assert.Equal("499", snap[499].Target);
}
[Fact]
public void Snapshot_IsACopy_InEmissionOrder()
{
var c = new FlowTraceCollector();
c.Add(Evt(1));
var first = c.Snapshot();
c.Add(Evt(2));
Assert.Single(first);
Assert.Equal(2, c.Count);
Assert.Equal(new[] { "1", "2" }, c.Snapshot().Select(e => e.Target));
}
[Fact]
public async Task ConcurrentAdds_NeverThrow_AndNeverExceedCap()
{
var c = new FlowTraceCollector(maxEvents: 100);
var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() =>
{
for (var i = 0; i < 50; i++) c.Add(Evt(t * 100 + i));
}));
await Task.WhenAll(tasks);
Assert.Equal(100, c.Snapshot().Count);
Assert.True(c.Truncated);
}
[Fact]
public void DefaultCap_Is500_AndNonPositiveCapIsRejected()
{
Assert.Equal(500, FlowTraceCollector.DefaultMaxEvents);
Assert.Throws<ArgumentOutOfRangeException>(() => new FlowTraceCollector(0));
}
[Fact]
public void Complete_ClosesTheCollector_LaterAddsAreIgnored_AndNeverTruncate()
{
var c = new FlowTraceCollector(maxEvents: 2);
var first = NewEvent();
c.Add(first);
Assert.False(c.Completed);
var snapshot = c.Complete();
Assert.True(c.Completed);
Assert.Same(first, Assert.Single(snapshot));
c.Add(NewEvent());
c.Add(NewEvent());
c.Add(NewEvent());
Assert.Equal(1, c.Count);
Assert.False(c.Truncated);
Assert.Equal(snapshot, c.Complete()); // idempotent
}
/// <summary>
/// Arch-review F1: the inline copy must be the SAME filtered record the writer persists —
/// the collector applies the redactor itself rather than handing on the raw event.
/// </summary>
[Fact]
public void Add_AppliesTheRedactor_SoTheInlineCopyIsTheFilteredEvent()
{
var c = new FlowTraceCollector(redactor: new UpperRedactor());
var evt = Evt(1) with { DetailsJson = "{\"secret\":\"abc\"}" };
c.Add(evt);
var stored = Assert.Single(c.Complete());
Assert.Equal("{\"SECRET\":\"ABC\"}", stored.DetailsJson);
Assert.NotSame(evt, stored);
}
/// <summary>
/// Arch-review F1: the 500-event cap alone does not bound the gRPC reply the inline copy
/// rides on — a byte budget over the redacted <c>DetailsJson</c> truncates first.
/// </summary>
[Fact]
public void Add_OverByteBudget_Truncates_KeepsEarlierEvents()
{
var c = new FlowTraceCollector(maxEvents: 500, maxBytes: 100);
var big = Evt(1) with { DetailsJson = new string('x', 80) };
var big2 = Evt(2) with { DetailsJson = new string('y', 80) };
c.Add(big);
c.Add(big2);
Assert.True(c.Truncated);
Assert.Single(c.Complete());
}
/// <summary>The byte budget has a documented default and is validated like the event cap.</summary>
[Fact]
public void DefaultByteBudget_Is1Mb_AndNonPositiveBudgetIsRejected()
{
Assert.Equal(1_048_576, FlowTraceCollector.DefaultMaxBytes);
Assert.Throws<ArgumentOutOfRangeException>(() => new FlowTraceCollector(maxBytes: 0));
}
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
private sealed class UpperRedactor : IAuditRedactor
{
public AuditEvent Apply(AuditEvent rawEvent)
=> rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() };
}
private static AuditEvent NewEvent() => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "t",
Action = "ScriptTrace.ScriptRun",
Outcome = AuditOutcome.Success,
DetailsJson = "{}",
};
}