Merge branch 'worktree-agent-a349413889618d94c' into fix/flow-debugger-arch-review
This commit is contained in:
@@ -119,6 +119,73 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
Assert.NotNull(cut.Find("[data-test='flow-graph-empty']"));
|
||||
Assert.Equal("Not run", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
|
||||
// arch-review F3: a Deployer gets the whole surface — entry point, both modes, Run.
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-entry-method']"));
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-mode-live']"));
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']"));
|
||||
}
|
||||
|
||||
// ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ----------------
|
||||
|
||||
[Fact]
|
||||
public void Renders_ForViewer_ReplayOnly_WithNoLiveControls()
|
||||
{
|
||||
// A Viewer holds OperationalAudit but not Deployer: the page opens (it is the
|
||||
// Audit Log / Execution Chain drill-in target) pinned to Replay, with no way to
|
||||
// start a Live run.
|
||||
RegisterServices(role: "Viewer");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
|
||||
Assert.NotNull(cut.Find("[data-test='flow-toolbar']"));
|
||||
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-load']"));
|
||||
Assert.NotNull(cut.Find("[data-test='flow-replay-only']"));
|
||||
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-entry-value-change']"));
|
||||
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Viewer_LoadsAReplay_ButGetsNoRetryOrDiscardOnAParkedNode()
|
||||
{
|
||||
// Retry / Discard relay a command to the site — a Deployer act. The auditor sees
|
||||
// the Parked node and its detail, but not the two action buttons.
|
||||
RegisterServices(role: "Viewer");
|
||||
var root = Guid.NewGuid();
|
||||
var (parked, _, _) = ParkedThenResolved(root);
|
||||
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(parked);
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
||||
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
|
||||
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']"));
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-step-discard']"));
|
||||
_flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Viewer_DrillInWithAMethodId_DoesNotOpenTheLiveRunSurface()
|
||||
{
|
||||
// ?methodId= is a Live-run drill-in. For a non-Deployer it must not put the page
|
||||
// into Live mode or surface the Run button.
|
||||
RegisterServices(role: "Viewer");
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo("/deployment/flow-debugger?methodId=4");
|
||||
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
|
||||
Assert.Empty(cut.FindAll("button[data-test='flow-run']"));
|
||||
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
|
||||
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -234,9 +234,20 @@ public class FlowDebugRunnerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleGate_AllThreeCommands_AreDeployerOnly()
|
||||
public void RoleGate_TheTwoRunCommands_AreDeployerOnly()
|
||||
{
|
||||
foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b"), new FlowDebugReplayCommand(Guid.NewGuid()) })
|
||||
// arch-review F3: a Live run executes scripts and writes attributes, so it stays Deployer-only.
|
||||
foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b") })
|
||||
Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleGate_Replay_AlsoAcceptsTheOperationalAuditRoles()
|
||||
{
|
||||
// arch-review F3: Replay only reads the audit chain, and the Audit Log / Execution
|
||||
// Chain drill-ins must work for an auditor who holds no Deployer role.
|
||||
var actual = ManagementActor.GetRequiredRoles(new FlowDebugReplayCommand(Guid.NewGuid()));
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(new[] { "Administrator", "Deployer", "Viewer" }, actual!.OrderBy(r => r, StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ public class RequiredRoleMatrixTests
|
||||
private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];
|
||||
private static readonly string[] SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator];
|
||||
|
||||
// arch-review F3: Flow Debugger Replay is an audit read, so it is open to the
|
||||
// OperationalAudit roles {Administrator, Viewer} in addition to Deployer. Written out
|
||||
// by hand (not read from AuthorizationPolicies.FlowDebuggerAccessRoles) to keep the
|
||||
// freeze meaningful — a change to the policy's role set must fail this table first.
|
||||
private static readonly string[] FlowDebuggerReplayers = [Roles.Deployer, Roles.Administrator, Roles.Viewer];
|
||||
|
||||
private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// ---- Administrator-only -----------------------------------------------------
|
||||
@@ -138,10 +144,14 @@ public class RequiredRoleMatrixTests
|
||||
["RetryParkedMessage"] = [Roles.Deployer],
|
||||
["DiscardParkedMessage"] = [Roles.Deployer],
|
||||
["DebugSnapshot"] = [Roles.Deployer],
|
||||
// Flow Debugger (design 2026-09-05 §9): Live runs + Replay are Deployer, like Debug View.
|
||||
// Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3): a Live run executes
|
||||
// scripts and writes attributes on operator input, so it stays Deployer-only.
|
||||
// Replay only reads the audit chain, so it carries the OperationalAudit roles as
|
||||
// well — the Audit Log / Execution Chain drill-ins are the feature's main entry
|
||||
// for a past incident and an auditor must be able to follow them.
|
||||
["FlowDebugRunMethod"] = [Roles.Deployer],
|
||||
["FlowDebugRunValueChange"] = [Roles.Deployer],
|
||||
["FlowDebugReplay"] = [Roles.Deployer],
|
||||
["FlowDebugReplay"] = FlowDebuggerReplayers,
|
||||
|
||||
// ---- Two-person secured write ----------------------------------------------
|
||||
["SubmitSecuredWrite"] = [Roles.Operator],
|
||||
|
||||
@@ -1264,6 +1264,44 @@ public class AuthorizationPolicyTests
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireSecuredWriteAccess, principal));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Flow Debugger page access (arch-review F3 / design D6). Replay is an
|
||||
// audit read, so opening the page is any-of {Deployer, Administrator,
|
||||
// Viewer} — the OperationalAudit roles plus Deployer. The Live run
|
||||
// controls are separately gated on Deployer by the page and, server-side,
|
||||
// by the ManagementActor role matrix; Designer gets neither.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Deployer")]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Viewer")]
|
||||
public async Task RequireFlowDebugger_ReplayCapableRoles_Satisfy(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Designer")]
|
||||
[InlineData("Operator")]
|
||||
[InlineData("Verifier")]
|
||||
public async Task RequireFlowDebugger_OtherRoles_Denied(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireFlowDebugger_DoesNotImplyDeployment()
|
||||
{
|
||||
// A Viewer may open the page (Replay) but must not satisfy the policy the
|
||||
// Live run controls are gated on.
|
||||
var viewer = CreatePrincipal(new[] { Roles.Viewer });
|
||||
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, viewer));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, viewer));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
|
||||
Reference in New Issue
Block a user