diff --git a/sdk/typescript/src/cloud-publish.ts b/sdk/typescript/src/cloud-publish.ts index 3f0a6ce63..8f5a1b3ee 100644 --- a/sdk/typescript/src/cloud-publish.ts +++ b/sdk/typescript/src/cloud-publish.ts @@ -455,6 +455,7 @@ async function publishCloudPayload( signal, }); } catch { + dependencies.signal?.throwIfAborted(); // A lost response does not establish whether the server accepted the POST. throw new CodexSecurityError( "Cloud publication was not confirmed. The request was not retried; check whether it was accepted before submitting again.", @@ -475,7 +476,10 @@ async function publishCloudPayload( ); } const receipt = receiptSchema.safeParse( - await response.json().catch(() => undefined), + await response.json().catch(() => { + dependencies.signal?.throwIfAborted(); + return undefined; + }), ); // Cloud assigns opaque IDs in request order, so they cannot be compared to // local finding IDs. The authenticated response must still preserve the diff --git a/sdk/typescript/tests-ts/cloud-publish.test.ts b/sdk/typescript/tests-ts/cloud-publish.test.ts index 200b97613..9cd96f6ca 100644 --- a/sdk/typescript/tests-ts/cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cloud-publish.test.ts @@ -684,6 +684,56 @@ describe("Cloud publication", () => { expect(requests).toBe(1); }); + test("preserves caller cancellation during the publication request", async () => { + const { scan, environment } = await fixture(); + const controller = new AbortController(); + const cancellation = new Error("publication cancelled"); + + await expect( + publishScanToCloud(scan, { + environment, + signal: controller.signal, + fetch: async (_url, options) => { + const signal = options.signal as AbortSignal; + return await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + controller.abort(cancellation); + }); + }, + }), + ).rejects.toBe(cancellation); + }); + + test("preserves caller cancellation while parsing the acceptance receipt", async () => { + const { scan, environment } = await fixture(); + const controller = new AbortController(); + const cancellation = new Error("receipt parsing cancelled"); + const response = { + ok: true, + status: 201, + body: null, + json: async () => + await new Promise((_resolve, reject) => { + controller.signal.addEventListener( + "abort", + () => reject(controller.signal.reason), + { once: true }, + ); + controller.abort(cancellation); + }), + } as unknown as Response; + + await expect( + publishScanToCloud(scan, { + environment, + signal: controller.signal, + fetch: async () => response, + }), + ).rejects.toBe(cancellation); + }); + test("requires a complete acceptance receipt instead of treating any 2xx as success", async () => { const { scan, environment } = await fixture(); for (const body of [