Merge branch 'worktree-agent-a5b63f3a0707e4e46' into fix/flow-debugger-arch-review

This commit is contained in:
Joseph Doherty
2026-09-06 14:15:58 -04:00
4 changed files with 138 additions and 11 deletions
@@ -1,7 +1,7 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
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
@@ -85,6 +85,56 @@ public class FlowTraceCollectorTests
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(),
@@ -209,6 +209,39 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
Assert.False(ctx.IsTraced);
}
/// <summary>
/// Arch-review F1: the collector applies the host redactor, so the inline copy the reply
/// carries is the FILTERED record — while the writer still receives the raw event and
/// redacts it itself (redaction is idempotent, so the stored row is identical).
/// </summary>
[Fact]
public async Task SetAttribute_WhenTraced_InlineCopyIsRedacted_WriterStillSeesTheRawEvent()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector(redactor: new UpperRedactor());
var ctx = CreateContext(probe.Ref, writer, traced: true, collector);
var call = ctx.SetAttribute("Setpoint", "42");
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
await call;
var written = Assert.Single(writer.Events);
var collected = Assert.Single(collector.Complete());
Assert.NotSame(written, collected);
Assert.Contains("\"channel\":\"ScriptTrace\"", written.DetailsJson);
Assert.NotEqual(written.DetailsJson, collected.DetailsJson);
Assert.Equal(written.DetailsJson!.ToUpperInvariant(), collected.DetailsJson);
}
/// <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() };
}
[Fact]
public async Task SetAttribute_WhenTraced_FailedWrite_EmitsFailedRow_ThenThrows()
{