// @vitest-environment jsdom // // PB-26-112: the 10 MiB import ceiling used to be enforced AFTER the file was // materialised as a string, but only on the CSV/JSON branch — the .peach picker // checked `file.size` first. A multi-gigabyte CSV was therefore read into a // popup string before any validation ran. // // Both branches now check `file.size` before `file.text()`. These tests assert // the rejection path with a stubbed File: the stub's text() records the call, so // "refused without reading it" is directly observable rather than inferred. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() })); vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage })); import { IMPORT_LIMITS } from "../../shared/import-limits"; import { ImportView } from "./Import"; import { flushView, renderView } from "./view-test-utils"; let mounted: Array<{ dispose: () => void }> = []; /** * A File-shaped stub large enough to exceed the ceiling. `size` is the only * field the guards read; `text()` is what must never be called. */ function oversizedFile(name: string): File & { readCalls: number } { const file = { name, size: IMPORT_LIMITS.MAX_JSON_BYTES + 1, type: "text/csv", readCalls: 0, async text() { file.readCalls += 1; return ""; }, async arrayBuffer() { file.readCalls += 1; return new ArrayBuffer(0); }, } as unknown as File & { readCalls: number }; return file; } function attachOversized(input: HTMLInputElement, file: File): void { Object.defineProperty(input, "files", { configurable: true, get: () => [file], }); } beforeEach(() => { mounted = []; mockSendMessage.mockReset(); mockSendMessage.mockImplementation(() => Promise.resolve({ ok: true, data: null })); (globalThis as unknown as { chrome: unknown }).chrome = { runtime: { onMessage: { addListener: () => undefined, removeListener: () => undefined }, getURL: (path: string) => `chrome-extension://test/${path}`, }, storage: { local: { get: () => Promise.resolve({}), set: () => Promise.resolve(), remove: () => Promise.resolve(), }, session: { get: () => Promise.resolve({}), set: () => Promise.resolve(), remove: () => Promise.resolve(), }, }, }; }); afterEach(() => { for (const view of mounted) view.dispose(); mounted = []; vi.restoreAllMocks(); }); describe("PB-26-112: import branches bound the file before reading it", () => { it("the CSV/JSON picker refuses an oversized file without reading it", async () => { const view = renderView(() => ( undefined} onImport={() => undefined} /> )); mounted.push(view); await flushView(); const picker = view.container.querySelector( 'input[type="file"][accept=".csv,.json,.peach,application/x-peach"]', ); expect(picker, "expected a CSV/JSON file input").not.toBeNull(); const file = oversizedFile("huge.csv"); attachOversized(picker!, file); picker!.dispatchEvent(new Event("change", { bubbles: true })); await flushView(); expect(file.readCalls).toBe(0); expect(view.container.textContent).toContain("too large"); }); it("the .peach picker refuses an oversized file without reading it", async () => { const view = renderView(() => ( undefined} onImport={() => undefined} /> )); mounted.push(view); await flushView(); const picker = view.container.querySelector( 'input[type="file"][accept=".peach,application/x-peach,text/plain"]', ); expect(picker, "expected a .peach file input").not.toBeNull(); const file = oversizedFile("huge.peach"); attachOversized(picker!, file); picker!.dispatchEvent(new Event("change", { bubbles: true })); await flushView(); expect(file.readCalls).toBe(0); expect(view.container.textContent).toContain("too large"); }); });