From 46f1c1c1e84785348a957c326573764504bdd0d5 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 1 Aug 2026 19:24:11 +0530 Subject: [PATCH 1/3] fix(injection): make MAIN-world functions self-contained Signed-off-by: Tapish Khandelwal --- .../filed-returns-download-trigger.ts | 7 +- .../filed-returns-json-acquisition.ts | 62 +++++++++-------- src/background/gstr2b-artifact-acquisition.ts | 46 ++++++++----- src/background/gstr3b-artifact-acquisition.ts | 46 ++++++++----- src/connectors/gst/artifact-source.ts | 3 + src/connectors/gst/portal-blob-shim.ts | 68 +++++++++++-------- ...eturns-download-trigger-checkpoint.test.ts | 24 +++++++ .../filed-returns-json-acquisition.test.ts | 28 +++++++- .../gstr3b-artifact-acquisition.test.ts | 33 +++++++++ ...orld-filed-returns-filter-executor.test.ts | 21 ++++++ ...rld-filed-returns-filter-selection.test.ts | 30 ++++++++ tests/connectors/portal-blob-shim.test.ts | 24 +++++++ 12 files changed, 297 insertions(+), 95 deletions(-) diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index d86dbcb8..8f332a52 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -318,7 +318,12 @@ function shouldRetainArtifactAcquisitionCheckpoint( // control but before it can prove the browser action quiesced. Keep the // intent so the next start routes it through recovery review instead of // repeating the portal action. - if (["checkpoint-failed", "generation-timeout"].includes(delivery.reason)) return true; + if ( + ["checkpoint-failed", "generation-timeout", "main-world-execution-failed"].includes( + delivery.reason, + ) + ) + return true; // The browser already created an exact-ID item for these outcomes. It may // settle as safe later, but it must not be forgotten and repeated first. return ( diff --git a/src/background/filed-returns-json-acquisition.ts b/src/background/filed-returns-json-acquisition.ts index 9b19c7ad..d81ce911 100644 --- a/src/background/filed-returns-json-acquisition.ts +++ b/src/background/filed-returns-json-acquisition.ts @@ -12,6 +12,7 @@ type JsonAcquisitionResult = safeSignals: string[]; } | { ok: false; reason: string; safeSignals: string[] }; +type MainWorldJsonCaptureResult = { ok: true; base64: string } | { ok: false; reason: string }; export async function acquireFiledReturnJsonInMainWorld(input: { deliver?: (input: { base64: string; mimeType: string }) => Promise; @@ -23,6 +24,7 @@ export async function acquireFiledReturnJsonInMainWorld(input: { returnType: JsonReturnType; tabId: number; }): Promise { + let captured: MainWorldJsonCaptureResult | undefined; try { const [injection] = await browser.scripting.executeScript({ args: [ @@ -36,36 +38,40 @@ export async function acquireFiledReturnJsonInMainWorld(input: { target: { tabId: input.tabId }, world: "MAIN", }); - const captured = injection?.result; - if (!captured?.ok) { - return { ok: false, reason: captured?.reason ?? "endpoint-unavailable", safeSignals: [] }; - } - const bytes = Uint8Array.from(atob(captured.base64), (value) => value.charCodeAt(0)); - const validation = validateArtifactBytes(bytes, "JSON", input.returnPeriod, input.returnType); - if (!validation.ok) return { ok: false, reason: validation.reason, safeSignals: [] }; - if (input.deliver) { - return input.deliver({ base64: captured.base64, mimeType: validation.mimeType }); - } - const delivery = await downloadAcquiredArtifact({ - requestId: input.requestId, - base64: captured.base64, - filename: input.filename, - mimeType: validation.mimeType, - ...(input.onStarted ? { onStarted: input.onStarted } : {}), - ...(input.onStartCheckpointFailed - ? { onStartCheckpointFailed: input.onStartCheckpointFailed } - : {}), - }); - return delivery.ok - ? { - ok: true, - safeSignals: [...delivery.safeSignals, "extension-download-complete"], - ...(delivery.safeMessage ? { safeMessage: delivery.safeMessage } : {}), - } - : { ok: false, reason: delivery.reason, safeSignals: delivery.safeSignals }; + captured = injection?.result as MainWorldJsonCaptureResult | undefined; } catch { - return { ok: false, reason: "endpoint-unavailable", safeSignals: [] }; + return { ok: false, reason: "main-world-execution-failed", safeSignals: [] }; + } + if (!captured?.ok) { + return { + ok: false, + reason: captured?.reason ?? "main-world-execution-failed", + safeSignals: [], + }; + } + const bytes = Uint8Array.from(atob(captured.base64), (value) => value.charCodeAt(0)); + const validation = validateArtifactBytes(bytes, "JSON", input.returnPeriod, input.returnType); + if (!validation.ok) return { ok: false, reason: validation.reason, safeSignals: [] }; + if (input.deliver) { + return input.deliver({ base64: captured.base64, mimeType: validation.mimeType }); } + const delivery = await downloadAcquiredArtifact({ + requestId: input.requestId, + base64: captured.base64, + filename: input.filename, + mimeType: validation.mimeType, + ...(input.onStarted ? { onStarted: input.onStarted } : {}), + ...(input.onStartCheckpointFailed + ? { onStartCheckpointFailed: input.onStartCheckpointFailed } + : {}), + }); + return delivery.ok + ? { + ok: true, + safeSignals: [...delivery.safeSignals, "extension-download-complete"], + ...(delivery.safeMessage ? { safeMessage: delivery.safeMessage } : {}), + } + : { ok: false, reason: delivery.reason, safeSignals: delivery.safeSignals }; } /** diff --git a/src/background/gstr2b-artifact-acquisition.ts b/src/background/gstr2b-artifact-acquisition.ts index c1d49781..4ce5481a 100644 --- a/src/background/gstr2b-artifact-acquisition.ts +++ b/src/background/gstr2b-artifact-acquisition.ts @@ -1,6 +1,10 @@ import { browser } from "wxt/browser"; import { validateArtifactBytes } from "../connectors/gst/artifact-validation"; -import { capturePortalPdfBlob } from "../connectors/gst/portal-blob-shim"; +import { + capturePortalPdfBlob, + MAX_PORTAL_BLOB_BYTES, + type PortalBlobShimResult, +} from "../connectors/gst/portal-blob-shim"; import { installPortalBlobDownloadSafetyNet } from "./artifact-download"; const MIME_TYPES = { @@ -22,27 +26,33 @@ export async function acquirePageGeneratedArtifact(input: { > { const safetyNet = installPortalBlobDownloadSafetyNet(input.tabId); try { - const [injection] = await browser.scripting.executeScript({ - args: [ - { - controlSelector: `[data-pack-artifact-request="${input.requestId}"]`, - expectedMime: MIME_TYPES[input.artifactType], - expectedTarget: { - financialYear: input.financialYear, - period: input.period, - returnType: input.returnType, + let captured: PortalBlobShimResult | undefined; + try { + const [injection] = await browser.scripting.executeScript({ + args: [ + { + controlSelector: `[data-pack-artifact-request="${input.requestId}"]`, + expectedMime: MIME_TYPES[input.artifactType], + maxPortalBlobBytes: MAX_PORTAL_BLOB_BYTES, + expectedTarget: { + financialYear: input.financialYear, + period: input.period, + returnType: input.returnType, + }, }, - }, - ], - func: capturePortalPdfBlob, - target: { tabId: input.tabId }, - world: "MAIN", - }); - const captured = injection?.result; + ], + func: capturePortalPdfBlob, + target: { tabId: input.tabId }, + world: "MAIN", + }); + captured = injection?.result as PortalBlobShimResult | undefined; + } catch { + return { ok: false, reason: "main-world-execution-failed", safeSignals: [] }; + } if (!captured?.ok) return { ok: false, - reason: captured?.reason ?? "generation-timeout", + reason: captured?.reason ?? "main-world-execution-failed", safeSignals: captured?.safeSignals ?? [], }; await safetyNet.bind(captured.blobUrl); diff --git a/src/background/gstr3b-artifact-acquisition.ts b/src/background/gstr3b-artifact-acquisition.ts index 41fd61c5..e79946b3 100644 --- a/src/background/gstr3b-artifact-acquisition.ts +++ b/src/background/gstr3b-artifact-acquisition.ts @@ -1,6 +1,10 @@ import { browser } from "wxt/browser"; import { validateArtifactBytes } from "../connectors/gst/artifact-validation"; -import { capturePortalPdfBlob } from "../connectors/gst/portal-blob-shim"; +import { + capturePortalPdfBlob, + MAX_PORTAL_BLOB_BYTES, + type PortalBlobShimResult, +} from "../connectors/gst/portal-blob-shim"; import { downloadAcquiredArtifact, installPortalBlobDownloadSafetyNet } from "./artifact-download"; export async function acquireGstr3bPdfAfterPreflight(input: { @@ -22,27 +26,33 @@ export async function acquireGstr3bPdfAfterPreflight(input: { > { const safetyNet = installPortalBlobDownloadSafetyNet(input.tabId); try { - const [injection] = await browser.scripting.executeScript({ - args: [ - { - controlSelector: `[data-pack-artifact-request="${input.requestId}"]`, - expectedMime: "application/pdf", - expectedTarget: { - financialYear: input.financialYear, - period: input.period, - returnType: "GSTR-3B", + let captured: PortalBlobShimResult | undefined; + try { + const [injection] = await browser.scripting.executeScript({ + args: [ + { + controlSelector: `[data-pack-artifact-request="${input.requestId}"]`, + expectedMime: "application/pdf", + maxPortalBlobBytes: MAX_PORTAL_BLOB_BYTES, + expectedTarget: { + financialYear: input.financialYear, + period: input.period, + returnType: "GSTR-3B", + }, }, - }, - ], - func: capturePortalPdfBlob, - target: { tabId: input.tabId }, - world: "MAIN", - }); - const captured = injection?.result; + ], + func: capturePortalPdfBlob, + target: { tabId: input.tabId }, + world: "MAIN", + }); + captured = injection?.result as PortalBlobShimResult | undefined; + } catch { + return { ok: false, reason: "main-world-execution-failed", safeSignals: [] }; + } if (!captured?.ok) { return { ok: false, - reason: captured?.reason ?? "generation-timeout", + reason: captured?.reason ?? "main-world-execution-failed", safeSignals: captured?.safeSignals ?? [], }; } diff --git a/src/connectors/gst/artifact-source.ts b/src/connectors/gst/artifact-source.ts index 6737145a..2e90b0ff 100644 --- a/src/connectors/gst/artifact-source.ts +++ b/src/connectors/gst/artifact-source.ts @@ -42,6 +42,7 @@ export type ArtifactFailureReason = | "empty" | "too-large" | "generation-timeout" + | "main-world-execution-failed" | "search-unavailable" | "page-period-mismatch" | "danger-unconfirmed" @@ -68,6 +69,8 @@ export const ARTIFACT_FAILURE_MESSAGES = { "too-large": "The GST Portal returned an artifact that exceeds Pack's safe local size limit.", "generation-timeout": "The GST Portal did not finish generating the filed-return artifact in time.", + "main-world-execution-failed": + "Pack could not run the verified GST Portal artifact action, so it did not mark the target saved.", "search-unavailable": "Pack could not confirm the browser download state, so it did not mark the target saved.", "page-period-mismatch": diff --git a/src/connectors/gst/portal-blob-shim.ts b/src/connectors/gst/portal-blob-shim.ts index cbb2ab2b..cbc88bee 100644 --- a/src/connectors/gst/portal-blob-shim.ts +++ b/src/connectors/gst/portal-blob-shim.ts @@ -2,9 +2,10 @@ export type PortalBlobShimInput = { controlSelector: string; expectedMime: string; expectedTarget?: { financialYear: string; period: string; returnType: string }; + maxPortalBlobBytes?: number; timeoutMs?: number; }; -const MAX_PORTAL_BLOB_BYTES = 25 * 1024 * 1024; +export const MAX_PORTAL_BLOB_BYTES = 25 * 1024 * 1024; export type PortalBlobShimResult = | { ok: true; base64: string; blobUrl: string; safeSignals: string[] } | { @@ -18,6 +19,9 @@ export type PortalBlobShimResult = safeSignals: string[]; }; export function capturePortalPdfBlob(input: PortalBlobShimInput): Promise { + // chrome.scripting serializes only this function, so every value it needs + // must be in its args or defined inside this body. + const maxPortalBlobBytes = input.maxPortalBlobBytes ?? 25 * 1024 * 1024; const anchor = HTMLAnchorElement.prototype; const originalDispatch = anchor.dispatchEvent; const originalClick = anchor.click; @@ -44,7 +48,7 @@ export function capturePortalPdfBlob(input: PortalBlobShimInput): Promise { if (!blob || !blobUrl) return; const capturedBlobUrl = blobUrl; - if (blob.size > MAX_PORTAL_BLOB_BYTES) + if (blob.size > maxPortalBlobBytes) return finish({ ok: false, reason: "too-large", safeSignals: [] }); void blob.arrayBuffer().then( (buffer) => @@ -81,6 +85,39 @@ export function capturePortalPdfBlob(input: PortalBlobShimInput): Promise(input.controlSelector); if (!control) return finish({ ok: false, reason: "control-not-found", safeSignals: [] }); + const controlHasVisibleTarget = ( + candidate: HTMLElement, + expected: NonNullable, + ): boolean => { + let current: HTMLElement | null = candidate; + const escape = (value: string) => + [...value] + .map((character) => + "\\.^$*+?()[]{}|".includes(character) ? `\\${character}` : character, + ) + .join(""); + while (current && current !== candidate.ownerDocument.body) { + const text = (current.textContent ?? "").replace(/\s+/g, " ").trim(); + if ( + /\b(?:(?:return|tax)\s*period|month)\b/i.test(text) && + /\b(?:financial\s*year|fy)\b/i.test(text) && + new RegExp(`\\b${escape(expected.returnType).replace("-", "[\\s-]?")}\\b`, "i").test( + text, + ) && + new RegExp( + `\\b(?:(?:return|tax)\\s*period|month)\\b\\s*(?:[-:]\\s*)?${escape(expected.period)}\\b`, + "i", + ).test(text) && + new RegExp( + `\\b(?:financial\\s*year|fy)\\b\\s*(?:[-:]\\s*)?${escape(expected.financialYear)}`, + "i", + ).test(text) + ) + return true; + current = current.parentElement; + } + return false; + }; if (input.expectedTarget && !controlHasVisibleTarget(control, input.expectedTarget)) { return finish({ ok: false, @@ -99,30 +136,3 @@ export function capturePortalPdfBlob(input: PortalBlobShimInput): Promise, -): boolean { - let current: HTMLElement | null = control; - const escape = (value: string) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&"); - while (current && current !== control.ownerDocument.body) { - const text = (current.textContent ?? "").replace(/\s+/g, " ").trim(); - if ( - /\b(?:(?:return|tax)\s*period|month)\b/i.test(text) && - /\b(?:financial\s*year|fy)\b/i.test(text) && - new RegExp(`\\b${escape(expected.returnType).replace("-", "[\\s-]?")}\\b`, "i").test(text) && - new RegExp( - `\\b(?:(?:return|tax)\\s*period|month)\\b\\s*(?:[-:]\\s*)?${escape(expected.period)}\\b`, - "i", - ).test(text) && - new RegExp( - `\\b(?:financial\\s*year|fy)\\b\\s*(?:[-:]\\s*)?${escape(expected.financialYear)}`, - "i", - ).test(text) - ) - return true; - current = current.parentElement; - } - return false; -} diff --git a/tests/background/filed-returns-download-trigger-checkpoint.test.ts b/tests/background/filed-returns-download-trigger-checkpoint.test.ts index be9d4a4a..f87444cd 100644 --- a/tests/background/filed-returns-download-trigger-checkpoint.test.ts +++ b/tests/background/filed-returns-download-trigger-checkpoint.test.ts @@ -50,6 +50,7 @@ const RETAINED_TERMINAL_DELIVERY_FAILURES = [ "danger-rejected", "empty", "generation-timeout", + "main-world-execution-failed", "interrupted", "search-unavailable", ] as const; @@ -171,6 +172,29 @@ describe("GSTR-3B artifact acquisition checkpoint cleanup", () => { ); }); + it("retains the PDF intent when MAIN-world execution cannot be proven", async () => { + mocks.acquireGstr3bPdfAfterPreflight.mockResolvedValueOnce({ + ok: false, + reason: "main-world-execution-failed", + safeSignals: [], + }); + + await triggerAndObserveFiledReturnDownload({ + activePeriod: "May", + artifactType: "PDF", + deps: { + sendMessageToTabWithInjection: vi.fn(async () => preparedPdf()), + storageKeys: {}, + }, + scope, + tabId: 17, + }); + + expect(mocks.session[artifactAcquisitionCheckpointKey(scope)]).toEqual( + expect.objectContaining({ state: "intent" }), + ); + }); + it.each( Object.keys(ARTIFACT_FAILURE_MESSAGES).filter( (reason) => !RETAINED_TERMINAL_DELIVERY_FAILURES.includes(reason as never), diff --git a/tests/background/filed-returns-json-acquisition.test.ts b/tests/background/filed-returns-json-acquisition.test.ts index e4c59038..3b2c0104 100644 --- a/tests/background/filed-returns-json-acquisition.test.ts +++ b/tests/background/filed-returns-json-acquisition.test.ts @@ -83,6 +83,9 @@ describe("filed-return JSON main-world acquisition", () => { returnPeriod: string; returnType: "GSTR-3B" | "GSTR-2B"; }) => Promise; + const rebuiltMainWorldFunction = new Function( + `"use strict"; return (${executeMainWorld.toString()});`, + )() as typeof executeMainWorld; const [mainWorldInput] = mainWorldInjection.args; const encode = vi.fn(); vi.stubGlobal("btoa", encode); @@ -92,7 +95,7 @@ describe("filed-return JSON main-world acquisition", () => { ); vi.stubGlobal("location", { origin: "https://return.gst.gov.in" }); try { - await expect(executeMainWorld(mainWorldInput)).resolves.toEqual({ + await expect(rebuiltMainWorldFunction(mainWorldInput)).resolves.toEqual({ ok: false, reason: "too-large", }); @@ -127,6 +130,29 @@ describe("filed-return JSON main-world acquisition", () => { expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); }); + it.each(["missing result", "rejected execution"])( + "reports %s as a MAIN-world execution failure rather than an endpoint failure", + async (scenario) => { + if (scenario === "missing result") + vi.mocked(browser.scripting.executeScript).mockResolvedValue([] as never); + else + vi.mocked(browser.scripting.executeScript).mockRejectedValue( + new Error("synthetic rejection"), + ); + + await expect( + acquireFiledReturnJsonInMainWorld({ + filename: "synthetic.json", + requestId: "main-world-failure", + returnPeriod: "062026", + returnType: "GSTR-3B", + tabId: 17, + }), + ).resolves.toEqual({ ok: false, reason: "main-world-execution-failed", safeSignals: [] }); + expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); + }, + ); + it("hands validated JSON to a worker-owned local stager when one is supplied", async () => { vi.mocked(browser.scripting.executeScript).mockResolvedValue([ { diff --git a/tests/background/gstr3b-artifact-acquisition.test.ts b/tests/background/gstr3b-artifact-acquisition.test.ts index 2464cfac..874e9ae7 100644 --- a/tests/background/gstr3b-artifact-acquisition.test.ts +++ b/tests/background/gstr3b-artifact-acquisition.test.ts @@ -91,6 +91,22 @@ describe("GSTR-3B page-generated acquisition", () => { ).resolves.toMatchObject({ ok: false, reason: "unexpected-content" }); expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); }); + + it("distinguishes an absent MAIN-world result from portal generation timeout", async () => { + mocks.executeScript.mockResolvedValue([]); + + await expect( + acquireGstr3bPdfAfterPreflight({ + financialYear: "2024-25", + filename: "synthetic.pdf", + period: "April", + requestId: "missing-main-world-result", + returnPeriod: "042024", + tabId: 17, + }), + ).resolves.toMatchObject({ ok: false, reason: "main-world-execution-failed" }); + expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); + }); }); describe("GSTR-2B page-generated acquisition", () => { @@ -169,4 +185,21 @@ describe("GSTR-2B page-generated acquisition", () => { ); expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); }); + + it("fails closed when MAIN-world execution rejects", async () => { + mocks.executeScript.mockRejectedValue(new Error("synthetic execution rejection")); + + await expect( + acquirePageGeneratedArtifact({ + artifactType: "PDF", + financialYear: "2024-25", + period: "April", + requestId: "rejected-main-world-execution", + returnPeriod: "042024", + returnType: "GSTR-1", + tabId: 17, + }), + ).resolves.toMatchObject({ ok: false, reason: "main-world-execution-failed" }); + expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); + }); }); diff --git a/tests/background/main-world-filed-returns-filter-executor.test.ts b/tests/background/main-world-filed-returns-filter-executor.test.ts index b04c1297..7a7e0d61 100644 --- a/tests/background/main-world-filed-returns-filter-executor.test.ts +++ b/tests/background/main-world-filed-returns-filter-executor.test.ts @@ -1,3 +1,4 @@ +import { readdir, readFile } from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; import { browser } from "wxt/browser"; import { selectFiledReturnsFiltersInMainWorldForTab } from "../../src/background/main-world-filed-returns-filter-executor"; @@ -12,6 +13,26 @@ vi.mock("wxt/browser", () => ({ })); describe("main-world filed-return filter executor", () => { + it("requires a serialization guard for every MAIN-world injected function", async () => { + const files = (await readdir("src", { recursive: true })).filter( + (file): file is string => typeof file === "string" && file.endsWith(".ts"), + ); + const functions = ( + await Promise.all(files.map((file) => readFile(`src/${file}`, "utf8"))) + ).flatMap((source) => + Array.from(source.matchAll(/func:\s*(\w+),\s*target:[\s\S]{0,120}?world:\s*"MAIN"/g)).map( + ([, func]) => func, + ), + ); + + expect(functions.sort()).toEqual([ + "capturePortalPdfBlob", + "capturePortalPdfBlob", + "fetchFiledReturnJsonInMainWorld", + "selectFiledReturnsFiltersInMainWorld", + ]); + }); + it("returns only the validated control-state outcome from the page", async () => { vi.mocked(browser.scripting.executeScript).mockResolvedValue([ { diff --git a/tests/connectors/main-world-filed-returns-filter-selection.test.ts b/tests/connectors/main-world-filed-returns-filter-selection.test.ts index 3b8e8f6c..6777c5ef 100644 --- a/tests/connectors/main-world-filed-returns-filter-selection.test.ts +++ b/tests/connectors/main-world-filed-returns-filter-selection.test.ts @@ -7,12 +7,42 @@ function selectFilters(scope: Parameters unknown>(func: T): T { + return new Function(`"use strict"; return (${func.toString()});`)() as T; +} + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); describe("main-world filed-return filter selection", () => { + it("survives Chrome's serialized MAIN-world function boundary", async () => { + vi.useFakeTimers(); + const windowRef = new JSDOM(`
${gstr1FilterFields()}
`) + .window; + const browserGlobals = windowRef as unknown as { + Event: typeof Event; + HTMLSelectElement: typeof HTMLSelectElement; + }; + vi.stubGlobal("window", windowRef); + vi.stubGlobal("document", windowRef.document); + vi.stubGlobal("Event", browserGlobals.Event); + vi.stubGlobal("HTMLSelectElement", browserGlobals.HTMLSelectElement); + const executeInMainWorld = rebuildInMainWorld(selectFiledReturnsFiltersInMainWorld); + + const outcome = executeInMainWorld( + { financialYear: "2026-27", period: "May", returnType: "GSTR-1" }, + CLICKABLE_CONTROL_SELECTOR, + ); + await vi.runAllTimersAsync(); + + await expect(outcome).resolves.toMatchObject({ + state: "searched", + safeSignals: expect.arrayContaining(["main-world-search-clicked"]), + }); + }); + it("selects GSTR-1 filing period and month despite unrelated page instructions", async () => { const windowRef = new JSDOM(`
diff --git a/tests/connectors/portal-blob-shim.test.ts b/tests/connectors/portal-blob-shim.test.ts index 98243119..811ba9af 100644 --- a/tests/connectors/portal-blob-shim.test.ts +++ b/tests/connectors/portal-blob-shim.test.ts @@ -110,6 +110,26 @@ describe("capturePortalPdfBlob", () => { ).resolves.toMatchObject({ ok: true, safeSignals: ["portal-blob-shim-suppressed-via-click"] }); }); + it("survives Chrome's serialized MAIN-world function boundary", async () => { + const { documentRef, view, url } = environment(); + install(view, url); + documentRef.body.innerHTML = ` +

GSTR-3B Monthly Return

Tax Period: April

+

Financial Year: 2024-25

`; + documentRef + .querySelector("button") + ?.addEventListener("click", () => savePdf(documentRef, view, "click")); + + const executeInMainWorld = rebuildInMainWorld(capturePortalPdfBlob); + await expect( + executeInMainWorld({ + controlSelector: "button", + expectedMime: "application/pdf", + expectedTarget: { financialYear: "2024-25", period: "April", returnType: "GSTR-3B" }, + }), + ).resolves.toMatchObject({ ok: true, safeSignals: ["portal-blob-shim-suppressed-via-click"] }); + }); + it("restores every wrapped member and never patches unrelated APIs", async () => { const { documentRef, view, url } = environment(); install(view, url); @@ -221,3 +241,7 @@ function saveBlob( if (method === "click") link.click(); else link.dispatchEvent(new view.MouseEvent("click")); } + +function rebuildInMainWorld unknown>(func: T): T { + return new Function(`"use strict"; return (${func.toString()});`)() as T; +} From 36badc08cbf140d790a1b2c4aa27f54feacfbd44 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 1 Aug 2026 21:54:27 +0530 Subject: [PATCH 2/3] fix(staging): retain unconfirmed bundle delivery --- .../filed-returns-download-trigger.ts | 32 ++++++++++-- .../filed-returns-json-acquisition.ts | 6 ++- .../filed-returns-selected-artifacts.ts | 16 ++++++ src/connectors/gst/artifact-source.ts | 3 ++ ...eturns-download-trigger-checkpoint.test.ts | 23 ++++++++- .../filed-returns-json-acquisition.test.ts | 30 ++++++++++++ .../filed-returns-selected-artifacts.test.ts | 49 +++++++++++++++++-- 7 files changed, 149 insertions(+), 10 deletions(-) diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index 8f332a52..9a5cdbc5 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -159,6 +159,7 @@ export async function triggerAndObserveFiledReturnDownload({ }; await persistArtifactAcquisitionIntent({ ...checkpointTarget, requestId }); let checkpointHasDownloadId = false; + let externallyVisibleActionMayHaveOccurred = false; let retainCheckpointForRecovery = false; try { const delivery = await acquireFiledReturnJsonInMainWorld({ @@ -175,6 +176,7 @@ export async function triggerAndObserveFiledReturnDownload({ state: "download-observing", }); checkpointHasDownloadId = true; + externallyVisibleActionMayHaveOccurred = true; }, onStartCheckpointFailed: async (downloadId) => { await persistArtifactAcquisitionUnconfirmedDownload({ @@ -183,11 +185,15 @@ export async function triggerAndObserveFiledReturnDownload({ requestId, state: "download-unconfirmed", }); + externallyVisibleActionMayHaveOccurred = true; }, }); retainCheckpointForRecovery = delivery.ok || - shouldRetainArtifactAcquisitionCheckpoint(delivery, checkpointHasDownloadId); + shouldRetainArtifactAcquisitionCheckpoint(delivery, { + checkpointHasDownloadId, + externallyVisibleActionMayHaveOccurred, + }); return delivery.ok ? { ok: true, @@ -239,6 +245,7 @@ export async function triggerAndObserveFiledReturnDownload({ }; await persistArtifactAcquisitionIntent({ ...checkpointTarget, requestId }); let checkpointHasDownloadId = false; + const externallyVisibleActionMayHaveOccurred = true; let retainCheckpointForRecovery = false; try { const acquired = await acquireGstr3bPdfAfterPreflight({ @@ -268,7 +275,10 @@ export async function triggerAndObserveFiledReturnDownload({ }); retainCheckpointForRecovery = acquired.ok || - shouldRetainArtifactAcquisitionCheckpoint(acquired, checkpointHasDownloadId); + shouldRetainArtifactAcquisitionCheckpoint(acquired, { + checkpointHasDownloadId, + externallyVisibleActionMayHaveOccurred, + }); return acquired.ok ? { ok: true, @@ -312,8 +322,12 @@ export async function triggerAndObserveFiledReturnDownload({ function shouldRetainArtifactAcquisitionCheckpoint( delivery: { ok: false; reason: string }, - checkpointHasDownloadId: boolean, + input: { + checkpointHasDownloadId: boolean; + externallyVisibleActionMayHaveOccurred: boolean; + }, ): boolean { + if (!input.externallyVisibleActionMayHaveOccurred) return false; // A portal generation timeout happens after Pack armed the target-bound // control but before it can prove the browser action quiesced. Keep the // intent so the next start routes it through recovery review instead of @@ -327,7 +341,7 @@ function shouldRetainArtifactAcquisitionCheckpoint( // The browser already created an exact-ID item for these outcomes. It may // settle as safe later, but it must not be forgotten and repeated first. return ( - checkpointHasDownloadId && + input.checkpointHasDownloadId && [ "timeout", "search-unavailable", @@ -446,6 +460,8 @@ async function triggerPageGeneratedSinglePeriodArtifact( await persistArtifactAcquisitionIntent({ ...checkpointTarget, requestId }); } let checkpointHasDownloadId = false; + let externallyVisibleActionMayHaveOccurred = + artifact.state === "ready" && (artifactType === "PDF" || artifactType === "EXCEL"); let retainCheckpointForRecovery = false; try { const callbacks = { @@ -457,6 +473,7 @@ async function triggerPageGeneratedSinglePeriodArtifact( state: "download-observing", }); checkpointHasDownloadId = true; + externallyVisibleActionMayHaveOccurred = true; }, onStartCheckpointFailed: async (downloadId: number) => { await persistArtifactAcquisitionUnconfirmedDownload({ @@ -465,6 +482,7 @@ async function triggerPageGeneratedSinglePeriodArtifact( requestId, state: "download-unconfirmed", }); + externallyVisibleActionMayHaveOccurred = true; }, }; const acquired = @@ -525,7 +543,11 @@ async function triggerPageGeneratedSinglePeriodArtifact( } retainCheckpointForRecovery = tracksBrowserDownload && - (acquired.ok || shouldRetainArtifactAcquisitionCheckpoint(acquired, checkpointHasDownloadId)); + (acquired.ok || + shouldRetainArtifactAcquisitionCheckpoint(acquired, { + checkpointHasDownloadId, + externallyVisibleActionMayHaveOccurred, + })); return acquired.ok ? { ok: true, diff --git a/src/background/filed-returns-json-acquisition.ts b/src/background/filed-returns-json-acquisition.ts index d81ce911..5b29a413 100644 --- a/src/background/filed-returns-json-acquisition.ts +++ b/src/background/filed-returns-json-acquisition.ts @@ -53,7 +53,11 @@ export async function acquireFiledReturnJsonInMainWorld(input: { const validation = validateArtifactBytes(bytes, "JSON", input.returnPeriod, input.returnType); if (!validation.ok) return { ok: false, reason: validation.reason, safeSignals: [] }; if (input.deliver) { - return input.deliver({ base64: captured.base64, mimeType: validation.mimeType }); + try { + return await input.deliver({ base64: captured.base64, mimeType: validation.mimeType }); + } catch { + return { ok: false, reason: "delivery-unconfirmed", safeSignals: [] }; + } } const delivery = await downloadAcquiredArtifact({ requestId: input.requestId, diff --git a/src/background/filed-returns-selected-artifacts.ts b/src/background/filed-returns-selected-artifacts.ts index e2c41424..664d8038 100644 --- a/src/background/filed-returns-selected-artifacts.ts +++ b/src/background/filed-returns-selected-artifacts.ts @@ -251,6 +251,22 @@ export async function triggerSelectedArtifacts({ } return response; } + if ( + singlePeriodBundleLedger && + response.flowStep.safeSignals.includes("artifact-delivery-unconfirmed") + ) { + const reviewLedger = await persistSinglePeriodBundleArtifactReview( + singlePeriodBundleLedger, + artifactType, + response.flowStep, + deps.now?.() ?? new Date(), + ); + return persistAmbiguousSinglePeriodBundleResponse( + reviewLedger ?? singlePeriodBundleLedger, + deps, + response.flowStep, + ); + } if (response.flowStep.state !== "downloaded") { if (singlePeriodBundleLedger) { const unavailableLedger = await persistSinglePeriodBundleArtifactUnavailable( diff --git a/src/connectors/gst/artifact-source.ts b/src/connectors/gst/artifact-source.ts index 2e90b0ff..53a1c150 100644 --- a/src/connectors/gst/artifact-source.ts +++ b/src/connectors/gst/artifact-source.ts @@ -42,6 +42,7 @@ export type ArtifactFailureReason = | "empty" | "too-large" | "generation-timeout" + | "delivery-unconfirmed" | "main-world-execution-failed" | "search-unavailable" | "page-period-mismatch" @@ -69,6 +70,8 @@ export const ARTIFACT_FAILURE_MESSAGES = { "too-large": "The GST Portal returned an artifact that exceeds Pack's safe local size limit.", "generation-timeout": "The GST Portal did not finish generating the filed-return artifact in time.", + "delivery-unconfirmed": + "Pack could not confirm local delivery of the verified filed-return artifact, so it did not mark the target saved.", "main-world-execution-failed": "Pack could not run the verified GST Portal artifact action, so it did not mark the target saved.", "search-unavailable": diff --git a/tests/background/filed-returns-download-trigger-checkpoint.test.ts b/tests/background/filed-returns-download-trigger-checkpoint.test.ts index f87444cd..5a3a98d4 100644 --- a/tests/background/filed-returns-download-trigger-checkpoint.test.ts +++ b/tests/background/filed-returns-download-trigger-checkpoint.test.ts @@ -101,6 +101,27 @@ describe("GSTR-3B artifact acquisition checkpoint cleanup", () => { expect(mocks.session).toEqual({}); }); + it("clears the JSON checkpoint when MAIN-world execution fails before any browser action", async () => { + mocks.acquireFiledReturnJsonInMainWorld.mockResolvedValueOnce({ + ok: false, + reason: "main-world-execution-failed", + safeSignals: [], + }); + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "May", + artifactType: "JSON", + deps: { + sendMessageToTabWithInjection: vi.fn(async () => acquiredJson()), + storageKeys: {}, + }, + scope: { ...scope, artifactType: "JSON" }, + tabId: 17, + }); + + expect(response).toMatchObject({ flowStep: { state: "blocked" } }); + expect(mocks.session).toEqual({}); + }); + it.each(["interrupted", "empty"] as const)( "retains the JSON checkpoint after terminal %s delivery failure with an exact ID", async (reason) => { @@ -172,7 +193,7 @@ describe("GSTR-3B artifact acquisition checkpoint cleanup", () => { ); }); - it("retains the PDF intent when MAIN-world execution cannot be proven", async () => { + it("retains the page-generated PDF intent when MAIN-world execution cannot be proven", async () => { mocks.acquireGstr3bPdfAfterPreflight.mockResolvedValueOnce({ ok: false, reason: "main-world-execution-failed", diff --git a/tests/background/filed-returns-json-acquisition.test.ts b/tests/background/filed-returns-json-acquisition.test.ts index 3b2c0104..78457188 100644 --- a/tests/background/filed-returns-json-acquisition.test.ts +++ b/tests/background/filed-returns-json-acquisition.test.ts @@ -184,4 +184,34 @@ describe("filed-return JSON main-world acquisition", () => { expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ mimeType: "application/json" })); expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); }); + + it("returns a terminal safe failure when worker-owned local staging rejects", async () => { + vi.mocked(browser.scripting.executeScript).mockResolvedValue([ + { + result: { + ok: true, + base64: base64Json({ + data: { rtnprd: "062026", padding: "x".repeat(100) }, + status: 1, + }), + }, + }, + ] as never); + const deliver = vi.fn(async () => { + throw new Error("synthetic local staging rejection"); + }); + + await expect( + acquireFiledReturnJsonInMainWorld({ + deliver, + filename: "synthetic.json", + requestId: "synthetic-request", + returnPeriod: "062026", + returnType: "GSTR-2B", + tabId: 17, + }), + ).resolves.toEqual({ ok: false, reason: "delivery-unconfirmed", safeSignals: [] }); + + expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); + }); }); diff --git a/tests/background/filed-returns-selected-artifacts.test.ts b/tests/background/filed-returns-selected-artifacts.test.ts index 905ddcdc..61c4bde0 100644 --- a/tests/background/filed-returns-selected-artifacts.test.ts +++ b/tests/background/filed-returns-selected-artifacts.test.ts @@ -96,7 +96,11 @@ const bundleMocks = vi.hoisted(() => { }; return { clearSinglePeriodBundleLedger: vi.fn(async () => true), - persistSinglePeriodBundleArtifactReview: vi.fn(async (ledger: SyntheticBundleLedger) => ledger), + persistSinglePeriodBundleArtifactReview: vi.fn(async (ledger: SyntheticBundleLedger) => ({ + ...ledger, + phase: "artifact-review" as const, + revision: ledger.revision + 1, + })), persistSinglePeriodBundleArtifactRunning: vi.fn( async (ledger: SyntheticBundleLedger, artifactType: FiledReturnsConcreteArtifactType) => transition(ledger, artifactType, "running"), @@ -500,6 +504,45 @@ describe("GSTR-2B all-format selection", () => { }); expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); }); + + it("retains a rejected staged JSON delivery for recovery instead of leaving it untracked", async () => { + mocks.triggerAndObserveFiledReturnDownload.mockImplementation( + async ({ artifactType }: { artifactType: FiledReturnsConcreteArtifactType }) => + artifactType === "JSON" + ? blocked("JSON", "artifact-delivery-unconfirmed") + : downloaded(artifactType), + ); + + await triggerSelectedArtifacts({ + activePeriod: "June", + deps: { + storageKeys: { + completion: "completion", + fullFiscalYearLedger: "ledger", + observation: "observation", + }, + } as never, + scope: { + artifactType: "PDF_AND_EXCEL", + financialYear: "2026-27", + period: "June", + returnType: "GSTR-2B", + }, + tabId: 17, + }); + + expect(bundleMocks.persistSinglePeriodBundleArtifactReview).toHaveBeenCalledWith( + expect.objectContaining({ + artifacts: expect.arrayContaining([ + expect.objectContaining({ artifactType: "JSON", status: "running" }), + ]), + }), + "JSON", + expect.objectContaining({ safeSignals: ["artifact-delivery-unconfirmed"] }), + expect.any(Date), + ); + expect(bundleMocks.persistSinglePeriodBundleArtifactUnavailable).not.toHaveBeenCalled(); + }); }); function downloaded(artifactType: string) { @@ -519,14 +562,14 @@ function downloaded(artifactType: string) { }; } -function blocked(artifactType: string) { +function blocked(artifactType: string, safeSignal = "artifact-generation-timeout") { return { ok: true, flowStep: { connectorId: "gst", scopeId: "gst-gstr2b-private-v0", state: "blocked", - safeSignals: ["artifact-generation-timeout"], + safeSignals: [safeSignal], safeMessage: `${artifactType} failed.`, }, }; From 752856f590306644915674bc1dd3843d94e25b66 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 1 Aug 2026 22:21:09 +0530 Subject: [PATCH 3/3] fix(downloads): retain rejected delivery recovery Signed-off-by: Tapish Khandelwal --- .../filed-returns-download-trigger.ts | 64 +++++++---- .../filed-returns-json-acquisition.ts | 25 +++-- src/background/gstr3b-artifact-acquisition.ts | 25 +++-- ...eturns-download-trigger-checkpoint.test.ts | 106 ++++++++++++++++++ .../filed-returns-json-acquisition.test.ts | 27 +++++ 5 files changed, 203 insertions(+), 44 deletions(-) diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index 9a5cdbc5..b8f0fe00 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -169,23 +169,24 @@ export async function triggerAndObserveFiledReturnDownload({ returnType: "GSTR-3B", tabId, onStarted: async (downloadId) => { + checkpointHasDownloadId = true; + externallyVisibleActionMayHaveOccurred = true; await persistArtifactAcquisitionDownloadId({ ...checkpointTarget, downloadId, requestId, state: "download-observing", }); - checkpointHasDownloadId = true; - externallyVisibleActionMayHaveOccurred = true; }, onStartCheckpointFailed: async (downloadId) => { + checkpointHasDownloadId = true; + externallyVisibleActionMayHaveOccurred = true; await persistArtifactAcquisitionUnconfirmedDownload({ ...checkpointTarget, downloadId, requestId, state: "download-unconfirmed", }); - externallyVisibleActionMayHaveOccurred = true; }, }); retainCheckpointForRecovery = @@ -256,15 +257,16 @@ export async function triggerAndObserveFiledReturnDownload({ returnPeriod, tabId, onStarted: async (downloadId) => { + checkpointHasDownloadId = true; await persistArtifactAcquisitionDownloadId({ ...checkpointTarget, downloadId, requestId, state: "download-observing", }); - checkpointHasDownloadId = true; }, onStartCheckpointFailed: async (downloadId) => { + checkpointHasDownloadId = true; await persistArtifactAcquisitionUnconfirmedDownload({ ...checkpointTarget, downloadId, @@ -333,9 +335,12 @@ function shouldRetainArtifactAcquisitionCheckpoint( // intent so the next start routes it through recovery review instead of // repeating the portal action. if ( - ["checkpoint-failed", "generation-timeout", "main-world-execution-failed"].includes( - delivery.reason, - ) + [ + "checkpoint-failed", + "delivery-unconfirmed", + "generation-timeout", + "main-world-execution-failed", + ].includes(delivery.reason) ) return true; // The browser already created an exact-ID item for these outcomes. It may @@ -466,23 +471,24 @@ async function triggerPageGeneratedSinglePeriodArtifact( try { const callbacks = { onStarted: async (downloadId: number) => { + checkpointHasDownloadId = true; + externallyVisibleActionMayHaveOccurred = true; await persistArtifactAcquisitionDownloadId({ ...checkpointTarget, downloadId, requestId, state: "download-observing", }); - checkpointHasDownloadId = true; - externallyVisibleActionMayHaveOccurred = true; }, onStartCheckpointFailed: async (downloadId: number) => { + checkpointHasDownloadId = true; + externallyVisibleActionMayHaveOccurred = true; await persistArtifactAcquisitionUnconfirmedDownload({ ...checkpointTarget, downloadId, requestId, state: "download-unconfirmed", }); - externallyVisibleActionMayHaveOccurred = true; }, }; const acquired = @@ -619,13 +625,18 @@ async function deliverValidatedArtifact({ > { const staging = deps.stageCapturedDownloads; if (staging) { - const result = await stageOffscreenFiledReturn({ - artifactType, - dataUrl: `data:${mimeType};base64,${base64}`, - ledgerId: staging.ledgerId, - returnType, - zipPath: safeFiledReturnZipEntryPath(scope, artifactType), - }); + let result; + try { + result = await stageOffscreenFiledReturn({ + artifactType, + dataUrl: `data:${mimeType};base64,${base64}`, + ledgerId: staging.ledgerId, + returnType, + zipPath: safeFiledReturnZipEntryPath(scope, artifactType), + }); + } catch { + return { ok: false, reason: "delivery-unconfirmed", safeSignals }; + } return result.status === "staged" ? { ok: true, @@ -638,13 +649,18 @@ async function deliverValidatedArtifact({ } : { ok: false, reason: result.errorCategory ?? "stage-failed", safeSignals }; } - const delivery = await downloadAcquiredArtifact({ - base64, - filename, - mimeType, - requestId, - ...callbacks, - }); + let delivery; + try { + delivery = await downloadAcquiredArtifact({ + base64, + filename, + mimeType, + requestId, + ...callbacks, + }); + } catch { + return { ok: false, reason: "delivery-unconfirmed", safeSignals }; + } return delivery.ok ? { ok: true, diff --git a/src/background/filed-returns-json-acquisition.ts b/src/background/filed-returns-json-acquisition.ts index 5b29a413..74b7c344 100644 --- a/src/background/filed-returns-json-acquisition.ts +++ b/src/background/filed-returns-json-acquisition.ts @@ -59,16 +59,21 @@ export async function acquireFiledReturnJsonInMainWorld(input: { return { ok: false, reason: "delivery-unconfirmed", safeSignals: [] }; } } - const delivery = await downloadAcquiredArtifact({ - requestId: input.requestId, - base64: captured.base64, - filename: input.filename, - mimeType: validation.mimeType, - ...(input.onStarted ? { onStarted: input.onStarted } : {}), - ...(input.onStartCheckpointFailed - ? { onStartCheckpointFailed: input.onStartCheckpointFailed } - : {}), - }); + let delivery; + try { + delivery = await downloadAcquiredArtifact({ + requestId: input.requestId, + base64: captured.base64, + filename: input.filename, + mimeType: validation.mimeType, + ...(input.onStarted ? { onStarted: input.onStarted } : {}), + ...(input.onStartCheckpointFailed + ? { onStartCheckpointFailed: input.onStartCheckpointFailed } + : {}), + }); + } catch { + return { ok: false, reason: "delivery-unconfirmed", safeSignals: [] }; + } return delivery.ok ? { ok: true, diff --git a/src/background/gstr3b-artifact-acquisition.ts b/src/background/gstr3b-artifact-acquisition.ts index e79946b3..9e9c73e7 100644 --- a/src/background/gstr3b-artifact-acquisition.ts +++ b/src/background/gstr3b-artifact-acquisition.ts @@ -60,16 +60,21 @@ export async function acquireGstr3bPdfAfterPreflight(input: { const bytes = Uint8Array.from(atob(captured.base64), (value) => value.charCodeAt(0)); const validation = validateArtifactBytes(bytes, "PDF", input.returnPeriod); if (!validation.ok) return { ok: false, reason: validation.reason, safeSignals: [] }; - const delivery = await downloadAcquiredArtifact({ - requestId: input.requestId, - base64: captured.base64, - filename: input.filename, - mimeType: validation.mimeType, - ...(input.onStarted ? { onStarted: input.onStarted } : {}), - ...(input.onStartCheckpointFailed - ? { onStartCheckpointFailed: input.onStartCheckpointFailed } - : {}), - }); + let delivery; + try { + delivery = await downloadAcquiredArtifact({ + requestId: input.requestId, + base64: captured.base64, + filename: input.filename, + mimeType: validation.mimeType, + ...(input.onStarted ? { onStarted: input.onStarted } : {}), + ...(input.onStartCheckpointFailed + ? { onStartCheckpointFailed: input.onStartCheckpointFailed } + : {}), + }); + } catch { + return { ok: false, reason: "delivery-unconfirmed", safeSignals: [] }; + } return delivery.ok ? { ok: true, diff --git a/tests/background/filed-returns-download-trigger-checkpoint.test.ts b/tests/background/filed-returns-download-trigger-checkpoint.test.ts index 5a3a98d4..2de533ca 100644 --- a/tests/background/filed-returns-download-trigger-checkpoint.test.ts +++ b/tests/background/filed-returns-download-trigger-checkpoint.test.ts @@ -49,6 +49,7 @@ const RETAINED_TERMINAL_DELIVERY_FAILURES = [ "danger-unconfirmed", "danger-rejected", "empty", + "delivery-unconfirmed", "generation-timeout", "main-world-execution-failed", "interrupted", @@ -59,6 +60,11 @@ describe("GSTR-3B artifact acquisition checkpoint cleanup", () => { beforeEach(() => { for (const key of Object.keys(mocks.session)) delete mocks.session[key]; vi.clearAllMocks(); + mocks.browser.storage.session.set.mockImplementation( + async (values: Record) => { + return Object.assign(mocks.session, values); + }, + ); }); it.each(Object.keys(ARTIFACT_FAILURE_MESSAGES))( @@ -193,6 +199,106 @@ describe("GSTR-3B artifact acquisition checkpoint cleanup", () => { ); }); + it("retains the exact JSON download ID when both checkpoint writes report rejection after committing", async () => { + let checkpointWrites = 0; + mocks.browser.storage.session.set.mockImplementation( + async (values: Record) => { + const stored = Object.assign(mocks.session, values); + checkpointWrites += 1; + if (checkpointWrites > 1) throw new Error("synthetic checkpoint storage rejection"); + return stored; + }, + ); + mocks.downloadAcquiredArtifact.mockImplementationOnce(async (input) => { + try { + await input.onStarted?.(91); + } catch { + try { + await input.onStartCheckpointFailed?.(91); + } catch { + // A response may reject after Chrome persisted the exact ID. + } + } + return { ok: false, reason: "checkpoint-failed", safeSignals: [] }; + }); + const target = { ...scope, artifactType: "JSON" as const }; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "May", + artifactType: "JSON", + deps: { + sendMessageToTabWithInjection: vi.fn(async () => acquiredJson()), + storageKeys: {}, + }, + scope: target, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { safeMessage: expect.any(String), state: "blocked" }, + }); + expect(mocks.session[artifactAcquisitionCheckpointKey(target)]).toEqual( + expect.objectContaining({ downloadId: 91, state: "download-unconfirmed" }), + ); + }); + + it("retains the JSON intent when neither checkpoint write commits", async () => { + let checkpointWrites = 0; + mocks.browser.storage.session.set.mockImplementation( + async (values: Record) => { + checkpointWrites += 1; + if (checkpointWrites > 1) throw new Error("synthetic checkpoint storage rejection"); + return Object.assign(mocks.session, values); + }, + ); + mocks.downloadAcquiredArtifact.mockImplementationOnce(async (input) => { + try { + await input.onStarted?.(91); + } catch { + await input.onStartCheckpointFailed?.(91).catch(() => undefined); + } + return { ok: false, reason: "checkpoint-failed", safeSignals: [] }; + }); + const target = { ...scope, artifactType: "JSON" as const }; + + await triggerAndObserveFiledReturnDownload({ + activePeriod: "May", + artifactType: "JSON", + deps: { + sendMessageToTabWithInjection: vi.fn(async () => acquiredJson()), + storageKeys: {}, + }, + scope: target, + tabId: 17, + }); + + expect(mocks.session[artifactAcquisitionCheckpointKey(target)]).toEqual( + expect.objectContaining({ state: "intent" }), + ); + }); + + it("renders a blocked user-visible result for an unconfirmed direct JSON delivery", async () => { + mocks.acquireFiledReturnJsonInMainWorld.mockResolvedValueOnce({ + ok: false, + reason: "delivery-unconfirmed", + safeSignals: [], + }); + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "May", + artifactType: "JSON", + deps: { + sendMessageToTabWithInjection: vi.fn(async () => acquiredJson()), + storageKeys: {}, + }, + scope: { ...scope, artifactType: "JSON" }, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { safeMessage: expect.any(String), state: "blocked" }, + }); + }); + it("retains the page-generated PDF intent when MAIN-world execution cannot be proven", async () => { mocks.acquireGstr3bPdfAfterPreflight.mockResolvedValueOnce({ ok: false, diff --git a/tests/background/filed-returns-json-acquisition.test.ts b/tests/background/filed-returns-json-acquisition.test.ts index 78457188..2dbd64c6 100644 --- a/tests/background/filed-returns-json-acquisition.test.ts +++ b/tests/background/filed-returns-json-acquisition.test.ts @@ -214,4 +214,31 @@ describe("filed-return JSON main-world acquisition", () => { expect(mocks.downloadAcquiredArtifact).not.toHaveBeenCalled(); }); + + it("returns a terminal safe failure when direct offscreen delivery rejects", async () => { + vi.mocked(browser.scripting.executeScript).mockResolvedValue([ + { + result: { + ok: true, + base64: base64Json({ + data: { rtnprd: "062026", padding: "x".repeat(100) }, + status: 1, + }), + }, + }, + ] as never); + mocks.downloadAcquiredArtifact.mockRejectedValueOnce( + new Error("synthetic offscreen delivery rejection"), + ); + + await expect( + acquireFiledReturnJsonInMainWorld({ + filename: "synthetic.json", + requestId: "synthetic-request", + returnPeriod: "062026", + returnType: "GSTR-2B", + tabId: 17, + }), + ).resolves.toEqual({ ok: false, reason: "delivery-unconfirmed", safeSignals: [] }); + }); });