clipboard.test.ts

10.1 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;45// Flush the fire-and-forget clearClipboardNow() promise chain. Fake timers6// mock timers, not microtasks, so awaiting plain microtasks drains it.7async function flushMicrotasks(): Promise<void> {8  for (let i = 0; i < 25; i++) await Promise.resolve();9}1011describe("background clipboard", () => {12  let storage: StorageShape;13  let session: StorageShape;14  let clipboardWriteText: ReturnType<typeof vi.fn>;15  let alarmsCreate: ReturnType<typeof vi.fn>;16  let alarmsClear: ReturnType<typeof vi.fn>;17  let alarmListeners: Array<(alarm: { name: string }) => void>;18  let sessionRemove: ReturnType<typeof vi.fn>;1920  beforeEach(async () => {21    vi.resetModules();22    vi.useFakeTimers();23    storage = {};24    session = {};25    clipboardWriteText = vi.fn(async () => undefined);26    alarmsCreate = vi.fn(async () => undefined);27    alarmsClear = vi.fn(async () => true);28    alarmListeners = [];29    sessionRemove = vi.fn(async (key: string) => {30      delete session[key];31    });3233    const chromeMock = {34      runtime: {35        // Default offscreen reply; tests that need offscreen install their own.36        sendMessage: vi.fn(async () => ({ ok: true })),37      },38      tabs: {39        query: vi.fn(async () => []),40      },41      scripting: {42        executeScript: vi.fn(async () => []),43      },44      alarms: {45        clear: alarmsClear,46        create: alarmsCreate,47        onAlarm: {48          addListener: vi.fn((fn: (alarm: { name: string }) => void) => {49            alarmListeners.push(fn);50          }),51        },52      },53      idle: {54        setDetectionInterval: vi.fn(),55        onStateChanged: { addListener: vi.fn() },56      },57      storage: {58        local: {59          get: vi.fn(async (key?: string | string[]) => {60            if (!key) return storage;61            if (typeof key === "string") {62              return { [key]: storage[key] };63            }6465            return key.reduce<Record<string, unknown>>((result, entry) => {66              result[entry] = storage[entry];67              return result;68            }, {});69          }),70          set: vi.fn(async (next: Record<string, unknown>) => {71            Object.assign(storage, next);72          }),73          remove: vi.fn(async () => undefined),74        },75        session: {76          get: vi.fn(async (key: string) => ({ [key]: session[key] })),77          set: vi.fn(async (next: Record<string, unknown>) => {78            Object.assign(session, next);79          }),80          remove: sessionRemove,81        },82      },83    } as unknown as typeof chrome;8485    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;86    vi.stubGlobal("navigator", {87      clipboard: {88        writeText: clipboardWriteText,89      },90    });9192    const vault = await import("./vault");93    await vault.ensureSettingsLoaded();94  });9596  it("copyToClipboard writes text to the clipboard", async () => {97    const { copyToClipboard } = await import("./clipboard");9899    await copyToClipboard("secret", "Password");100101    expect(clipboardWriteText).toHaveBeenCalledWith("secret");102  });103104  it("copyToClipboard clears the clipboard after clipboardClearSeconds", async () => {105    const vault = await import("./vault");106    const { copyToClipboard } = await import("./clipboard");107108    await vault.updateSettings({ clipboardClearSeconds: 1 });109    await copyToClipboard("secret", "Password");110111    await vi.advanceTimersByTimeAsync(1000);112113    expect(clipboardWriteText).toHaveBeenNthCalledWith(2, "");114  });115116  it("copyToClipboard respects custom clipboardClearSeconds", async () => {117    const vault = await import("./vault");118    const { copyToClipboard } = await import("./clipboard");119120    await vault.updateSettings({ clipboardClearSeconds: 10 });121    await copyToClipboard("secret", "Password");122123    await vi.advanceTimersByTimeAsync(9999);124    expect(clipboardWriteText).toHaveBeenCalledTimes(1);125126    await vi.advanceTimersByTimeAsync(2);127    expect(clipboardWriteText).toHaveBeenNthCalledWith(2, "");128  });129130  it("copyToClipboard defaults to 30 seconds when clipboardClearSeconds is absent", async () => {131    const { copyToClipboard } = await import("./clipboard");132133    await copyToClipboard("secret", "Password");134135    await vi.advanceTimersByTimeAsync(29999);136    expect(clipboardWriteText).toHaveBeenCalledTimes(1);137138    await vi.advanceTimersByTimeAsync(2);139    expect(clipboardWriteText).toHaveBeenNthCalledWith(2, "");140  });141142  // --- HV-2: eviction-proof clear via chrome.alarms + offscreen ---143144  it("HV-2: copyToClipboard arms the clear alarm (delayInMinutes >= 0.5)", async () => {145    const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(146      "./clipboard"147    );148149    await copyToClipboard("secret", "Password");150151    expect(alarmsCreate).toHaveBeenCalledWith(152      CLIPBOARD_CLEAR_ALARM,153      expect.objectContaining({ delayInMinutes: expect.any(Number) }),154    );155    const arg = alarmsCreate.mock.calls.at(-1)?.[1] as {156      delayInMinutes: number;157    };158    expect(arg.delayInMinutes).toBeGreaterThanOrEqual(0.5);159  });160161  it("HV-2: alarm handler routes the clear through the offscreen document when present", async () => {162    const sendMessage = vi.fn(async () => ({ ok: true }));163    const createDocument = vi.fn(async () => undefined);164    const closeDocument = vi.fn(async () => undefined);165    (chrome as unknown as { offscreen: unknown }).offscreen = {166      createDocument,167      closeDocument,168      Reason: { CLIPBOARD: "CLIPBOARD" },169    };170    (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage =171      sendMessage;172173    const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(174      "./clipboard"175    );176    // vault's alarms.ts already registered an autolock onAlarm listener; the177    // clipboard listener is the one registered now (last).178    setupClipboardClearAlarm();179    const clipListener = alarmListeners.at(-1)!;180181    // PCH-01-021: the alarm only clears when a clear is still owed.182    session["clipboard-clear-hash"] = "owed";183    clipListener({ name: CLIPBOARD_CLEAR_ALARM });184    await flushMicrotasks();185186    expect(createDocument).toHaveBeenCalledOnce();187    expect(sendMessage).toHaveBeenCalledWith({ type: "CLEAR_CLIPBOARD" });188    // The offscreen clear succeeded, so we never fell back to the active-tab write.189    expect(clipboardWriteText).not.toHaveBeenCalled();190  });191192  it("HV-2: alarm handler ignores unrelated alarm names", async () => {193    const { setupClipboardClearAlarm } = await import("./clipboard");194    setupClipboardClearAlarm();195    const clipListener = alarmListeners.at(-1)!;196197    clipListener({ name: "some-other-alarm" });198    await flushMicrotasks();199200    expect(clipboardWriteText).not.toHaveBeenCalled();201  });202203  it("HV-2: alarm handler falls back to active-tab write when offscreen is absent (Firefox)", async () => {204    // No chrome.offscreen on this mock — the Firefox path.205    const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(206      "./clipboard"207    );208    setupClipboardClearAlarm();209    const clipListener = alarmListeners.at(-1)!;210211    // PCH-01-021: the alarm only clears when a clear is still owed.212    session["clipboard-clear-hash"] = "owed";213    clipListener({ name: CLIPBOARD_CLEAR_ALARM });214    await flushMicrotasks();215216    expect(clipboardWriteText).toHaveBeenCalledWith("");217  });218219  // PCH-01-021: a stale alarm with no clear owed must skip the clear and drop220  // the alarm, avoiding pointless offscreen-document churn on a cold worker.221  it("PCH-01-021: alarm with no clear-owed flag skips the clear and drops the alarm", async () => {222    const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(223      "./clipboard"224    );225    setupClipboardClearAlarm();226    const clipListener = alarmListeners.at(-1)!;227228    // Empty session → nothing owed.229    delete session["clipboard-clear-hash"];230    alarmsClear.mockClear();231    clipListener({ name: CLIPBOARD_CLEAR_ALARM });232    await flushMicrotasks();233234    expect(clipboardWriteText).not.toHaveBeenCalled();235    expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM);236  });237238  it("HV-2: a second copy clears the previous alarm before re-arming", async () => {239    const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(240      "./clipboard"241    );242243    await copyToClipboard("first", "Password");244    alarmsClear.mockClear();245    alarmsCreate.mockClear();246247    await copyToClipboard("second", "Password");248249    expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM);250    expect(alarmsCreate).toHaveBeenCalled();251    // clear must precede the new create.252    const clearOrder = alarmsClear.mock.invocationCallOrder[0];253    const createOrder = alarmsCreate.mock.invocationCallOrder.at(-1)!;254    expect(clearOrder).toBeLessThan(createOrder);255  });256257  it("HV-2: a failed clear re-arms the alarm and keeps the session hash", async () => {258    // Offscreen present but the clear fails, and the active-tab fallback also259    // fails — the secret must not be silently abandoned.260    (chrome as unknown as { offscreen: unknown }).offscreen = {261      createDocument: vi.fn(async () => undefined),262      closeDocument: vi.fn(async () => undefined),263      Reason: { CLIPBOARD: "CLIPBOARD" },264    };265    (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage = vi.fn(266      async () => ({ ok: false }),267    );268    clipboardWriteText.mockRejectedValue(new Error("no clipboard access"));269270    const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(271      "./clipboard"272    );273    setupClipboardClearAlarm();274    const clipListener = alarmListeners.at(-1)!;275    alarmsCreate.mockClear();276277    // PCH-01-021: a clear is owed (hash present), so the alarm proceeds to clear.278    session["clipboard-clear-hash"] = "owed";279    clipListener({ name: CLIPBOARD_CLEAR_ALARM });280    await flushMicrotasks();281282    // Re-armed rather than swallowed.283    expect(alarmsCreate).toHaveBeenCalledWith(284      CLIPBOARD_CLEAR_ALARM,285      expect.objectContaining({ delayInMinutes: expect.any(Number) }),286    );287    // Session hash bookkeeping NOT torn down while a clear is still owed.288    expect(sessionRemove).not.toHaveBeenCalled();289  });290});