fix(inbound): authorize+secure Database helper, async/deadline-bound DB, wait-timeout-bound WaitForAttribute

Resolves InboundAPI-026/027/028/029 (+ newly-surfaced -030).

- 026: authorize the scoped Database helper in the design doc; SQL-injection
  protection is parameter binding (values never concatenated); allow writes via
  ExecuteAsync; drop the false 'read-only' claim. Named connections only.
- 027: async ADO.NET end-to-end (no .GetAwaiter().GetResult()); honour the method
  deadline token on ExecuteScalarAsync/ExecuteReaderAsync/ExecuteNonQueryAsync +
  a CommandTimeout backstop derived from the method timeout.
- 028: negative-path tests (null-gateway, deadline cancellation, parameterization)
  + e2e Database + WaitForAttribute cases through the real endpoint.
- 029: WaitForAttribute is bounded by its WAIT timeout (per-wait CTS + client-abort
  + explicit token), NOT the method deadline (spec §6) — a long wait may outlive the
  method timeout; WithRequestAborted threads the raw client-abort token separately.
- 030: Central UI compile-surface mirrors (InboundScriptHost / SandboxInboundScriptHost)
  gained the Database member (drifted since the runtime helper was added) so the
  authorized async API type-checks at the design-time gate.
This commit is contained in:
Joseph Doherty
2026-06-23 22:00:17 -04:00
parent d39089f4ed
commit b3c9014379
11 changed files with 540 additions and 68 deletions
@@ -214,10 +214,15 @@ public class RouteHelperTests
}
[Fact]
public async Task WaitForAttribute_WithNoExplicitToken_InheritsMethodDeadlineToken()
public async Task WaitForAttribute_IsNotBoundByMethodDeadline_WaitTimeoutGoverns()
{
// InboundAPI-029 (spec §6): unlike Call/GetAttributes/SetAttributes, the wait is
// bounded by its OWN timeout, not the method deadline. Even with the method
// deadline ALREADY cancelled, the wait still proceeds and returns the site's
// wait-timeout result — the deadline does not cut it short.
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
await deadline.CancelAsync(); // method deadline already elapsed
CancellationToken seen = default;
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToWaitForAttributeResponse(
@@ -226,9 +231,52 @@ public class RouteHelperTests
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithDeadline(deadline.Token);
var matched = await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
Assert.False(matched); // the site's wait-timeout result, not a cancellation
Assert.False(seen.IsCancellationRequested); // the wait token ignores the cancelled method deadline
Assert.NotEqual(deadline.Token, seen); // it is a per-wait token, not the method deadline
}
[Fact]
public async Task WaitForAttribute_ExplicitToken_IsHonoured()
{
// An explicit caller token still applies (a tighter bound the script chose).
SiteResolves("inst-1", "SiteA");
using var explicitCts = new CancellationTokenSource();
await explicitCts.CancelAsync();
CancellationToken seen = default;
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: false, Value: null, Quality: null, TimedOut: true,
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
await CreateHelper().To("inst-1")
.WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30), explicitCts.Token);
Assert.True(seen.IsCancellationRequested); // the explicit caller token cancels the wait
}
[Fact]
public async Task WaitForAttribute_ClientDisconnect_CancelsTheWait()
{
// A client disconnect (the raw request-abort token, threaded via WithRequestAborted)
// still cancels the wait even though the method deadline does not bound it.
SiteResolves("inst-1", "SiteA");
using var clientAbort = new CancellationTokenSource();
await clientAbort.CancelAsync();
CancellationToken seen = default;
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToWaitForAttributeResponse(
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
Matched: false, Value: null, Quality: null, TimedOut: true,
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithRequestAborted(clientAbort.Token);
await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
Assert.Equal(deadline.Token, seen);
Assert.True(seen.IsCancellationRequested); // the client-abort token cancels the wait
}
[Fact]