fix(flow-debugger): refuse method-entry Live runs for site-scoped users (#55)

Owner decision (b), 2026-11-09. An inbound API method has no site
affinity, so the F2 route-time scope only bounded routed SITE traffic;
the method's central-side effects (Database.*, ExternalSystem,
notifications) still ran for a scoped Deployer. FlowDebugRunner now
refuses FlowDebugRunMethodCommand for a site-scoped caller (site ids,
not Administrator) before the script executes, audits the refusal as a
failed InboundRequest under the operator's name, and raises
SiteScopeViolationException -> ManagementUnauthorized, so the Central
UI and the CLI (same /management command) share the gate.

The page disables the "Inbound API method" entry for scoped Deployers
with the reason (tooltip + inline note), starts them on script entry,
and ignores ?methodId= / a stored method choice. Replay and the
instance-scoped script / value-change Live runs are unchanged. The
route-time scope is kept as defence in depth.

Tests: scoped refused (in- and out-of-scope routing; script never runs,
refusal audited), unscoped/Administrator allowed, scoped Replay allowed,
scoped in-scope value-change Live still runs; bUnit for the disabled
entry, the drill-in, and the unscoped control. Mutation-proven (gate
and UI flag reverted -> 4 red).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 22:22:44 -05:00
parent 4624c8db28
commit 63ec45e2e3
11 changed files with 198 additions and 25 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -116,7 +116,7 @@ scadabridge instance deploy --id 7
| `shared-schema` | `list`, `get`, `create`, `update`, `delete` | Read: authenticated; mutations: Designer |
| `secured-write` | `list`, `submit`, `approve`, `reject` | Operator / Verifier / Administrator, enforced per operation |
| `cached-call` | `retry`, `discard` (list parked messages with `health parked-messages`) | Server-enforced operational roles and site scope |
| `flow-debug` | `run-method`, `run-script`, `run-value-change`, `replay` | Deployer; replay also accepts OperationalAudit roles |
| `flow-debug` | `run-method`, `run-script`, `run-value-change`, `replay` | Deployer; `run-method` is refused for a site-scoped Deployer (Gitea #55); replay also accepts OperationalAudit roles |
| `shared-script` | `list`, `get`, `create`, `update`, `delete` | `Designer` |
| `db-connection` | `list`, `get`, `create`, `update`, `delete` | `Designer` |
| `api-method` | `list`, `get`, `create`, `update`, `delete`, `validate`, `recompile` | `Designer` |
@@ -292,6 +292,13 @@ surface); the walker is syntax-only and never executes anything.
**before any site traffic**; the denial is rethrown as `SiteScopeViolationException` *after* the
inbound audit row is written, so the attempt is recorded under the operator's name.
Administrators and unscoped users are exempt, exactly as `EnforceSiteScope` treats them.
**Superseded for scoped users by Gitea #55 (owner decision (b), 2026-11-09):** route-time scope only
bounds routed site traffic — the method's central-side effects (`Database.*`, `ExternalSystem`,
notifications) have no site to scope against — so a site-scoped operator is now refused a method
Live run outright, before the script executes (`FlowDebugRunner.IsSiteScoped`, audited failed
`InboundRequest` row, `SiteScopeViolationException` → `ManagementUnauthorized`). The page disables the
method entry for them with the reason; Replay and instance-scoped script / value-change Live runs are
unchanged. The route-time scope stays as defence in depth.
- Trace payloads pass through the existing redaction policy on BOTH copies: the stored row (writer)
and the inline Live copy (`FlowTraceCollector` applies `IAuditRedactor` and a 1 MB byte budget
beside the 500-event cap — arch-review F1). The row-level caps are unchanged (auth headers
+1
View File
@@ -26,6 +26,7 @@ Central cluster only. Sites have no user interface.
- Cookie login validates an antiforgery token before reading credentials or calling LDAP; the login form supplies the token. The JSON token endpoint retains its non-cookie client contract.
- Manual failover goes through a scoped `AuthorizedManualFailoverService` that refreshes the circuit's Administrator grant before calling the host singleton. The component's visibility policy is an additional presentation guard.
- Flow Debugger method-entry Live runs are unavailable to a site-scoped Deployer (Gitea #55): the *Inbound API method* entry is disabled with the reason as a tooltip and an inline note, the page starts on script entry, a `?methodId=` drill-in or a stored method choice is ignored, and the server refuses the command regardless. Script entry on a permitted instance and Replay stay available.
- Flow Debugger retry/discard validates the current Deployer grant and resolves site scope inside its service. Scoped callers cannot relay an unknown site. Refresh merges preserve Replay's event ordering and append only rows known exclusively to the live run.
- Health and Alarm Summary observe the whole posted refresh task and release their poll gate even when renderer dispatch fails during disposal.
- The Topology initially loads permitted sites **and their area metadata** (Gitea #39 — every permitted site's areas are read at open, out-of-scope sites never) and opens with each site expanded to its top-level areas (`TreeView.InitiallyExpanded`; a browser's saved expansion state always wins). Instances stay lazy: select **Load instances** to load the first root-instance page of a site; each expanded area has its own loader. **Previous** and **Next** replace that branch's 50-instance page. Queries are untracked and omit member collections; templates are not loaded. Live refresh and staleness checks cover loaded pages. Search and counts explicitly describe loaded data. **Deploy all…** (Gitea #40): the Site and Area node context menus offer a subtree bulk deploy (Deployer role + current site scope re-checked at click). The page asks `DeploymentService.PreviewSubtreeDeployAsync` for the instance count and Disabled count (a cheap read, no staleness probe); a zero-instance subtree shows an info toast and no dialog; otherwise `DeployAllDialog` (via `IDialogService.ShowAsync`) shows the counts and a **Force redeploy up-to-date instances** checkbox with confirm label **Deploy all**. Confirm calls `DeploySiteAsync` (site node) or `DeployAreaAsync` (area node — the area plus all descendant areas) with the Force choice — same rules as the CLI/management bulk deploy (Disabled always skipped, up-to-date skipped unless forced, not all-or-nothing) — then toasts `N deployed, M skipped, K failed` (an error toast naming the first three failures when any failed) and reloads the tree. The menu item is disabled while another page action is in flight.
@@ -249,7 +249,7 @@ All four commands return `FlowDebugRunResult`: the chain root execution ID, comp
| Command | Behaviour | Required role |
|---|---|---|
| `FlowDebugRunMethodCommand` | Validates an inbound API method's parameters and runs it with tracing; routed calls enforce the caller's site scope. | Deployer |
| `FlowDebugRunMethodCommand` | Validates an inbound API method's parameters and runs it with tracing; routed calls enforce the caller's site scope. **Refused for a site-scoped caller** (a Deployer carrying site ids, not an Administrator) with `ManagementUnauthorized` before the script executes — a method has no site, so its central-side effects cannot be scoped (Gitea #55); the refusal is audited as a failed `InboundRequest` under the operator's name. | Deployer (system-wide, or Administrator) |
| `FlowDebugRunValueChangeCommand` | Writes an instance attribute through the real site path and polls for the triggered execution tree. | Deployer |
| `FlowDebugRunScriptCommand` | Resolves the effective script; writes its trigger attribute for write-trigger kinds, otherwise invokes it directly with validated parameters. | Deployer |
| `FlowDebugReplayCommand` | Reads the stored execution tree for any execution ID, enforcing caller scope and retaining the repository's chain anchor. | Any of Deployer, Administrator, Viewer |
@@ -52,7 +52,7 @@ public static class FlowDebugCommands
DefaultValueFactory = _ => 120,
};
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps" };
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps (system-wide Deployer or Administrator; refused for a site-scoped user — use replay or run-script)" };
cmd.Add(methodIdOption);
cmd.Add(paramsOption);
cmd.Add(timeoutOption);
@@ -23,7 +23,9 @@
<label class="form-label form-label-sm">Entry point</label>
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
data-test="flow-entry-method" disabled="@(_running || _methodLiveRefused)"
title="@(_methodLiveRefused ? MethodLiveRefusedHint : null)"
@onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
<button type="button" class="btn @(_entry == EntryKind.Script ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</button>
</div>
@@ -128,6 +130,13 @@
</div>
</div>
@if (_methodLiveRefused && _mode == FlowMode.Live)
{
<div class="text-muted small mb-2" data-test="flow-method-live-refused">
@MethodLiveRefusedHint
</div>
}
@if (!_canLive)
{
<div class="text-muted small mb-2" data-test="flow-replay-only">
@@ -113,6 +113,17 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// </summary>
private bool _canLive;
// Gitea #55 (owner decision (b)): a site-scoped Deployer may not Live-run an inbound API
// method — the method has no site, so its central-side effects cannot be bounded by the
// scope. The server refuses it (FlowDebugRunner); the page disables the entry and says why.
// Script entry (instance-scoped) and Replay stay available.
private bool _methodLiveRefused;
private const string MethodLiveRefusedHint =
"Live runs of an inbound API method are not available to a site-scoped Deployer: a method has no site, " +
"so its central-side effects (Database, ExternalSystem, notifications) cannot be bounded by your scope. " +
"Run a site script on a permitted instance, or use Replay.";
private List<ApiMethod> _methods = [];
private List<Site> _sites = [];
@@ -258,7 +269,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
// Script entry: a write kind needs the value to write; a call kind needs a parameter form
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
_entry == EntryKind.Method ? _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
_entry == EntryKind.Method ? !_methodLiveRefused && _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
: SelectedScript is { } s && (s.IsWriteKind
? !string.IsNullOrWhiteSpace(_valueChangeValue)
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
@@ -304,6 +315,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
if (!_canLive)
_mode = FlowMode.Replay;
_methodLiveRefused = _canLive && !await SiteScope.IsSystemWideAsync();
if (_methodLiveRefused)
_entry = EntryKind.Script;
_methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList();
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
@@ -339,7 +353,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
}
}
else if (MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
else if (!_methodLiveRefused && MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
{
_entry = EntryKind.Method;
_selectedMethodId = mid;
@@ -390,7 +404,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
}
}
else
else if (!_methodLiveRefused)
{
var method = await JS.InvokeAsync<string?>("localStorage.getItem", StorageMethod);
if (int.TryParse(method, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mid) && _methods.Any(m => m.Id == mid))
@@ -411,6 +425,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private async Task SetEntryAsync(EntryKind entry)
{
if (_entry == entry) return;
if (entry == EntryKind.Method && _methodLiveRefused) return;
_entry = entry;
ResetResult();
_predicted = null;
@@ -151,6 +151,27 @@ public sealed class FlowDebugRunner
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"ApiMethod with ID {cmd.ApiMethodId} not found.", NotFoundCode);
// Gitea #55 (owner decision (b), 2026-11-09): a site-scoped operator may not Live-run a
// method at all. ApiMethods have no site affinity, so the route-time scope below (F2)
// only bounds routed SITE traffic — the method's central-side effects (Database.*,
// ExternalSystem, notifications) would still run for another plant. Refused before
// anything executes; Replay and the instance-scoped script / value-change Live runs
// stay available to them. The refusal is audited against the operator's name.
if (IsSiteScoped(user))
{
var refusal = MethodLiveRefusedMessage(user);
await WriteCentralAsync(sp, ScadaBridgeAuditEventFactory.Create(
AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Failed,
occurredAtUtc: DateTime.UtcNow,
actor: user.Username,
target: method.Name,
sourceNode: sp.GetService<INodeIdentityProvider>()?.NodeName,
executionId: Guid.NewGuid(),
errorMessage: refusal,
extra: new ScriptTraceExtra { Source = SourceTag }.ToJson()), ct).ConfigureAwait(false);
throw new SiteScopeViolationException(refusal);
}
var resolveRef = await SchemaRefResolver.BuildAsync(
sp.GetService<ISharedSchemaRepository>(), [method.ParameterDefinitions], ct).ConfigureAwait(false);
var validation = ParameterValidator.Validate(cmd.Parameters, method.ParameterDefinitions, resolveRef);
@@ -173,7 +194,9 @@ public sealed class FlowDebugRunner
// Arch-review F2: a method Live run has no single target site to check up front the
// way RunValueChangeAsync does — the operator picks a METHOD, and where it routes is
// only known once the script runs. So the operator's scope is pushed into the
// RouteHelper and enforced at route time, before any site traffic.
// RouteHelper and enforced at route time, before any site traffic. Since Gitea #55 a
// scoped operator is refused above, so this resolves to null (unscoped) for every
// caller that reaches it; it is kept as defence in depth should that gate ever loosen.
var scope = await ResolvePermittedSiteIdentifiersAsync(sp, user, ct).ConfigureAwait(false);
// Gitea #54: a routed site run whose own collector dropped rows reports it here; the flags
// are OR-ed into the result so the page labels the short site lane as truncated.
@@ -708,6 +731,31 @@ public sealed class FlowDebugRunner
}
}
/// <summary>
/// True when <paramref name="user"/> is restricted to a site subset — carries site ids and
/// is not an Administrator — by the same rule <see cref="EnforceSiteScope"/> applies.
/// </summary>
/// <param name="user">The operator.</param>
/// <returns><c>true</c> for a site-scoped operator.</returns>
public static bool IsSiteScoped(AuthenticatedUser user)
{
ArgumentNullException.ThrowIfNull(user);
return user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase);
}
/// <summary>The refusal a site-scoped operator gets for a method-entry Live run (Gitea #55).</summary>
/// <param name="user">The site-scoped operator.</param>
/// <returns>The operator-facing refusal message.</returns>
public static string MethodLiveRefusedMessage(AuthenticatedUser user)
{
ArgumentNullException.ThrowIfNull(user);
return $"Access denied: your Deployment role is scoped to sites [{string.Join(", ", user.PermittedSiteIds)}], " +
"and an inbound API method has no site, so its central-side effects (Database, ExternalSystem, " +
"notifications) cannot be bounded by your scope. Live method runs require a system-wide Deployer " +
"or an Administrator. Replay, and Live runs of a site script on a permitted instance, remain available.";
}
private static void EnforceSiteScope(AuthenticatedUser user, int siteId)
{
if (user.PermittedSiteIds.Length == 0) return;
@@ -167,6 +167,55 @@ public class FlowDebuggerPageTests : BunitContext
Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']"));
}
// ---- Gitea #55: a site-scoped Deployer gets no method-entry Live run -------------------------
/// <summary>Verifies that a site-scoped deployer sees the method entry disabled with an explanation, and starts on script entry.</summary>
[Fact]
public void ScopedDeployer_MethodEntryIsDisabledAndExplained_ScriptEntryStaysAvailable()
{
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "1")]);
var cut = Render<FlowDebuggerPage>();
var method = cut.Find("button[data-test='flow-entry-method']");
Assert.True(method.HasAttribute("disabled"));
Assert.Contains("site-scoped", method.GetAttribute("title"));
Assert.Contains("site-scoped", cut.Find("[data-test='flow-method-live-refused']").TextContent);
// Script entry is the starting entry, so its site picker — not the method select — is shown.
Assert.False(cut.Find("button[data-test='flow-entry-script']").HasAttribute("disabled"));
Assert.NotNull(cut.Find("select[data-test='flow-site-select']"));
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
// Replay stays one click away.
Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']"));
}
/// <summary>Verifies that a site-scoped deployer drilling in with a method id never reaches a runnable method pane.</summary>
[Fact]
public void ScopedDeployer_DrillInWithAMethodId_DoesNotSelectTheMethod()
{
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "1")]);
Services.GetRequiredService<NavigationManager>().NavigateTo("/deployment/flow-debugger?methodId=4");
var cut = Render<FlowDebuggerPage>();
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
_flow.DidNotReceive().PredictMethodAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an unscoped deployer keeps the method entry enabled with no refusal note.</summary>
[Fact]
public void UnscopedDeployer_MethodEntryStaysEnabled()
{
RegisterServices();
var cut = Render<FlowDebuggerPage>();
Assert.False(cut.Find("button[data-test='flow-entry-method']").HasAttribute("disabled"));
Assert.Empty(cut.FindAll("[data-test='flow-method-live-refused']"));
Assert.NotNull(cut.Find("select[data-test='flow-method-select']"));
}
// ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ----------------
/// <summary>Verifies that the page renders replay-only for a viewer, with no live run controls.</summary>
@@ -457,10 +506,17 @@ public class FlowDebuggerPageTests : BunitContext
public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm()
{
// Deployer scoped to site 2 only; the parked call belongs to site-a (id 1).
// A scoped Deployer cannot Live-run a method (Gitea #55), so the parked node is reached through Replay.
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "2")]);
var root = Guid.NewGuid();
var (parked, _, _) = ParkedThenResolved(root);
var cut = RunToParkedNode(root, parked);
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(parked);
Services.GetRequiredService<NavigationManager>().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();
cut.WaitForElement("button[data-test='flow-step-retry']");
cut.Find("button[data-test='flow-step-retry']").Click();
@@ -575,37 +575,74 @@ public class FlowDebugRunnerTests
return sites;
}
/// <summary>Verifies that a site-scoped deployer routing to another site is unauthorized and the inbound row is still written.</summary>
// --- Gitea #55 (owner decision (b)): a site-scoped operator may not Live-run a method at all ---
//
// F2's route-time scope only bounds routed SITE traffic; a method's central-side effects
// (Database.*, ExternalSystem, notifications) have no site to scope against. So a scoped
// operator is refused before the script executes — whether or not it would route in scope.
/// <summary>Verifies that a site-scoped deployer is refused a method Live run, nothing executes, and the refusal is audited.</summary>
/// <param name="routesTo">The site the method would route to — in or out of the operator's scope.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_ScopedDeployer_RouteToOtherSite_IsUnauthorized_AndInboundRowIsWritten()
[Theory]
[InlineData("site-a")] // within scope (id 1 → site-a): still refused, the central effects are unscoped
[InlineData("site-b")] // outside scope
public async Task RunMethod_ScopedDeployer_IsRefused_BeforeTheScriptRuns_AndTheRefusalIsAudited(string routesTo)
{
WireRoutedMethod(11, "inst", "site-b");
WireRoutedMethod(11, "inst", routesTo);
var ran = false;
_executor.RegisterHandler("M", _ => { ran = true; return Task.FromResult<object?>(1); });
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunMethodAsync(
var ex = await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunMethodAsync(
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(11, null), scoped, CancellationToken.None));
// The site was never contacted...
Assert.Equal(FlowDebugRunner.MethodLiveRefusedMessage(scoped), ex.Message);
Assert.Contains("Replay", ex.Message);
Assert.False(ran, "the method script must not execute for a site-scoped operator");
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");
// The refused attempt IS recorded against the operator's name.
var row = Assert.Single(_central, e => e.AsRow().Kind == AuditKind.InboundRequest);
Assert.Equal("ops", row.Actor);
Assert.Equal(AuditStatus.Failed, row.AsRow().Status);
}
/// <summary>Verifies that a site-scoped deployer routing within its scope runs the method.</summary>
/// <summary>Verifies that a site-scoped deployer can still Replay a method execution.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_ScopedDeployer_RouteWithinScope_Runs()
public async Task Replay_ScopedDeployer_IsAllowed()
{
// The same scoped user, routing to a site their numeric id DOES resolve to.
WireRoutedMethod(12, "inst", "site-a");
var repo = Substitute.For<IAuditLogRepository>();
var root = Guid.NewGuid();
var rows = new[]
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, executionId: root),
};
repo.GetTraceEventsAsync(root, Arg.Any<CancellationToken>(), Arg.Is<IReadOnlyList<string>>(s => s.SequenceEqual(new[] { "1" })))
.Returns(new ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.TraceEventBatch(root, rows, false));
_services.AddSingleton(repo);
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
var result = await Runner().RunMethodAsync(
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(12, null), scoped, CancellationToken.None);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(root), CancellationToken.None, scoped);
Assert.True(result.Completed, result.Error);
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
Assert.Equal(rows, result.Events);
}
/// <summary>Verifies that a site-scoped deployer's value-change Live run on a permitted instance still runs (instance-scoped entry is unchanged by #55).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunValueChange_ScopedDeployer_InScopeSite_StillRuns()
{
var (_, _, puller) = WireValueChange();
puller.PullAsync(default!, default!, default, default, default, default).ReturnsForAnyArgs(Array.Empty<AuditEvent>());
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "2" }); // instance 5 lives on site 2
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 3), scoped, CancellationToken.None);
await _router.ReceivedWithAnyArgs(1).RouteToSetAttributesAsync(default!, default!, default);
Assert.Equal(FlowDebugRunner.NoTriggerCode, result.ErrorCode); // ran to the poll, not refused
}
/// <summary>Verifies that an administrator is never restricted by site scope.</summary>