feat(inbound-api): method-script exception detail on the inbound audit row, never on the HTTP body (D3)

This commit is contained in:
Joseph Doherty
2026-09-09 12:15:00 -04:00
parent a45857ddd6
commit 98c6809a66
6 changed files with 188 additions and 4 deletions
@@ -202,6 +202,56 @@ public class InboundScriptExecutorTests
Assert.Equal("Internal script error", result.ErrorMessage);
}
[Fact]
public async Task HandlerThrows_ErrorDetailCarriesTheException_WhileTheMessageStaysSafe()
{
// Flow Debugger step logs (design 2026-09-09 D3): the generic SCRIPT_ERROR catch keeps
// the caller-facing message sanitised, but the exception itself must survive on the
// additive ErrorDetail so the endpoint can put it on the inbound audit row (and the
// Flow Debugger's inbound node can show why the run failed).
var method = new ApiMethod("failing-detail", "throw new InvalidOperationException(\"real reason\");")
{ Id = 1, TimeoutSeconds = 10 };
_executor.RegisterHandler("failing-detail", _ => throw new InvalidOperationException("real reason"));
var result = await _executor.ExecuteAsync(
method,
new Dictionary<string, object?>(),
_route,
TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("Internal script error", result.ErrorMessage);
Assert.Equal("SCRIPT_ERROR", result.ErrorCode);
Assert.NotNull(result.ErrorDetail);
Assert.Contains("InvalidOperationException", result.ErrorDetail);
Assert.Contains("real reason", result.ErrorDetail);
// The caller-safe fields must never carry it.
Assert.DoesNotContain("real reason", result.ErrorMessage!);
}
[Fact]
public async Task HandlerTimesOut_CarriesNoErrorDetail()
{
// A timeout is fully described by its message + TIMEOUT code — the
// OperationCanceledException adds nothing, so the row stays detail-less.
var method = new ApiMethod("slow-detail", "Thread.Sleep(60000);") { Id = 1, TimeoutSeconds = 1 };
_executor.RegisterHandler("slow-detail", async ctx =>
{
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
return "never";
});
var result = await _executor.ExecuteAsync(
method,
new Dictionary<string, object?>(),
_route,
TimeSpan.FromMilliseconds(100));
Assert.False(result.Success);
Assert.Equal("TIMEOUT", result.ErrorCode);
Assert.Null(result.ErrorDetail);
}
[Fact]
public async Task RoutedAskTimeout_Returns500WithSiteUnreachableCode()
{