Lock.test.tsx

5.6 KB · raw

1// @vitest-environment jsdom2//3// PB-26-208: every .peach entry path checks File.size before file.text() —4// including drag/drop and the file input on the lock screen restore form.5// The stub File's text() records the call, so "refused without reading it"6// is directly observable rather than inferred.7import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";89const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));10vi.mock("../App", () => ({ sendMessage: mockSendMessage }));11vi.mock("../components/RuneBackground", () => ({12  RuneBackground: () => null,13}));14vi.mock("../components/RuneCanvas", () => ({ RuneCanvas: () => null }));1516import { IMPORT_LIMITS } from "../../shared/import-limits";17import { LockView } from "./Lock";18import { flushView, renderView } from "./view-test-utils";1920let mounted: Array<{ dispose: () => void }> = [];2122/**23 * A File-shaped stub over the ceiling. `size` is the only field the guards24 * read; `text()` is what must never be called.25 */26function oversizedFile(name: string): File & { readCalls: number } {27  const file = {28    name,29    size: IMPORT_LIMITS.MAX_JSON_BYTES + 1,30    type: "text/plain",31    readCalls: 0,32    async text() {33      file.readCalls += 1;34      return "";35    },36    async arrayBuffer() {37      file.readCalls += 1;38      return new ArrayBuffer(0);39    },40  } as unknown as File & { readCalls: number };41  return file;42}4344function renderSymbolsRestore() {45  const view = renderView(() => (46    <LockView47      hasVault={true}48      ready={true}49      onUnlock={() => undefined}50      initialRestore={true}51      initialRestoreSource="symbols"52    />53  ));54  mounted.push(view);55  return view;56}5758function renderCsvRestore() {59  const view = renderView(() => (60    <LockView61      hasVault={true}62      ready={true}63      onUnlock={() => undefined}64      initialRestore={true}65      initialRestoreSource="csv"66    />67  ));68  mounted.push(view);69  return view;70}7172beforeEach(() => {73  mounted = [];74  mockSendMessage.mockReset();75  mockSendMessage.mockImplementation(() => Promise.resolve({ ok: false }));76  (globalThis as unknown as { chrome: unknown }).chrome = {77    runtime: {78      onMessage: { addListener: () => undefined, removeListener: () => undefined },79      getURL: (path: string) => `chrome-extension://test/${path}`,80    },81    storage: {82      local: {83        get: () => Promise.resolve({}),84        set: () => Promise.resolve(),85        remove: () => Promise.resolve(),86      },87      session: {88        get: () => Promise.resolve({}),89        set: () => Promise.resolve(),90        remove: () => Promise.resolve(),91      },92    },93  };94});9596afterEach(() => {97  for (const view of mounted) view.dispose();98  mounted = [];99  vi.restoreAllMocks();100});101102describe("PB-26-310: lock-screen CSV/JSON paths bound the file before reading it", () => {103  it("the CSV/JSON picker refuses an oversized file without reading it", async () => {104    const view = renderCsvRestore();105    await flushView();106107    const picker = view.container.querySelector<HTMLInputElement>(108      'input[type="file"][accept=".csv,.json,.peach,application/x-peach"]',109    );110    expect(picker, "expected a CSV/JSON file input").not.toBeNull();111112    const file = oversizedFile("huge.csv");113    Object.defineProperty(picker!, "files", {114      configurable: true,115      get: () => [file],116    });117    picker!.dispatchEvent(new Event("change", { bubbles: true }));118    await flushView();119120    expect(file.readCalls).toBe(0);121    expect(view.container.textContent).toContain("too large");122  });123124  it("CSV/JSON drag/drop refuses an oversized file without reading it", async () => {125    const view = renderCsvRestore();126    await flushView();127128    const form = view.container.querySelector<HTMLElement>(".peach-restore-form");129    expect(form, "expected the CSV/JSON restore form").not.toBeNull();130131    const file = oversizedFile("huge.json");132    const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {133      dataTransfer?: { files: File[] };134    };135    drop.dataTransfer = { files: [file] };136    form!.dispatchEvent(drop);137    await flushView();138139    expect(file.readCalls).toBe(0);140    expect(view.container.textContent).toContain("too large");141  });142});143144describe("PB-26-208: lock-screen .peach paths bound the file before reading it", () => {145  it("drag/drop refuses an oversized file without reading it", async () => {146    const view = renderSymbolsRestore();147    await flushView();148149    const form = view.container.querySelector<HTMLElement>(".peach-restore-form");150    expect(form, "expected the symbols restore form").not.toBeNull();151152    const file = oversizedFile("vault.peach");153    const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {154      dataTransfer?: { files: File[] };155    };156    drop.dataTransfer = { files: [file] };157    form!.dispatchEvent(drop);158    await flushView();159160    expect(file.readCalls).toBe(0);161    expect(view.container.textContent).toContain("too large");162  });163164  it("the file input refuses an oversized file without reading it", async () => {165    const view = renderSymbolsRestore();166    await flushView();167168    const picker = view.container.querySelector<HTMLInputElement>(169      'input[type="file"][accept=".peach,text/plain"]',170    );171    expect(picker, "expected a .peach file input").not.toBeNull();172173    const file = oversizedFile("vault.peach");174    Object.defineProperty(picker!, "files", {175      configurable: true,176      get: () => [file],177    });178    picker!.dispatchEvent(new Event("change", { bubbles: true }));179    await flushView();180181    expect(file.readCalls).toBe(0);182    expect(view.container.textContent).toContain("too large");183  });184});