clipboard.test.ts

9.0 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    clipListener({ name: CLIPBOARD_CLEAR_ALARM });182    await flushMicrotasks();183184    expect(createDocument).toHaveBeenCalledOnce();185    expect(sendMessage).toHaveBeenCalledWith({ type: "CLEAR_CLIPBOARD" });186    // The offscreen clear succeeded, so we never fell back to the active-tab write.187    expect(clipboardWriteText).not.toHaveBeenCalled();188  });189190  it("HV-2: alarm handler ignores unrelated alarm names", async () => {191    const { setupClipboardClearAlarm } = await import("./clipboard");192    setupClipboardClearAlarm();193    const clipListener = alarmListeners.at(-1)!;194195    clipListener({ name: "some-other-alarm" });196    await flushMicrotasks();197198    expect(clipboardWriteText).not.toHaveBeenCalled();199  });200201  it("HV-2: alarm handler falls back to active-tab write when offscreen is absent (Firefox)", async () => {202    // No chrome.offscreen on this mock — the Firefox path.203    const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(204      "./clipboard"205    );206    setupClipboardClearAlarm();207    const clipListener = alarmListeners.at(-1)!;208209    clipListener({ name: CLIPBOARD_CLEAR_ALARM });210    await flushMicrotasks();211212    expect(clipboardWriteText).toHaveBeenCalledWith("");213  });214215  it("HV-2: a second copy clears the previous alarm before re-arming", async () => {216    const { copyToClipboard, CLIPBOARD_CLEAR_ALARM } = await import(217      "./clipboard"218    );219220    await copyToClipboard("first", "Password");221    alarmsClear.mockClear();222    alarmsCreate.mockClear();223224    await copyToClipboard("second", "Password");225226    expect(alarmsClear).toHaveBeenCalledWith(CLIPBOARD_CLEAR_ALARM);227    expect(alarmsCreate).toHaveBeenCalled();228    // clear must precede the new create.229    const clearOrder = alarmsClear.mock.invocationCallOrder[0];230    const createOrder = alarmsCreate.mock.invocationCallOrder.at(-1)!;231    expect(clearOrder).toBeLessThan(createOrder);232  });233234  it("HV-2: a failed clear re-arms the alarm and keeps the session hash", async () => {235    // Offscreen present but the clear fails, and the active-tab fallback also236    // fails — the secret must not be silently abandoned.237    (chrome as unknown as { offscreen: unknown }).offscreen = {238      createDocument: vi.fn(async () => undefined),239      closeDocument: vi.fn(async () => undefined),240      Reason: { CLIPBOARD: "CLIPBOARD" },241    };242    (chrome.runtime as unknown as { sendMessage: unknown }).sendMessage = vi.fn(243      async () => ({ ok: false }),244    );245    clipboardWriteText.mockRejectedValue(new Error("no clipboard access"));246247    const { setupClipboardClearAlarm, CLIPBOARD_CLEAR_ALARM } = await import(248      "./clipboard"249    );250    setupClipboardClearAlarm();251    const clipListener = alarmListeners.at(-1)!;252    alarmsCreate.mockClear();253254    clipListener({ name: CLIPBOARD_CLEAR_ALARM });255    await flushMicrotasks();256257    // Re-armed rather than swallowed.258    expect(alarmsCreate).toHaveBeenCalledWith(259      CLIPBOARD_CLEAR_ALARM,260      expect.objectContaining({ delayInMinutes: expect.any(Number) }),261    );262    // Session hash bookkeeping NOT torn down while a clear is still owed.263    expect(sessionRemove).not.toHaveBeenCalled();264  });265});