|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25820] The `:name` segment of every `/automation/:name/runs/:runId/*` door |
| 5 | + * is a SCOPE the door checks, not a decoration it ignores. |
| 6 | + * |
| 7 | + * Every one of these doors reads the run by `runId` alone, so before this card |
| 8 | + * `POST /automation/OTHER_FLOW/runs/RUNID/resume` completed another flow's |
| 9 | + * paused run, and `GET /automation/OTHER_FLOW/runs/RUNID` served it. The URL |
| 10 | + * claimed a scope the server never checked: a caller holding a stale or |
| 11 | + * mixed-up runId acted on the wrong flow's run, silently, and the audit trail |
| 12 | + * read as if the named flow had been acted on. |
| 13 | + * |
| 14 | + * The ruling (triage 6399818476): a flow name that exists but does not own the |
| 15 | + * run answers what an UNKNOWN name answers — `404 RUN_NOT_FOUND` on the resume |
| 16 | + * and run-read doors — byte for byte. Each door below is held to the same |
| 17 | + * sentence: "exists but not the owner" ≡ "unknown flow" ≡ "unknown run", where |
| 18 | + * the last is that door's own not-found answer for a runId nobody holds. The |
| 19 | + * fake engine answers an unknown run in the real engine's spelling |
| 20 | + * (`service-automation` `run-resume.ts` / `consumed-suspension-restore.ts`). |
| 21 | + * |
| 22 | + * ⛔ Not an authorization change. Who may act is still decided where it was |
| 23 | + * (`refuseUnrelatedResume`, the #7900 read gate, the #13953 operator gate); the |
| 24 | + * scope check runs after those, immediately before the verb, so a mismatched |
| 25 | + * request consumes nothing: the run stays paused and nothing is written. |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, it, expect, vi } from 'vitest'; |
| 29 | + |
| 30 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 31 | +import type { HttpProtocolContext } from '../http-dispatcher.js'; |
| 32 | + |
| 33 | +const OWNER = 'showcase_reassign_wizard'; |
| 34 | +const OTHER = 'showcase_project_closure'; |
| 35 | +const UNKNOWN = 'no_such_flow'; |
| 36 | +const RUN = 'run_wizard_1'; |
| 37 | +const NOBODYS_RUN = 'run_nobody_holds'; |
| 38 | + |
| 39 | +interface HarnessOptions { |
| 40 | + /** `getRun` answers `null` for every run, as after a degraded durable read. */ |
| 41 | + getRunBlind?: boolean; |
| 42 | + /** Neither `getRun` nor `listSuspendedRuns` is mounted. */ |
| 43 | + noOwnerProbe?: boolean; |
| 44 | +} |
| 45 | + |
| 46 | +function makeHarness(options: HarnessOptions = {}) { |
| 47 | + const runs = new Map([[RUN, { |
| 48 | + id: RUN, |
| 49 | + flowName: OWNER, |
| 50 | + status: 'paused', |
| 51 | + trigger: { type: 'api', userId: 'usr_admin' }, |
| 52 | + startedAt: '2026-10-26T00:00:00.000Z', |
| 53 | + }]]); |
| 54 | + const flows = new Set([OWNER, OTHER]); |
| 55 | + const screen = { title: 'Reassign', fields: [{ name: 'new_assignee', type: 'text' }] }; |
| 56 | + |
| 57 | + const resume = vi.fn(async (runId: string) => (runs.has(runId) |
| 58 | + ? { success: true, runId, output: { done: true } } |
| 59 | + : { success: false, code: 'RUN_NOT_FOUND', error: `No suspended run '${runId}'` })); |
| 60 | + const cancelRun = vi.fn(async (runId: string) => runs.has(runId)); |
| 61 | + const restoreConsumedSuspension = vi.fn(async (runId: string) => (runs.has(runId) |
| 62 | + ? { restored: true, runId, reason: 'Suspension restored.' } |
| 63 | + : { restored: false, runId, refusal: 'RUN_NOT_FOUND', reason: `No run '${runId}' is known` })); |
| 64 | + const getSuspendedScreen = vi.fn(async (runId: string) => (runs.has(runId) ? screen : null)); |
| 65 | + |
| 66 | + const automation: Record<string, unknown> = { |
| 67 | + handlerReady: true, |
| 68 | + execute: vi.fn(), |
| 69 | + resume, |
| 70 | + cancelRun, |
| 71 | + restoreConsumedSuspension, |
| 72 | + getSuspendedScreen, |
| 73 | + listFlows: async () => [...flows], |
| 74 | + getFlow: async (name: string) => (flows.has(name) ? { name, nodes: [] } : null), |
| 75 | + }; |
| 76 | + if (!options.noOwnerProbe) { |
| 77 | + automation.getRun = async (runId: string) => (options.getRunBlind ? null : runs.get(runId) ?? null); |
| 78 | + automation.listSuspendedRuns = () => [...runs.values()].map((r) => ({ runId: r.id, flowName: r.flowName, nodeId: 'collect' })); |
| 79 | + } |
| 80 | + |
| 81 | + const services: Record<string, unknown> = { automation }; |
| 82 | + const resolve = (name: string): unknown => services[name]; |
| 83 | + const kernel = { |
| 84 | + getService: resolve, |
| 85 | + getServiceAsync: async (name: string) => resolve(name), |
| 86 | + context: { getService: resolve, trigger: async () => undefined }, |
| 87 | + }; |
| 88 | + return { |
| 89 | + dispatcher: new HttpDispatcher(kernel as never), |
| 90 | + resume, |
| 91 | + cancelRun, |
| 92 | + restoreConsumedSuspension, |
| 93 | + }; |
| 94 | +} |
| 95 | + |
| 96 | +/** Clears every gate the doors keep (#7900 read, #19987 resume, #13953 operator). */ |
| 97 | +const ADMIN_CTX = (): HttpProtocolContext => |
| 98 | + ({ request: {}, executionContext: { userId: 'usr_admin', posture: 'PLATFORM_ADMIN' } } as HttpProtocolContext); |
| 99 | + |
| 100 | +interface Door { |
| 101 | + label: string; |
| 102 | + method: 'GET' | 'POST'; |
| 103 | + path: (flow: string, runId: string) => string; |
| 104 | + body?: unknown; |
| 105 | + /** The verb that changes state; asserted never called on a mismatch. */ |
| 106 | + verb?: 'resume' | 'cancelRun' | 'restoreConsumedSuspension'; |
| 107 | + /** The door's own success status for the owning flow. */ |
| 108 | + okStatus: number; |
| 109 | +} |
| 110 | + |
| 111 | +const DOORS: Door[] = [ |
| 112 | + { |
| 113 | + label: 'POST /:name/runs/:runId/resume', |
| 114 | + method: 'POST', |
| 115 | + path: (f, r) => `${f}/runs/${r}/resume`, |
| 116 | + body: { inputs: { new_assignee: 'usr_b' } }, |
| 117 | + verb: 'resume', |
| 118 | + okStatus: 200, |
| 119 | + }, |
| 120 | + { label: 'GET /:name/runs/:runId', method: 'GET', path: (f, r) => `${f}/runs/${r}`, okStatus: 200 }, |
| 121 | + { label: 'GET /:name/runs/:runId/screen', method: 'GET', path: (f, r) => `${f}/runs/${r}/screen`, okStatus: 200 }, |
| 122 | + { |
| 123 | + label: 'POST /:name/runs/:runId/cancel', |
| 124 | + method: 'POST', |
| 125 | + path: (f, r) => `${f}/runs/${r}/cancel`, |
| 126 | + verb: 'cancelRun', |
| 127 | + okStatus: 200, |
| 128 | + }, |
| 129 | + { |
| 130 | + label: 'POST /:name/runs/:runId/restore-suspension', |
| 131 | + method: 'POST', |
| 132 | + path: (f, r) => `${f}/runs/${r}/restore-suspension`, |
| 133 | + verb: 'restoreConsumedSuspension', |
| 134 | + okStatus: 200, |
| 135 | + }, |
| 136 | +]; |
| 137 | + |
| 138 | +async function send(h: ReturnType<typeof makeHarness>, door: Door, flow: string, runId: string) { |
| 139 | + const { response } = await h.dispatcher.handleAutomation(door.path(flow, runId), door.method, door.body, ADMIN_CTX(), {}); |
| 140 | + return { status: (response as any)?.status, body: (response as any)?.body }; |
| 141 | +} |
| 142 | + |
| 143 | +describe('#25820 — a flow name that does not own the run is answered as an unknown one', () => { |
| 144 | + for (const door of DOORS) { |
| 145 | + describe(door.label, () => { |
| 146 | + it('a flow that exists but does not own the run answers exactly what an unknown flow name answers', async () => { |
| 147 | + const h = makeHarness(); |
| 148 | + const mismatched = await send(h, door, OTHER, RUN); |
| 149 | + const unknownFlow = await send(h, door, UNKNOWN, RUN); |
| 150 | + expect(mismatched).toEqual(unknownFlow); |
| 151 | + }); |
| 152 | + |
| 153 | + it('…which is exactly the door\'s answer for a run nobody holds — no existence oracle', async () => { |
| 154 | + const h = makeHarness(); |
| 155 | + const mismatched = await send(h, door, OTHER, RUN); |
| 156 | + const nobodys = await send(h, door, OWNER, NOBODYS_RUN); |
| 157 | + // The runId is echoed in some answers; compare with it named. |
| 158 | + expect(JSON.stringify(mismatched).split(RUN).join('RUNID')) |
| 159 | + .toBe(JSON.stringify(nobodys).split(NOBODYS_RUN).join('RUNID')); |
| 160 | + }); |
| 161 | + |
| 162 | + if (door.verb) { |
| 163 | + it('a mismatched request reaches no state-changing verb', async () => { |
| 164 | + const h = makeHarness(); |
| 165 | + await send(h, door, OTHER, RUN); |
| 166 | + await send(h, door, UNKNOWN, RUN); |
| 167 | + expect(h[door.verb!]).not.toHaveBeenCalled(); |
| 168 | + }); |
| 169 | + } |
| 170 | + |
| 171 | + it('control: the owning flow name is served as before', async () => { |
| 172 | + const h = makeHarness(); |
| 173 | + const owned = await send(h, door, OWNER, RUN); |
| 174 | + expect(owned.status).toBe(door.okStatus); |
| 175 | + if (door.verb) { |
| 176 | + expect(h[door.verb]).toHaveBeenCalledTimes(1); |
| 177 | + expect(h[door.verb].mock.calls[0]?.[0]).toBe(RUN); |
| 178 | + } |
| 179 | + }); |
| 180 | + |
| 181 | + it('the owner is still checked when `getRun` cannot see the run, through the suspended-run list', async () => { |
| 182 | + const h = makeHarness({ getRunBlind: true }); |
| 183 | + const mismatched = await send(h, door, OTHER, RUN); |
| 184 | + const unknownFlow = await send(h, door, UNKNOWN, RUN); |
| 185 | + expect(mismatched).toEqual(unknownFlow); |
| 186 | + if (door.verb) expect(h[door.verb]).not.toHaveBeenCalled(); |
| 187 | + }); |
| 188 | + }); |
| 189 | + } |
| 190 | + |
| 191 | + it('resume and run-read answer the mismatch 404 RUN_NOT_FOUND (the ruling, by code and status)', async () => { |
| 192 | + const h = makeHarness(); |
| 193 | + const resumed = await send(h, DOORS[0], OTHER, RUN); |
| 194 | + expect(resumed.status).toBe(404); |
| 195 | + expect(resumed.body?.error?.code).toBe('RUN_NOT_FOUND'); |
| 196 | + const read = await send(h, DOORS[1], OTHER, RUN); |
| 197 | + expect(read.status).toBe(404); |
| 198 | + expect(read.body?.error?.code).toBe('RUN_NOT_FOUND'); |
| 199 | + }); |
| 200 | + |
| 201 | + it('a service that cannot name a run\'s flow keeps today\'s answer: the door cannot prove a mismatch', async () => { |
| 202 | + const h = makeHarness({ noOwnerProbe: true }); |
| 203 | + const resumed = await send(h, DOORS[0], OTHER, RUN); |
| 204 | + expect(resumed.status).toBe(200); |
| 205 | + expect(h.resume).toHaveBeenCalledTimes(1); |
| 206 | + }); |
| 207 | +}); |
0 commit comments