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

This commit is contained in:
Joseph Doherty
2026-09-06 14:14:01 -04:00
5 changed files with 317 additions and 10 deletions
@@ -621,4 +621,86 @@ public class RouteHelperTests
await Assert.ThrowsAsync<SiteUnreachableException>(
() => CreateHelper().To("inst").GetAttributes(new[] { "a" }));
}
// --- Arch-review F2: site scope on routed calls ---
//
// The gate lives in RouteTarget.ResolveSiteAsync — the single choke point every
// routed operation passes through — so it fires BEFORE any site traffic.
private static IReadOnlySet<string> Scope(params string[] siteIdentifiers) =>
new HashSet<string>(siteIdentifiers, StringComparer.OrdinalIgnoreCase);
[Fact]
public async Task Call_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting()
{
SiteResolves("inst-1", "site-b");
var helper = CreateHelper().WithSiteScope(Scope("site-a"));
var ex = await Assert.ThrowsAsync<RouteSiteScopeException>(
() => helper.To("inst-1").Call("doWork"));
Assert.Contains("site-b", ex.Message);
Assert.Contains("inst-1", ex.Message);
// Nothing was routed: the check runs before the site is contacted.
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
}
[Fact]
public async Task Call_InsideSiteScope_Routes()
{
SiteResolves("inst-1", "site-b");
_router.RouteToCallAsync("site-b", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow));
var helper = CreateHelper().WithSiteScope(Scope("SITE-B"));
Assert.Equal(42, await helper.To("inst-1").Call("doWork"));
}
[Fact]
public async Task Call_NullScope_IsUnscoped()
{
SiteResolves("inst-1", "site-b");
_router.RouteToCallAsync("site-b", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow));
var helper = CreateHelper().WithSiteScope(null);
Assert.Equal(42, await helper.To("inst-1").Call("doWork"));
}
[Fact]
public async Task SetAttributes_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting()
{
// ResolveSiteAsync is shared by all four routed operations, so the gate is
// not Call-specific.
SiteResolves("inst-1", "site-b");
var helper = CreateHelper().WithSiteScope(Scope("site-a"));
await Assert.ThrowsAsync<RouteSiteScopeException>(
() => helper.To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" }));
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}
[Fact]
public async Task WithSiteScope_SurvivesTheOtherWithCopies()
{
// The scope must not be dropped by the copies InboundScriptExecutor makes
// (WithDeadline / WithRequestAborted / WithParentExecutionId / WithTrace).
SiteResolves("inst-1", "site-b");
var helper = CreateHelper()
.WithSiteScope(Scope("site-a"))
.WithDeadline(CancellationToken.None)
.WithRequestAborted(CancellationToken.None)
.WithParentExecutionId(Guid.NewGuid())
.WithTrace(true)
.WithTraceSink(null);
await Assert.ThrowsAsync<RouteSiteScopeException>(
() => helper.To("inst-1").Call("doWork"));
}
}
@@ -233,6 +233,87 @@ public class FlowDebugRunnerTests
Assert.Equal(FlowDebugRunner.NotFoundCode, empty.ErrorCode);
}
// --- Arch-review F2: a method-entry Live run is bound by the operator's site scope ---
//
// RunValueChange already called EnforceSiteScope on the target instance; a method
// Live run has no single target site up front, so the scope is pushed into the
// RouteHelper and enforced at route time (RouteTarget.ResolveSiteAsync), before any
// site traffic. The denial comes back as SITE_SCOPE_DENIED and the runner converts it.
/// <summary>Wires a method whose script routes to <paramref name="instanceCode"/>, resident at <paramref name="siteIdentifier"/>.</summary>
private ISiteRepository WireRoutedMethod(int methodId, string instanceCode, string siteIdentifier)
{
var method = new ApiMethod("M", "x") { Id = methodId, TimeoutSeconds = 5 };
_apiRepo.GetApiMethodByIdAsync(methodId, Arg.Any<CancellationToken>()).Returns(method);
_locator.GetSiteIdForInstanceAsync(instanceCode, Arg.Any<CancellationToken>()).Returns(siteIdentifier);
_router.RouteToCallAsync(siteIdentifier, Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, 7, null, DateTimeOffset.UtcNow));
_executor.RegisterHandler("M", ctx => ctx.Route.To(instanceCode).Call("work"));
// The user's PermittedSiteIds are NUMERIC ids; the locator speaks site IDENTIFIERS,
// so the runner resolves one to the other through the repository.
var sites = Substitute.For<ISiteRepository>();
sites.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(new Site("Site A", "site-a") { Id = 1 });
_services.AddSingleton(sites);
return sites;
}
[Fact]
public async Task RunMethod_ScopedDeployer_RouteToOtherSite_IsUnauthorized_AndInboundRowIsWritten()
{
WireRoutedMethod(11, "inst", "site-b");
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunMethodAsync(
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(11, null), scoped, CancellationToken.None));
// The site was never contacted...
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
// ...but the denied attempt IS recorded against the operator's name.
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.InboundRequest && e.Actor == "ops");
}
[Fact]
public async Task RunMethod_ScopedDeployer_RouteWithinScope_Runs()
{
// The same scoped user, routing to a site their numeric id DOES resolve to.
WireRoutedMethod(12, "inst", "site-a");
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
var result = await Runner().RunMethodAsync(
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(12, null), scoped, CancellationToken.None);
Assert.True(result.Completed, result.Error);
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
}
[Fact]
public async Task RunMethod_Administrator_IsNeverScoped()
{
WireRoutedMethod(13, "inst", "site-b");
var admin = new AuthenticatedUser("boss", "Boss", new[] { "Deployer", "Administrator" }, new[] { "1" });
var result = await Runner().RunMethodAsync(
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(13, null), admin, CancellationToken.None);
Assert.True(result.Completed, result.Error);
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
}
[Fact]
public async Task RunMethod_UnscopedUser_IsNeverScoped()
{
WireRoutedMethod(14, "inst", "site-b");
// Deployer with an EMPTY PermittedSiteIds = global scope, as EnforceSiteScope treats it.
var result = await Runner().RunMethodAsync(
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(14, null), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
}
[Fact]
public void RoleGate_TheTwoRunCommands_AreDeployerOnly()
{