// T-037: clipboard double-failure / alreadyWritten path // // Why these tests exist: copyToClipboard in clipboard.ts has two untested paths: // // 1. `alreadyWritten=true` skips the writeToClipboard() call. // The popup/content caller may have already written to the clipboard from a // focused document with a fresh user gesture (more reliable than the SW's // executeScript path). When `alreadyWritten=true`, the SW should only // schedule the auto-clear alarm — NOT attempt a second write that would // fail or overwrite the user's clipboard. // // 2. Both clipboard write paths fail (chrome.scripting.executeScript + direct // navigator.clipboard.writeText). The function must not throw; the alarm // is still scheduled so the clear attempt fires on a retry. // // The existing clipboard.test.ts covers the happy path (write + clear). // These tests cover the edge paths that complete the audit finding HV-2. import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; describe("T-037: clipboard.ts alreadyWritten and double-failure paths", () => { let storage: StorageShape; let sessionStorage: StorageShape; beforeEach(async () => { vi.resetModules(); storage = {}; sessionStorage = {}; const alarmListeners: Array<(alarm: { name: string }) => void> = []; const createdAlarms: Array<{ name: string; opts: Record }> = []; const chromeMock = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; if (typeof key === "string") return { [key]: storage[key] }; return (Array.isArray(key) ? key : [key]).reduce( (acc, k) => { acc[k] = storage[k]; return acc; }, {}, ); }), set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }), remove: vi.fn(async () => {}), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...sessionStorage }; if (typeof key === "string") return { [key]: sessionStorage[key] }; return (Array.isArray(key) ? key : [key]).reduce( (acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}, ); }), set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(async () => true), create: vi.fn(async (name: string, opts: Record) => { createdAlarms.push({ name, opts }); }), onAlarm: { addListener: vi.fn((cb: (alarm: { name: string }) => void) => { alarmListeners.push(cb); }), }, }, tabs: { query: vi.fn(async () => []), }, scripting: { executeScript: vi.fn(async () => { throw new Error("executeScript failed"); }), }, } as unknown as typeof chrome; (globalThis as Record).chrome = chromeMock; (globalThis as Record)._createdAlarms = createdAlarms; // Mock navigator.clipboard for this test suite Object.defineProperty(globalThis, "navigator", { value: { clipboard: { writeText: vi.fn() } }, configurable: true, }); }); // T-037a: alreadyWritten=true → writeToClipboard is NOT called. // The caller has already written the clipboard via a user-gesture path. // The background script must only schedule the auto-clear alarm. // // Bug this catches: ignoring `alreadyWritten` flag would double-write, // potentially overwriting the clipboard with the previous value if // executeScript fails and falls through to navigator.clipboard. it("T-037a: alreadyWritten=true → navigator.clipboard.writeText NOT called", async () => { const { copyToClipboard } = await import("./clipboard"); const writeTextMock = vi.fn(async () => {}); Object.defineProperty(globalThis.navigator, "clipboard", { value: { writeText: writeTextMock }, configurable: true, }); await copyToClipboard("secret-value", "Password", true /* alreadyWritten */); // navigator.clipboard.writeText must NOT be called (the caller already wrote) expect(writeTextMock).not.toHaveBeenCalled(); }); // T-037b: alreadyWritten=true → the auto-clear alarm IS still scheduled. // Even when we skip writing, the alarm must fire to clear the clipboard later. it("T-037b: alreadyWritten=true → alarm is still created for auto-clear", async () => { const { copyToClipboard } = await import("./clipboard"); const alarmsMock = chrome.alarms as unknown as { create: ReturnType }; await copyToClipboard("secret-value", "Password", true); // The alarm must have been created expect(alarmsMock.create).toHaveBeenCalled(); const alarmName = (alarmsMock.create.mock.calls[0] as [string, unknown])[0]; expect(alarmName).toBe("peach-clipboard-clear-current"); }); // T-037c: both clipboard write paths fail → copyToClipboard does NOT throw. // executeScript fails (tab query returns no tabs or scripting throws) AND // navigator.clipboard.writeText also throws. The alarm must still be // scheduled so the secret is not silently left without a clear attempt. // // Bug this catches: an uncaught error in writeToClipboard propagating up // through copyToClipboard would leave the alarm unscheduled — the clipboard // would never be cleared. it("T-037c: both write paths fail → copyToClipboard does not throw (alarm still scheduled)", async () => { // Make navigator.clipboard.writeText throw Object.defineProperty(globalThis.navigator, "clipboard", { value: { writeText: vi.fn(async () => { throw new Error("clipboard API unavailable"); }) }, configurable: true, }); // executeScript already fails (no tabs in mock) const { copyToClipboard } = await import("./clipboard"); const alarmsMock = chrome.alarms as unknown as { create: ReturnType }; // Must not throw even when both paths fail await expect(copyToClipboard("secret", "Password", false)).resolves.toBeUndefined(); // Alarm must still be scheduled expect(alarmsMock.create).toHaveBeenCalled(); }); // T-037d: alreadyWritten=false (default) → write IS attempted. // Verifies the normal path still works after the alreadyWritten tests. it("T-037d: alreadyWritten=false (default) → clipboard write is attempted", async () => { const writeTextMock = vi.fn(async () => {}); Object.defineProperty(globalThis.navigator, "clipboard", { value: { writeText: writeTextMock }, configurable: true, }); const { copyToClipboard } = await import("./clipboard"); await copyToClipboard("secret", "Password", false); // Either executeScript OR navigator.clipboard was used. // Since executeScript fails (no tab), fallback goes to navigator.clipboard. expect(writeTextMock).toHaveBeenCalledWith("secret"); }); });