fix(inbound-api): RouteCall audit row honours the method's SkipBodyCapture override (arch-review F11)

RouteHelper/RouteTarget carry a new captureBodies flag (default true) threaded
through every With* copy, same shape as the site-scope field Task 3 added.
InboundScriptExecutor resolves IOptionsMonitor<AuditLogOptions> from the
per-execution DI scope and looks up PerTargetOverrides[method.Name]?.SkipBodyCapture
exactly as AuditWriteMiddleware.cs:183-187 does for the inbound row, then calls
.WithBodyCapture(!skip) when building the script's Route helper. When set, the
always-on ScriptTrace.RouteCall row omits its request/response summary, so an
operator who suppressed a method's body from the audit log doesn't get it back
via the routed call's arguments (derived from that same body).

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 14:22:01 -04:00
parent 3f1981865a
commit 453c3dec18
4 changed files with 203 additions and 13 deletions
@@ -4,10 +4,13 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -57,6 +60,77 @@ public class InboundScriptExecutorTests
Assert.False(captured[1].Trace);
}
[Fact]
public async Task Executor_MethodWithSkipBodyCapture_RoutesWithBodyCaptureOff()
{
// Arch-review F11: a method configured with PerTargetOverrides[method].SkipBodyCapture
// must build its Route helper WithBodyCapture(false), so the always-on RouteCall row
// carries no request/response summary — mirroring AuditWriteMiddleware's own
// SkipBodyCapture lookup for the inbound row (AuditWriteMiddleware.cs:183-187).
var locator = Substitute.For<IInstanceLocator>();
var router = Substitute.For<IInstanceRouter>();
locator.GetSiteIdForInstanceAsync("inst", Arg.Any<CancellationToken>()).Returns("SiteA");
router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 1, null, DateTimeOffset.UtcNow));
var writer = Substitute.For<ICentralAuditWriter>();
AuditEvent? written = null;
writer.WriteAsync(Arg.Do<AuditEvent>(e => written = e), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var route = new RouteHelper(locator, router, writer, null);
var services = new ServiceCollection();
services.Configure<AuditLogOptions>(o =>
o.PerTargetOverrides["routed"] = new PerTargetRedactionOverride { SkipBodyCapture = true });
using var provider = services.BuildServiceProvider();
var executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, provider);
var method = new ApiMethod("routed", "x") { Id = 9, TimeoutSeconds = 10 };
executor.RegisterHandler("routed", ctx => ctx.Route.To("inst").Call("work", new { x = 1 }));
var result = await executor.ExecuteAsync(method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(written);
var row = written!.AsRow();
Assert.Null(row.RequestSummary);
Assert.Null(row.ResponseSummary);
}
[Fact]
public async Task Executor_MethodWithoutSkipBodyCapture_RoutesWithBodyCaptureOn()
{
// Control: no PerTargetOverrides entry for this method → default behaviour unchanged.
var locator = Substitute.For<IInstanceLocator>();
var router = Substitute.For<IInstanceRouter>();
locator.GetSiteIdForInstanceAsync("inst", Arg.Any<CancellationToken>()).Returns("SiteA");
router.RouteToCallAsync("SiteA", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(((RouteToCallRequest)ci[1]).CorrelationId, true, 1, null, DateTimeOffset.UtcNow));
var writer = Substitute.For<ICentralAuditWriter>();
AuditEvent? written = null;
writer.WriteAsync(Arg.Do<AuditEvent>(e => written = e), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var route = new RouteHelper(locator, router, writer, null);
var services = new ServiceCollection();
services.Configure<AuditLogOptions>(o =>
o.PerTargetOverrides["other-method"] = new PerTargetRedactionOverride { SkipBodyCapture = true });
using var provider = services.BuildServiceProvider();
var executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, provider);
var method = new ApiMethod("routed", "x") { Id = 9, TimeoutSeconds = 10 };
executor.RegisterHandler("routed", ctx => ctx.Route.To("inst").Call("work", new { x = 1 }));
var result = await executor.ExecuteAsync(method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(written);
var row = written!.AsRow();
Assert.Contains("\"x\":1", row.RequestSummary);
Assert.Equal("1", row.ResponseSummary);
}
[Fact]
public async Task RegisteredHandler_ExecutesSuccessfully()
{