clipboard.double-failure.test.ts

7.1 KB · raw

1// T-037: clipboard double-failure / alreadyWritten path2//3// Why these tests exist: copyToClipboard in clipboard.ts has two untested paths:4//5//   1. `alreadyWritten=true` skips the writeToClipboard() call.6//      The popup/content caller may have already written to the clipboard from a7//      focused document with a fresh user gesture (more reliable than the SW's8//      executeScript path). When `alreadyWritten=true`, the SW should only9//      schedule the auto-clear alarm — NOT attempt a second write that would10//      fail or overwrite the user's clipboard.11//12//   2. Both clipboard write paths fail (chrome.scripting.executeScript + direct13//      navigator.clipboard.writeText). The function must not throw; the alarm14//      is still scheduled so the clear attempt fires on a retry.15//16// The existing clipboard.test.ts covers the happy path (write + clear).17// These tests cover the edge paths that complete the audit finding HV-2.1819import { beforeEach, describe, expect, it, vi } from "vitest";2021type StorageShape = Record<string, unknown>;2223describe("T-037: clipboard.ts alreadyWritten and double-failure paths", () => {24  let storage: StorageShape;25  let sessionStorage: StorageShape;2627  beforeEach(async () => {28    vi.resetModules();29    storage = {};30    sessionStorage = {};3132    const alarmListeners: Array<(alarm: { name: string }) => void> = [];33    const createdAlarms: Array<{ name: string; opts: Record<string, unknown> }> = [];3435    const chromeMock = {36      storage: {37        local: {38          get: vi.fn(async (key?: string | string[]) => {39            if (!key) return { ...storage };40            if (typeof key === "string") return { [key]: storage[key] };41            return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(42              (acc, k) => { acc[k] = storage[k]; return acc; },43              {},44            );45          }),46          set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),47          remove: vi.fn(async () => {}),48        },49        session: {50          get: vi.fn(async (key?: string | string[]) => {51            if (!key) return { ...sessionStorage };52            if (typeof key === "string") return { [key]: sessionStorage[key] };53            return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(54              (acc, k) => { acc[k] = sessionStorage[k]; return acc; },55              {},56            );57          }),58          set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),59          remove: vi.fn(async () => {}),60        },61      },62      alarms: {63        clear: vi.fn(async () => true),64        create: vi.fn(async (name: string, opts: Record<string, unknown>) => {65          createdAlarms.push({ name, opts });66        }),67        onAlarm: {68          addListener: vi.fn((cb: (alarm: { name: string }) => void) => {69            alarmListeners.push(cb);70          }),71        },72      },73      tabs: {74        query: vi.fn(async () => []),75      },76      scripting: {77        executeScript: vi.fn(async () => { throw new Error("executeScript failed"); }),78      },79    } as unknown as typeof chrome;8081    (globalThis as Record<string, unknown>).chrome = chromeMock;82    (globalThis as Record<string, unknown>)._createdAlarms = createdAlarms;8384    // Mock navigator.clipboard for this test suite85    Object.defineProperty(globalThis, "navigator", {86      value: { clipboard: { writeText: vi.fn() } },87      configurable: true,88    });89  });9091  // T-037a: alreadyWritten=true → writeToClipboard is NOT called.92  // The caller has already written the clipboard via a user-gesture path.93  // The background script must only schedule the auto-clear alarm.94  //95  // Bug this catches: ignoring `alreadyWritten` flag would double-write,96  // potentially overwriting the clipboard with the previous value if97  // executeScript fails and falls through to navigator.clipboard.98  it("T-037a: alreadyWritten=true → navigator.clipboard.writeText NOT called", async () => {99    const { copyToClipboard } = await import("../../background/clipboard");100101    const writeTextMock = vi.fn(async () => {});102    Object.defineProperty(globalThis.navigator, "clipboard", {103      value: { writeText: writeTextMock },104      configurable: true,105    });106107    await copyToClipboard("secret-value", "Password", true /* alreadyWritten */);108109    // navigator.clipboard.writeText must NOT be called (the caller already wrote)110    expect(writeTextMock).not.toHaveBeenCalled();111  });112113  // T-037b: alreadyWritten=true → the auto-clear alarm IS still scheduled.114  // Even when we skip writing, the alarm must fire to clear the clipboard later.115  it("T-037b: alreadyWritten=true → alarm is still created for auto-clear", async () => {116    const { copyToClipboard } = await import("../../background/clipboard");117    const alarmsMock = chrome.alarms as { create: ReturnType<typeof vi.fn> };118119    await copyToClipboard("secret-value", "Password", true);120121    // The alarm must have been created122    expect(alarmsMock.create).toHaveBeenCalled();123    const alarmName = (alarmsMock.create.mock.calls[0] as [string, unknown])[0];124    expect(alarmName).toBe("peach-clipboard-clear-current");125  });126127  // T-037c: both clipboard write paths fail → copyToClipboard does NOT throw.128  // executeScript fails (tab query returns no tabs or scripting throws) AND129  // navigator.clipboard.writeText also throws. The alarm must still be130  // scheduled so the secret is not silently left without a clear attempt.131  //132  // Bug this catches: an uncaught error in writeToClipboard propagating up133  // through copyToClipboard would leave the alarm unscheduled — the clipboard134  // would never be cleared.135  it("T-037c: both write paths fail → copyToClipboard does not throw (alarm still scheduled)", async () => {136    // Make navigator.clipboard.writeText throw137    Object.defineProperty(globalThis.navigator, "clipboard", {138      value: { writeText: vi.fn(async () => { throw new Error("clipboard API unavailable"); }) },139      configurable: true,140    });141    // executeScript already fails (no tabs in mock)142143    const { copyToClipboard } = await import("../../background/clipboard");144    const alarmsMock = chrome.alarms as { create: ReturnType<typeof vi.fn> };145146    // Must not throw even when both paths fail147    await expect(copyToClipboard("secret", "Password", false)).resolves.toBeUndefined();148149    // Alarm must still be scheduled150    expect(alarmsMock.create).toHaveBeenCalled();151  });152153  // T-037d: alreadyWritten=false (default) → write IS attempted.154  // Verifies the normal path still works after the alreadyWritten tests.155  it("T-037d: alreadyWritten=false (default) → clipboard write is attempted", async () => {156    const writeTextMock = vi.fn(async () => {});157    Object.defineProperty(globalThis.navigator, "clipboard", {158      value: { writeText: writeTextMock },159      configurable: true,160    });161162    const { copyToClipboard } = await import("../../background/clipboard");163164    await copyToClipboard("secret", "Password", false);165166    // Either executeScript OR navigator.clipboard was used.167    // Since executeScript fails (no tab), fallback goes to navigator.clipboard.168    expect(writeTextMock).toHaveBeenCalledWith("secret");169  });170});