diff --git a/packages/sdk/src/cloud-answer.ts b/packages/sdk/src/cloud-answer.ts index bbfa098c..f7a6d165 100644 --- a/packages/sdk/src/cloud-answer.ts +++ b/packages/sdk/src/cloud-answer.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { CloudFlowError, cloudFetch, cloudRunId, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js'; import { cloudRunState } from './cloud-run-record.js'; import { readCloudHumanWaitValue } from './cloud-human-wait.js'; +import { readCloudRepositoryGrant } from './cloud-repository-grant.js'; import { readSurfaceAuthority, type SurfaceModuleAuthority } from './authored-flow-loader.js'; import type { CloudAuthoredAuthority } from './cloud-run.js'; @@ -29,24 +30,32 @@ export class CloudAnswerError extends Error { } } +interface CloudRecordedHumanAnswer { + waitId: string; + answer: boolean; + note?: string; +} + const route = (runId: string): string => `/api/v1/workflows/runs/${runId}`; const get = (path: string, options: CloudConnectionOptions): Promise => cloudFetch(path, options, { method: 'GET', detail: true }); const post = (path: string, body: unknown, options: CloudConnectionOptions): Promise => cloudFetch(path, options, { method: 'POST', body: JSON.stringify(body), detail: true }); -/** - * Accepted answer route shapes are explicit: humanWait (or wait), or openWaits, - * and an optional recorded answer { waitId, answer, note? }. Unknown/ambiguous - * shapes refuse before writes. A closed wait is retryable only with that proof. - */ -export async function readCloudHumanWait(runId: string, options: CloudConnectionOptions = {}) { - cloudRunId(runId); - const body = await get(`${route(runId)}/answer`, options); +function readCloudHumanAnswerRecord(body: unknown, runId: string): { + wait?: ReturnType; + recorded?: CloudRecordedHumanAnswer; +} { if (!isCloudRecord(body)) throw new CloudFlowError('invalid_response', 'Cloud returned no human answer record.'); - if (body.runId !== undefined && body.runId !== runId) { + if (body.runId !== runId) { throw new CloudFlowError('invalid_response', 'Cloud returned a human answer record for a different run.'); } + if (body.error !== undefined && body.error !== null) { + throw new CloudFlowError('invalid_response', 'Cloud returned an error beside a human answer record.'); + } + if (body.ok !== undefined && body.ok !== true) { + throw new CloudFlowError('invalid_response', 'Cloud explicitly refused the human answer record.'); + } // An absent and a null field mean the same thing everywhere below. const present = (key: string): boolean => body[key] !== undefined && body[key] !== null; if (['humanWait', 'wait', 'openWaits'].filter(present).length > 1) { @@ -59,12 +68,18 @@ export async function readCloudHumanWait(runId: string, options: CloudConnection } const wait = readCloudHumanWaitValue(candidates[0]); if (candidates.length === 1 && !wait) throw new CloudFlowError('invalid_response', 'Cloud returned an invalid open human wait.'); - const recorded = body.answer; + if (['humanAnswer', 'answer'].filter(present).length > 1) { + throw new CloudFlowError('invalid_response', 'Cloud returned competing recorded human answer fields.'); + } + const recorded = body.humanAnswer ?? body.answer; if (recorded !== undefined && recorded !== null && (!isCloudRecord(recorded) || !readCloudHumanWaitValue(recorded) || typeof recorded.answer !== 'boolean' || (recorded.note !== undefined && recorded.note !== null && typeof recorded.note !== 'string'))) { throw new CloudFlowError('invalid_response', 'Cloud returned an invalid recorded human answer.'); } + if (wait && isCloudRecord(recorded) && recorded.waitId !== wait.waitId) { + throw new CloudFlowError('invalid_response', 'Cloud returned a human answer for a different wait.'); + } return { wait, recorded: isCloudRecord(recorded) // Like the wait fields, a null note means absent. ? { waitId: recorded.waitId as string, answer: recorded.answer as boolean, @@ -72,6 +87,30 @@ export async function readCloudHumanWait(runId: string, options: CloudConnection : undefined }; } +/** + * Accepted answer route shapes are explicit: humanWait (or wait), or openWaits, + * and an optional recorded humanAnswer (legacy: answer) { waitId, answer, note? }. + * Unknown fields are additive, but competing or cross-run/wait shapes refuse. + */ +export async function readCloudHumanWait(runId: string, options: CloudConnectionOptions = {}) { + cloudRunId(runId); + return readCloudHumanAnswerRecord(await get(`${route(runId)}/answer`, options), runId); +} + +function normalizedAnswerNote(note: string | undefined): string | undefined { + const normalized = note?.trim(); + return normalized === undefined || normalized.length === 0 ? undefined : normalized; +} + +function sameRecordedAnswer( + recorded: CloudRecordedHumanAnswer, + waitId: string, + answer: boolean, + note: string | undefined, +): boolean { + return recorded.waitId === waitId && recorded.answer === answer && recorded.note === note; +} + const HEX64 = /^[a-f0-9]{64}$/u; function isSurfaceAuthority(value: unknown): value is SurfaceModuleAuthority { @@ -125,6 +164,10 @@ async function resumeBody(record: Record, runId: string, source throw new CloudFlowError('unsupported_source', 'Cannot resume a run with extensions: Cloud must expose their original submissions.'); } } + const repositoryGrant = readCloudRepositoryGrant(authority.repository); + if (authority.repository !== undefined && repositoryGrant === undefined) { + throw new CloudFlowError('invalid_response', 'Cloud run carries an invalid repository grant; refusing to widen or alter it.'); + } let workflow = record.workflow; if (source !== undefined) { try { workflow = await readFile(source, 'utf8'); } @@ -144,6 +187,7 @@ async function resumeBody(record: Record, runId: string, source surface: await resumeSurface(authority.source.surface, source), }); return { workflow, authoredAuthority, fileType: 'ts', relayflowVersion: 'v2', resume: runId, + ...(repositoryGrant === undefined ? {} : { repositoryGrant }), ...(typeof record.workspaceId === 'string' ? { workspaceId: record.workspaceId } : {}) }; } @@ -160,18 +204,19 @@ export async function answerCloudFlow(runId: string, answer: boolean, options: C if (!waitId || (state.humanWait && state.humanWait.waitId !== waitId)) { throw new CloudFlowError('invalid_response', 'Cloud answer record does not identify the run’s parked wait.'); } - // A recorded answer that omits its optional note does not contradict --note. - if (recorded && (recorded.answer !== answer - || (recorded.note !== undefined && recorded.note !== options.note))) { + const note = normalizedAnswerNote(options.note); + if (recorded && !sameRecordedAnswer(recorded, waitId, answer, note)) { throw new CloudFlowError('invalid_input', 'The recorded answer differs; a closed human wait cannot be changed.'); } const body = await resumeBody(record as Record, runId, options.source); let answerRecorded = recorded !== undefined; try { if (!answerRecorded) { - const accepted = await post(`${route(runId)}/answer`, { waitId, answer, ...(options.note === undefined ? {} : { note: options.note }) }, options); - // Only an explicit acknowledgement counts; {} or { ok: null } is not a recorded decision. - if (!isCloudRecord(accepted) || accepted.ok !== true || accepted.error !== undefined) { + const accepted = await post(`${route(runId)}/answer`, { waitId, answer, ...(note === undefined ? {} : { note }) }, options); + const acknowledged = readCloudHumanAnswerRecord(accepted, runId); + if (!acknowledged.wait || !acknowledged.recorded + || acknowledged.wait.waitId !== waitId + || !sameRecordedAnswer(acknowledged.recorded, waitId, answer, note)) { throw new CloudFlowError('invalid_response', 'Cloud did not confirm the human answer.'); } answerRecorded = true; diff --git a/packages/sdk/src/cloud-repository-grant.ts b/packages/sdk/src/cloud-repository-grant.ts new file mode 100644 index 00000000..0d161a1b --- /dev/null +++ b/packages/sdk/src/cloud-repository-grant.ts @@ -0,0 +1,77 @@ +import { isCloudRecord } from './cloud-http.js'; + +export interface CloudRepositoryGrant { + host?: 'gitlab'; + owner: string; + repo: string; + baseBranch: string; + baseSha: string; + branch: string; + mode?: 'head'; + workflows?: true; + access?: 'read'; +} + +const coordinate = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/u; + +function validGitlabNamespace(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 255) return false; + const parts = value.split('/'); + return parts.length >= 1 && parts.length <= 20 && parts.every(part => ( + coordinate.test(part) && !part.endsWith('.') && !part.endsWith('.git') && !part.endsWith('.atom') + )); +} + +function validRepositoryBranch(value: unknown): value is string { + return typeof value === 'string' + && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/u.test(value) + && value !== 'HEAD' + && !value.includes('..') + && !value.includes('//') + && value.split('/').every(part => ( + part !== '' && !part.startsWith('.') && !part.endsWith('.') && !part.endsWith('.lock') + )); +} + +/** + * Rebuild the request portion of persisted Cloud repository authority. + * Cloud re-admits this request against the persisted identity and current + * connection; the SDK must neither infer missing authority nor widen it. + */ +export function readCloudRepositoryGrant(value: unknown): CloudRepositoryGrant | undefined { + if (value === undefined) return undefined; + if (!isCloudRecord(value)) return undefined; + const gitlab = value.host === 'gitlab'; + if ((value.host !== undefined && value.host !== 'github' && value.host !== 'gitlab') + || typeof value.owner !== 'string' + || !(gitlab ? validGitlabNamespace(value.owner) : coordinate.test(value.owner)) + || typeof value.repo !== 'string' + || !/^[A-Za-z0-9_.-]{1,100}$/u.test(value.repo) + || value.repo === '.' || value.repo === '..' + || (gitlab && (value.repo.endsWith('.git') || value.repo.endsWith('.atom') || value.repo.endsWith('.'))) + || !validRepositoryBranch(value.baseBranch) + || !validRepositoryBranch(value.branch) + || (value.mode !== undefined && value.mode !== 'new' && value.mode !== 'head') + || (value.workflows !== undefined && typeof value.workflows !== 'boolean') + || (gitlab && value.workflows === true) + || (value.access !== undefined && value.access !== 'read') + || (value.access === 'read' && value.workflows === true) + || (gitlab && value.access === 'read') + || (value.mode !== 'head' && !value.branch.startsWith('relayflow/')) + || value.branch === value.baseBranch + || typeof value.baseSha !== 'string' + || !/^[a-f0-9]{40}$/u.test(value.baseSha)) { + return undefined; + } + return { + ...(gitlab ? { host: 'gitlab' as const } : {}), + owner: value.owner.toLowerCase(), + repo: value.repo.toLowerCase(), + baseBranch: value.baseBranch, + baseSha: value.baseSha, + branch: value.branch, + ...(value.mode === 'head' ? { mode: 'head' as const } : {}), + ...(value.workflows === true ? { workflows: true as const } : {}), + ...(value.access === 'read' ? { access: 'read' as const } : {}), + }; +} diff --git a/packages/sdk/tests/cloud-answer.test.ts b/packages/sdk/tests/cloud-answer.test.ts index f29d427d..b1142d53 100644 --- a/packages/sdk/tests/cloud-answer.test.ts +++ b/packages/sdk/tests/cloud-answer.test.ts @@ -16,8 +16,25 @@ const surface = { packageName: '@relayflows/surface', version: '2.0.18', const sourceSha256 = createHash('sha256').update(workflow).digest('hex'); // The stored attestation shape, which differs from the submission authority. const authority = { source: { sha256: sourceSha256, surface }, artifact: { sha256: 'original-artifact' } }; +const repositoryAuthority = { + host: 'gitlab', owner: 'Nabis-Engineering/IT', repo: 'Nabis-Slack-Atlas', + baseBranch: 'main', baseSha: 'c'.repeat(40), branch: 'feature/e2e-proof', mode: 'head', + userId: 'user-1', workspaceId: 'ws-1', relayWorkspaceId: 'rw_1', organizationId: 'org-1', + installationId: '86233786', repositoryId: 86233786, connectionId: 'connection-1', providerConfigKey: 'gitlab-pat', +}; +const repositoryGrant = { + host: 'gitlab', owner: 'nabis-engineering/it', repo: 'nabis-slack-atlas', + baseBranch: 'main', baseSha: 'c'.repeat(40), branch: 'feature/e2e-proof', mode: 'head', +}; const submitted = { schemaVersion: 1, sourceSha256, byteLength: Buffer.byteLength(workflow), surface }; const wait = { waitId: 'human-2', question: 'Ship this?', to: 'slack:#eng' }; +const answerRoute = (humanAnswer?: { waitId: string; answer: boolean; note?: string }) => ({ + runId: RUN, + humanWait: wait, + ...(humanAnswer === undefined ? {} : { humanAnswer: { + ...humanAnswer, answeredBy: 'khaliq@agent-relay.com', at: '2026-11-06T16:41:08.000Z', + } }), +}); const parked = { runId: RUN, relayflowVersion: 'v2', status: 'failed', workflow, relayflowV2Authority: authority, workspaceId: 'original-workspace', result: { completionReason: 'needs_human', humanWait: wait } }; @@ -39,8 +56,10 @@ function cloud(replies: Reply[]) { }); return calls; } -function replies(record = parked): Reply[] { - return [{ body: record }, { body: { humanWait: wait } }, { body: { ok: true } }, +function replies(record = parked, answer = true, note?: string): Reply[] { + const recorded = { waitId: wait.waitId, answer, ...(note === undefined ? {} : { note }) }; + return [{ body: record }, { body: answerRoute() }, { body: { ...answerRoute(recorded), + next: { resume: `POST /api/v1/workflows/run with { "resume": "${RUN}" }` } } }, { body: record }, { body: { runId: 'resumed-1', status: 'pending' } }]; } function capture() { @@ -53,8 +72,8 @@ function env() { describe('Cloud answer and resume', () => { it('discovers the wait, answers, rechecks and resumes using original authority without inputs', async () => { - const calls = cloud(replies({ ...parked, relayflowV2RunBudgetMinutes: 120 })); - expect(await answerCloudFlow(RUN, true, { ...options, note: 'reviewed' })).toEqual({ + const calls = cloud(replies({ ...parked, relayflowV2RunBudgetMinutes: 120 }, true, 'reviewed')); + expect(await answerCloudFlow(RUN, true, { ...options, note: ' reviewed ' })).toEqual({ runId: 'resumed-1', resumedFrom: RUN, waitId: 'human-2', answer: true, resumedByCloud: false, }); expect(calls.map(c => [c.method, c.path])).toEqual([ @@ -67,6 +86,35 @@ describe('Cloud answer and resume', () => { expect(calls[4]!.body).not.toHaveProperty('relayflowV2RunBudgetMinutes'); }); + it('answers and resumes a repository-backed run with the canonical persisted GitLab grant', async () => { + const record = { ...parked, relayflowV2Authority: { ...authority, repository: repositoryAuthority } }; + const calls = cloud(replies(record)); + await answerCloudFlow(RUN, true, options); + expect(calls.map(c => [c.method, c.path])).toEqual([ + ['GET', path], ['GET', `${path}/answer`], ['POST', `${path}/answer`], ['GET', path], ['POST', '/api/v1/workflows/run'], + ]); + expect(calls[4]!.body).toMatchObject({ resume: RUN }); + expect((calls[4]!.body as Record).repositoryGrant).toEqual(repositoryGrant); + }); + + it.each([ + ['workflows', { ...repositoryAuthority, host: 'github', owner: 'AgentWorkforce', repo: 'Flows', workflows: true }, + { owner: 'agentworkforce', repo: 'flows', baseBranch: 'main', baseSha: 'c'.repeat(40), + branch: 'feature/e2e-proof', mode: 'head', workflows: true }], + ['read access', { ...repositoryAuthority, host: 'github', owner: 'AgentWorkforce', repo: 'Flows', access: 'read' }, + { owner: 'agentworkforce', repo: 'flows', baseBranch: 'main', baseSha: 'c'.repeat(40), + branch: 'feature/e2e-proof', mode: 'head', access: 'read' }], + ])('retries a known answer without another answer POST and preserves the %s repository restriction', async (_label, repository, expected) => { + const record = { ...parked, relayflowV2Authority: { ...authority, repository } }; + const calls = cloud([{ body: record }, { body: answerRoute({ waitId: 'human-2', answer: true }) }, + { body: record }, { body: { runId: 'resumed-1', status: 'pending' } }]); + await answerCloudFlow(RUN, true, options); + const posts = calls.filter(c => c.method === 'POST'); + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ path: '/api/v1/workflows/run' }); + expect((posts[0]!.body as Record).repositoryGrant).toEqual(expected); + }); + it.each([ ['truncated source', { workflow: workflow.slice(0, 10) }], ['missing digest', { relayflowV2Authority: { source: {} } }], @@ -80,6 +128,22 @@ describe('Cloud answer and resume', () => { expect(calls.every(c => c.method === 'GET')).toBe(true); }); + it.each([ + ['null', null], + ['unknown access', { ...repositoryAuthority, access: 'write' }], + ['GitLab workflows write', { ...repositoryAuthority, workflows: true }], + ['GitLab read access', { ...repositoryAuthority, access: 'read' }], + ['contradictory access', { ...repositoryAuthority, host: 'github', workflows: true, access: 'read' }], + ['unreserved new branch', { ...repositoryAuthority, mode: 'new', branch: 'feature/not-reserved' }], + ['invalid base SHA', { ...repositoryAuthority, baseSha: 'not-a-sha' }], + ])('refuses a malformed stored repository contract (%s) before writes', async (_label, repository) => { + const record = { ...parked, relayflowV2Authority: { ...authority, repository } }; + const calls = cloud([{ body: record }, { body: answerRoute() }]); + await expect(answerCloudFlow(RUN, true, options)).rejects.toMatchObject({ code: 'invalid_response' }); + expect(calls).toHaveLength(2); + expect(calls.every(c => c.method === 'GET')).toBe(true); + }); + async function sourceWithSurface(version: string) { const dir = await mkdtemp(join(tmpdir(), 'cloud-answer-')); dirs.push(dir); const pkg = join(dir, 'node_modules', '@relayflows', 'surface'); await mkdir(pkg, { recursive: true }); @@ -117,7 +181,7 @@ describe('Cloud answer and resume', () => { it('reads --source as bytes to recover a truncated stored copy', async () => { const dir = await mkdtemp(join(tmpdir(), 'cloud-answer-')); dirs.push(dir); const source = join(dir, 'original.flow.ts'); await writeFile(source, workflow); - const calls = cloud(replies({ ...parked, workflow: 'prefix' })); + const calls = cloud(replies({ ...parked, workflow: 'prefix' }, false)); await answerCloudFlow(RUN, false, { ...options, source }); expect(calls[4]!.body).toMatchObject({ workflow }); }); @@ -130,9 +194,10 @@ describe('Cloud answer and resume', () => { expect(calls).toHaveLength(2); }); - it.each([{}, { runId: 'different-run', humanWait: wait }, { openWaits: [wait, { ...wait, waitId: 'human-3' }] }, - { humanWait: { waitId: 'approval' } }, { humanWait: { ...wait, waitId: 'human-3' } }, - { humanWait: wait, openWaits: [wait] }, { answer: { waitId: 'human-2', answer: 'yes' } }])('refuses absent, ambiguous or inconsistent waits: %j', async body => { + it.each([{}, { runId: 'different-run', humanWait: wait }, { runId: RUN, openWaits: [wait, { ...wait, waitId: 'human-3' }] }, + { runId: RUN, humanWait: { waitId: 'approval' } }, { runId: RUN, humanWait: { ...wait, waitId: 'human-3' } }, + { runId: RUN, humanWait: wait, openWaits: [wait] }, + { runId: RUN, humanWait: wait, humanAnswer: { waitId: 'human-2', answer: 'yes' } }])('refuses absent, ambiguous or inconsistent waits: %j', async body => { const calls = cloud([{ body: parked }, { body }]); await expect(answerCloudFlow(RUN, true, options)).rejects.toMatchObject({ code: 'invalid_response' }); expect(calls.every(c => c.method === 'GET')).toBe(true); @@ -146,21 +211,23 @@ describe('Cloud answer and resume', () => { }); it('retries from an identical recorded answer without answering twice', async () => { - const calls = cloud([{ body: parked }, { body: { humanWait: null, answer: { waitId: 'human-2', answer: true } } }, + const calls = cloud([{ body: parked }, { body: answerRoute({ waitId: 'human-2', answer: true, note: 'reviewed' }) }, { body: parked }, { body: { runId: 'resumed-1', status: 'pending' } }]); - await answerCloudFlow(RUN, true, options); + await answerCloudFlow(RUN, true, { ...options, note: ' reviewed ' }); expect(calls.filter(c => c.method === 'POST').map(c => c.path)).toEqual(['/api/v1/workflows/run']); }); it('discovers a humanWait beside a null openWaits', async () => { - const sequence = replies(); sequence[1] = { body: { humanWait: wait, openWaits: null } }; + const sequence = replies(); sequence[1] = { body: { ...answerRoute(), openWaits: null } }; const calls = cloud(sequence); expect(await answerCloudFlow(RUN, true, options)).toMatchObject({ waitId: 'human-2', runId: 'resumed-1' }); expect(calls).toHaveLength(5); }); it('treats a null recorded note as absent and resumes without answering twice', async () => { - const calls = cloud([{ body: parked }, { body: { humanWait: null, answer: { waitId: 'human-2', answer: true, note: null } } }, + const calls = cloud([{ body: parked }, { body: { ...answerRoute(), humanAnswer: { + waitId: 'human-2', answer: true, note: null, answeredBy: 'khaliq@agent-relay.com', at: '2026-11-06T16:41:08.000Z', + } } }, { body: parked }, { body: { runId: 'resumed-1', status: 'pending' } }]); await answerCloudFlow(RUN, true, options); expect(calls.filter(c => c.method === 'POST').map(c => c.path)).toEqual(['/api/v1/workflows/run']); @@ -181,12 +248,11 @@ describe('Cloud answer and resume', () => { expect(calls).toHaveLength(4); }); - it('answers the open wait when the answer route still holds an earlier gate\'s decision', async () => { - const sequence = replies(); - sequence[1] = { body: { humanWait: wait, answer: { waitId: 'human-1', answer: false, note: 'earlier gate' } } }; - const calls = cloud(sequence); - expect(await answerCloudFlow(RUN, true, options)).toMatchObject({ waitId: 'human-2', runId: 'resumed-1' }); - expect(calls[2]).toMatchObject({ method: 'POST', path: `${path}/answer`, body: { waitId: 'human-2', answer: true } }); + it('refuses a current wait paired with an earlier gate answer without writes', async () => { + const calls = cloud([{ body: parked }, { body: { runId: RUN, humanWait: wait, + humanAnswer: { waitId: 'human-1', answer: false, note: 'earlier gate' } } }]); + await expect(answerCloudFlow(RUN, true, options)).rejects.toMatchObject({ code: 'invalid_response' }); + expect(calls.every(c => c.method === 'GET')).toBe(true); }); it('does not report a run cancelled after the answer as resumed', async () => { @@ -196,21 +262,21 @@ describe('Cloud answer and resume', () => { expect(calls).toHaveLength(4); }); - it('retries with --note when the recorded answer omits its optional note', async () => { - const calls = cloud([{ body: parked }, { body: { humanWait: null, answer: { waitId: 'human-2', answer: true } } }, - { body: parked }, { body: { runId: 'resumed-1', status: 'pending' } }]); - await answerCloudFlow(RUN, true, { ...options, note: 'reviewed' }); - expect(calls.filter(c => c.method === 'POST').map(c => c.path)).toEqual(['/api/v1/workflows/run']); + it('refuses a known answer that omits the requested note without writes', async () => { + const calls = cloud([{ body: parked }, { body: answerRoute({ waitId: 'human-2', answer: true }) }]); + await expect(answerCloudFlow(RUN, true, { ...options, note: 'reviewed' })) + .rejects.toThrow('recorded answer differs'); + expect(calls.every(c => c.method === 'GET')).toBe(true); }); it('refuses a recorded note that differs from --note without writes', async () => { - const calls = cloud([{ body: parked }, { body: { answer: { waitId: 'human-2', answer: true, note: 'other' } } }]); + const calls = cloud([{ body: parked }, { body: answerRoute({ waitId: 'human-2', answer: true, note: 'other' }) }]); await expect(answerCloudFlow(RUN, true, { ...options, note: 'reviewed' })).rejects.toThrow('recorded answer differs'); expect(calls).toHaveLength(2); }); it('refuses a conflicting recorded answer without writes', async () => { - const calls = cloud([{ body: parked }, { body: { answer: { waitId: 'human-2', answer: false } } }]); + const calls = cloud([{ body: parked }, { body: answerRoute({ waitId: 'human-2', answer: false }) }]); await expect(answerCloudFlow(RUN, true, options)).rejects.toThrow('recorded answer differs'); expect(calls).toHaveLength(2); }); @@ -232,13 +298,20 @@ describe('Cloud answer and resume', () => { }); it('does not resume after an answer refusal in a successful HTTP response', async () => { - const sequence = replies(); sequence[2] = { body: { ok: false, error: 'not accepted' } }; + const sequence = replies(); sequence[2] = { body: { ...answerRoute({ waitId: 'human-2', answer: true }), error: 'not accepted' } }; const calls = cloud(sequence); await expect(answerCloudFlow(RUN, true, options)).rejects.toMatchObject({ answerRecorded: false }); expect(calls).toHaveLength(3); }); - it.each([{}, { ok: null }, { ok: 'true' }, { status: 'recorded' }])('does not resume after an unconfirmed answer acknowledgement %j', async ack => { + it.each([{}, { ok: true }, { runId: RUN, humanWait: wait }, + { ...answerRoute({ waitId: 'human-2', answer: true }), ok: false }, + { ...answerRoute({ waitId: 'human-2', answer: true }), runId: 'different-run' }, + { ...answerRoute({ waitId: 'human-3', answer: true }) }, + { ...answerRoute({ waitId: 'human-2', answer: false }) }, + { ...answerRoute({ waitId: 'human-2', answer: true, note: 'other' }) }, + { ...answerRoute({ waitId: 'human-2', answer: true }), answer: { waitId: 'human-2', answer: true } }, + ])('does not resume after an unconfirmed answer acknowledgement %j', async ack => { const sequence = replies(); sequence[2] = { body: ack }; const calls = cloud(sequence); await expect(answerCloudFlow(RUN, true, options)).rejects.toMatchObject({ answerRecorded: false });