|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #9416 — the resume body refuses a MIS-SHAPED VALUE on an accepted key, and a |
| 5 | + * body that is not a JSON object at all. |
| 6 | + * |
| 7 | + * #8796 closed the resume envelope's KEY set; this is the same silent-drop |
| 8 | + * family one axis over, on the VALUE. The assembly type-guarded each accepted |
| 9 | + * key and skipped whatever failed the guard, so `{"inputs":"a string"}` passed |
| 10 | + * the closed key set (the key IS accepted), lost its value, and answered HTTP |
| 11 | + * 200 `success:true` with the submission treated as EMPTY — the run completed |
| 12 | + * and the caller was told its screen input landed when nothing did. Identical |
| 13 | + * for `{"output":42}`, `{"branchLabel":7}`, a non-object JSON body, and an |
| 14 | + * EMPTY array body (a non-empty one was already refused, because its indices |
| 15 | + * read as unknown keys). |
| 16 | + * |
| 17 | + * Maintainer ruling on the card — **Option A**: refuse, 400, located, naming |
| 18 | + * the key and the expected type; non-object and array bodies refuse the same |
| 19 | + * way. It inherits #8796's ruling together with its reason, plus #3899's |
| 20 | + * toggle-arm precedent (a truthy non-boolean `enabled` is refused there, never |
| 21 | + * coerced or dropped). ⛔ Option B — forward the raw value and let the engine |
| 22 | + * judge — was rejected: `ResumeSignal` types `variables`/`output` as |
| 23 | + * `Record<string, unknown>` and `branchLabel` as `string`, so forwarding hands |
| 24 | + * a service a shape its own contract excludes. |
| 25 | + * |
| 26 | + * BOTH directions are pinned here, because a door that refuses everything ships |
| 27 | + * just as green as a correct one: every refused shape answers 400 naming the |
| 28 | + * key and the expected type, AND every currently-valid submission still |
| 29 | + * succeeds with byte-identical arguments at the service. |
| 30 | + */ |
| 31 | + |
| 32 | +import { describe, it, expect, vi } from 'vitest'; |
| 33 | + |
| 34 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 35 | +import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; |
| 36 | + |
| 37 | +function makeDispatcher() { |
| 38 | + const spies = { |
| 39 | + resume: vi.fn(async () => ({ success: true, output: {}, durationMs: 7 })), |
| 40 | + }; |
| 41 | + const services: Record<string, unknown> = { automation: spies }; |
| 42 | + const resolve = (name: string) => services[name]; |
| 43 | + const kernel: any = { |
| 44 | + getService: resolve, |
| 45 | + getServiceAsync: async (name: string) => resolve(name), |
| 46 | + context: { getService: resolve }, |
| 47 | + }; |
| 48 | + return { dispatcher: new HttpDispatcher(kernel), spies }; |
| 49 | +} |
| 50 | + |
| 51 | +const CTX = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any); |
| 52 | +const RESUME = '/flow_a/runs/run_1/resume'; |
| 53 | + |
| 54 | +/** |
| 55 | + * Drive the resume route with one body and report the refusal as the wire |
| 56 | + * would: the status is the one both dispatcher error exits derive for a thrown |
| 57 | + * validation failure carrying no `.status` of its own (#3918). |
| 58 | + */ |
| 59 | +async function refusalFor(body: unknown) { |
| 60 | + const { dispatcher, spies } = makeDispatcher(); |
| 61 | + let thrown: unknown; |
| 62 | + let response: unknown; |
| 63 | + try { |
| 64 | + response = (await dispatcher.handleAutomation(RESUME, 'POST', body, CTX())).response; |
| 65 | + } catch (e) { |
| 66 | + thrown = e; |
| 67 | + } |
| 68 | + expect( |
| 69 | + thrown, |
| 70 | + `resume body ${JSON.stringify(body)} was accepted (answered ${JSON.stringify(response)}) instead of refused`, |
| 71 | + ).toBeDefined(); |
| 72 | + const details = validationFailureDetails(thrown); |
| 73 | + const status = |
| 74 | + typeof (thrown as any)?.status === 'number' ? (thrown as any).status |
| 75 | + : details ? VALIDATION_FAILED_STATUS |
| 76 | + : 500; |
| 77 | + return { details, status, message: (thrown as Error).message, code: (thrown as any).code, spies }; |
| 78 | +} |
| 79 | + |
| 80 | +/** Drive the resume route to its 200 and hand back what the service received. */ |
| 81 | +async function acceptedFor(body: unknown) { |
| 82 | + const { dispatcher, spies } = makeDispatcher(); |
| 83 | + const result = await dispatcher.handleAutomation(RESUME, 'POST', body, CTX()); |
| 84 | + return { status: result.response?.status, spies }; |
| 85 | +} |
| 86 | + |
| 87 | +// ───────────────────────────────────────────────────────────────────────────── |
| 88 | +// Direction 1 — the refusal |
| 89 | +// ───────────────────────────────────────────────────────────────────────────── |
| 90 | + |
| 91 | +describe('#9416 — a type-mismatched value on an accepted key is refused, not dropped', () => { |
| 92 | + // The card's measured shapes, plus the two JSON values that used to vanish |
| 93 | + // most quietly: an explicit `null`, and an array (which the old |
| 94 | + // `typeof === "object"` guard forwarded to a contract that excludes it). |
| 95 | + const OBJECT_KEYS = ['inputs', 'variables', 'output'] as const; |
| 96 | + const NON_OBJECTS: Array<[string, unknown]> = [ |
| 97 | + ['a string', 'a string'], |
| 98 | + ['a number', 42], |
| 99 | + ['a boolean', true], |
| 100 | + ['null', null], |
| 101 | + ['an array', [1, 2]], |
| 102 | + ]; |
| 103 | + |
| 104 | + describe.each(OBJECT_KEYS)('`%s` must be an object', (key) => { |
| 105 | + it.each(NON_OBJECTS)('refuses %s — 400, located, naming the expected type', async (_label, value) => { |
| 106 | + const r = await refusalFor({ [key]: value }); |
| 107 | + expect(r.status).toBe(VALIDATION_FAILED_STATUS); |
| 108 | + expect(r.details?.code).toBe('VALIDATION_FAILED'); |
| 109 | + // Located: the offending key is named, with an ADR-0114 catalog code. |
| 110 | + expect(r.details?.fields).toMatchObject([{ field: key, code: 'invalid_type' }]); |
| 111 | + // …and the EXPECTED TYPE is named, in the field entry and the message. |
| 112 | + expect((r.details?.fields[0] as any).message).toMatch(/expected an object/); |
| 113 | + expect(r.message).toMatch(new RegExp(`\`${key}\``)); |
| 114 | + expect(r.message).toMatch(/expected an object/); |
| 115 | + // The suspension was never consulted, so a corrected retry is legitimate. |
| 116 | + expect(r.spies.resume).not.toHaveBeenCalled(); |
| 117 | + }); |
| 118 | + }); |
| 119 | + |
| 120 | + const NON_STRINGS: Array<[string, unknown]> = [ |
| 121 | + ['a number', 7], |
| 122 | + ['a boolean', false], |
| 123 | + ['null', null], |
| 124 | + ['an object', { label: 'approve' }], |
| 125 | + ['an array', ['approve']], |
| 126 | + ]; |
| 127 | + |
| 128 | + it.each(NON_STRINGS)('`branchLabel` must be a string — refuses %s', async (_label, value) => { |
| 129 | + const r = await refusalFor({ branchLabel: value }); |
| 130 | + expect(r.status).toBe(VALIDATION_FAILED_STATUS); |
| 131 | + expect(r.details?.fields).toMatchObject([{ field: 'branchLabel', code: 'invalid_type' }]); |
| 132 | + expect((r.details?.fields[0] as any).message).toMatch(/expected a string/); |
| 133 | + expect(r.message).toMatch(/`branchLabel`/); |
| 134 | + expect(r.message).toMatch(/expected a string/); |
| 135 | + expect(r.spies.resume).not.toHaveBeenCalled(); |
| 136 | + }); |
| 137 | + |
| 138 | + it('names EVERY mis-shaped key, not just the first', async () => { |
| 139 | + const r = await refusalFor({ inputs: 'a string', output: 42, branchLabel: 7 }); |
| 140 | + expect(r.details?.fields).toMatchObject([ |
| 141 | + { field: 'inputs', code: 'invalid_type' }, |
| 142 | + { field: 'output', code: 'invalid_type' }, |
| 143 | + { field: 'branchLabel', code: 'invalid_type' }, |
| 144 | + ]); |
| 145 | + expect(r.message).toMatch(/`inputs`/); |
| 146 | + expect(r.message).toMatch(/`output`/); |
| 147 | + expect(r.message).toMatch(/`branchLabel`/); |
| 148 | + }); |
| 149 | + |
| 150 | + it('reports the received type too, so the caller sees what it sent', async () => { |
| 151 | + expect((await refusalFor({ inputs: 'x' })).message).toMatch(/received a string/); |
| 152 | + expect((await refusalFor({ output: 42 })).message).toMatch(/received a number/); |
| 153 | + expect((await refusalFor({ inputs: null })).message).toMatch(/received null/); |
| 154 | + expect((await refusalFor({ output: [] })).message).toMatch(/received an array/); |
| 155 | + expect((await refusalFor({ branchLabel: 7 })).message).toMatch(/received a number/); |
| 156 | + }); |
| 157 | + |
| 158 | + it('stays off FLOW_FAILED — this refusal leaves the suspension live', async () => { |
| 159 | + // ⚠️ #8684 hazard pin: the console treats 400 FLOW_FAILED as terminal |
| 160 | + // (the wizard closes — objectui PR #4899). The engine was never |
| 161 | + // consulted here, so the pause is intact and the caller can retry. |
| 162 | + const r = await refusalFor({ inputs: 'a string' }); |
| 163 | + expect(r.code).toBe('VALIDATION_FAILED'); |
| 164 | + expect(r.code).not.toBe('FLOW_FAILED'); |
| 165 | + }); |
| 166 | + |
| 167 | + it('escapes dispatch() as the recognized validation-failure shape', async () => { |
| 168 | + const { dispatcher, spies } = makeDispatcher(); |
| 169 | + (dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'user_1' }); |
| 170 | + let thrown: unknown; |
| 171 | + try { |
| 172 | + await dispatcher.dispatch( |
| 173 | + 'POST', '/automation/flow_a/runs/run_1/resume', |
| 174 | + { inputs: 'a string' }, {}, {} as any, |
| 175 | + ); |
| 176 | + } catch (e) { |
| 177 | + thrown = e; |
| 178 | + } |
| 179 | + expect(thrown, 'the refusal must reach the HTTP error exits').toBeDefined(); |
| 180 | + expect(validationFailureDetails(thrown)?.code).toBe('VALIDATION_FAILED'); |
| 181 | + expect(validationFailureDetails(thrown)?.fields).toMatchObject([{ field: 'inputs' }]); |
| 182 | + expect(spies.resume).not.toHaveBeenCalled(); |
| 183 | + }); |
| 184 | + |
| 185 | + it('refuses the mis-shaped value even when a sibling key is perfectly valid', async () => { |
| 186 | + // The half-wrong body must not be silently half-dropped — the same |
| 187 | + // reasoning #8796 used to decline "refuse only when nothing is |
| 188 | + // recognized". |
| 189 | + const r = await refusalFor({ inputs: { real: 'value' }, branchLabel: 7 }); |
| 190 | + expect(r.details?.fields).toMatchObject([{ field: 'branchLabel', code: 'invalid_type' }]); |
| 191 | + expect(r.spies.resume).not.toHaveBeenCalled(); |
| 192 | + }); |
| 193 | + |
| 194 | + it('reports an unknown KEY ahead of a mis-shaped value — #8796 message unchanged', async () => { |
| 195 | + // Ordering pin: a body that is both misspelled and mis-shaped still |
| 196 | + // reports the misspelling, which is the correction the caller needs |
| 197 | + // first and the one #8796 pinned. |
| 198 | + const r = await refusalFor({ inputs: 'a string', values: { x: 1 } }); |
| 199 | + expect(r.details?.fields).toMatchObject([{ field: 'values', code: 'unknown_field' }]); |
| 200 | + expect(r.message).toMatch(/`values`/); |
| 201 | + }); |
| 202 | +}); |
| 203 | + |
| 204 | +describe('#9416 — a body that is not a JSON object is refused', () => { |
| 205 | + const NON_OBJECT_BODIES: Array<[string, unknown]> = [ |
| 206 | + ['a JSON string', 'a string'], |
| 207 | + ['a JSON number', 42], |
| 208 | + ['a JSON boolean', true], |
| 209 | + ['an EMPTY array', []], |
| 210 | + ]; |
| 211 | + |
| 212 | + it.each(NON_OBJECT_BODIES)('refuses %s — 400 at `(body)`, naming the accepted keys', async (_label, body) => { |
| 213 | + const r = await refusalFor(body); |
| 214 | + expect(r.status).toBe(VALIDATION_FAILED_STATUS); |
| 215 | + expect(r.details?.code).toBe('VALIDATION_FAILED'); |
| 216 | + // `(body)` is the root-level locator — a body of the wrong type has no |
| 217 | + // path to point at (`fieldsFromZodIssues`' own convention). |
| 218 | + expect(r.details?.fields).toMatchObject([{ field: '(body)', code: 'invalid_type' }]); |
| 219 | + expect(r.message).toMatch(/expected an object/); |
| 220 | + expect(r.message).toMatch(/`inputs`/); |
| 221 | + expect(r.message).toMatch(/`branchLabel`/); |
| 222 | + expect(r.spies.resume).not.toHaveBeenCalled(); |
| 223 | + }); |
| 224 | + |
| 225 | + it('refuses a NON-empty array too — the #8796 arm keeps working, now located at `(body)`', async () => { |
| 226 | + // Previously caught by the key check (indices read as unknown keys); |
| 227 | + // now caught one step earlier, by the shape it actually is. |
| 228 | + const r = await refusalFor([{ inputs: {} }]); |
| 229 | + expect(r.status).toBe(VALIDATION_FAILED_STATUS); |
| 230 | + expect(r.details?.fields).toMatchObject([{ field: '(body)', code: 'invalid_type' }]); |
| 231 | + expect(r.spies.resume).not.toHaveBeenCalled(); |
| 232 | + }); |
| 233 | +}); |
| 234 | + |
| 235 | +// ───────────────────────────────────────────────────────────────────────────── |
| 236 | +// Direction 2 — the half a regression does not redden: everything valid still |
| 237 | +// works, with the arguments the service always received. |
| 238 | +// ───────────────────────────────────────────────────────────────────────────── |
| 239 | + |
| 240 | +describe('#9416 — every currently-valid submission still succeeds, unchanged', () => { |
| 241 | + it('forwards all four accepted keys exactly as before', async () => { |
| 242 | + const { status, spies } = await acceptedFor({ |
| 243 | + inputs: { new_assignee: 'ada' }, |
| 244 | + output: { comment: 'ok' }, |
| 245 | + branchLabel: 'approve', |
| 246 | + }); |
| 247 | + expect(status).toBe(200); |
| 248 | + expect(spies.resume).toHaveBeenCalledWith('run_1', { |
| 249 | + variables: { new_assignee: 'ada' }, |
| 250 | + output: { comment: 'ok' }, |
| 251 | + branchLabel: 'approve', |
| 252 | + }); |
| 253 | + }); |
| 254 | + |
| 255 | + it('keeps the `variables` alias, and `inputs` still wins when both are sent', async () => { |
| 256 | + const alias = await acceptedFor({ variables: { note: 'hi' } }); |
| 257 | + expect(alias.status).toBe(200); |
| 258 | + expect(alias.spies.resume).toHaveBeenCalledWith('run_1', { variables: { note: 'hi' } }); |
| 259 | + |
| 260 | + const both = await acceptedFor({ inputs: { a: 1 }, variables: { b: 2 } }); |
| 261 | + expect(both.status).toBe(200); |
| 262 | + expect(both.spies.resume).toHaveBeenCalledWith('run_1', { variables: { a: 1 } }); |
| 263 | + }); |
| 264 | + |
| 265 | + it.each([ |
| 266 | + ['empty object', {}], |
| 267 | + ['undefined body', undefined], |
| 268 | + ['null body', null], |
| 269 | + ])('still accepts %s as an empty submission', async (_label, body) => { |
| 270 | + // The bodyless resume is legal (a screen whose declared fields are all |
| 271 | + // optional). The refusal is about a value that is WRONG, never about a |
| 272 | + // value that is absent. |
| 273 | + const { status, spies } = await acceptedFor(body); |
| 274 | + expect(status).toBe(200); |
| 275 | + expect(spies.resume).toHaveBeenCalledWith('run_1', {}); |
| 276 | + }); |
| 277 | + |
| 278 | + it.each([ |
| 279 | + ['an empty inputs object', { inputs: {} }, { variables: {} }], |
| 280 | + ['an empty output object', { output: {} }, { output: {} }], |
| 281 | + ['an empty-string branchLabel', { branchLabel: '' }, { branchLabel: '' }], |
| 282 | + ])('accepts %s — empty is a legal value of the right type', async (_label, body, expected) => { |
| 283 | + const { status, spies } = await acceptedFor(body); |
| 284 | + expect(status).toBe(200); |
| 285 | + expect(spies.resume).toHaveBeenCalledWith('run_1', expected); |
| 286 | + }); |
| 287 | + |
| 288 | + it('treats an `undefined` value as ABSENT, not mis-shaped', async () => { |
| 289 | + // `JSON.stringify` drops such a key, so no HTTP caller can produce |
| 290 | + // one; the in-process spelling `{ inputs: maybeUndefined }` means "no |
| 291 | + // inputs" and must not become a 400. |
| 292 | + const { status, spies } = await acceptedFor({ inputs: undefined, branchLabel: undefined }); |
| 293 | + expect(status).toBe(200); |
| 294 | + expect(spies.resume).toHaveBeenCalledWith('run_1', {}); |
| 295 | + }); |
| 296 | + |
| 297 | + it('still forwards the INNER bag verbatim — the engine, not the route, judges its contents', async () => { |
| 298 | + // The refusal is about the value's TYPE only. Reserved-name and |
| 299 | + // declared-field verdicts stay in the engine (#3853, #4477), at the one |
| 300 | + // place a signal reaches the variable map. |
| 301 | + const { status, spies } = await acceptedFor({ |
| 302 | + inputs: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3, nested: { deep: [1, 2] } }, |
| 303 | + output: { decision: 'ok', $internal: true }, |
| 304 | + }); |
| 305 | + expect(status).toBe(200); |
| 306 | + expect(spies.resume).toHaveBeenCalledWith('run_1', { |
| 307 | + variables: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3, nested: { deep: [1, 2] } }, |
| 308 | + output: { decision: 'ok', $internal: true }, |
| 309 | + }); |
| 310 | + }); |
| 311 | +}); |
0 commit comments