// @vitest-environment jsdom import type { JSX } from "solid-js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ImportParseResult } from "../../shared/import"; import type { VaultEntry } from "../../shared/types"; import { WORD_LIST } from "../../shared/recovery-word-list"; import { LockView } from "./Lock"; import { ExportView } from "./Export"; import { SettingsView } from "./Settings"; import { DEFAULT_BINDINGS } from "../../shared/shortcuts"; import { EntryView } from "./Entry"; import { ImportView } from "./Import"; import { RecoveryWordEntryView } from "./RecoveryWordEntry"; import { VaultView } from "./Vault"; import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils"; const sendMessageMock = vi.fn<(msg: unknown) => Promise>(); const triggerRingMock = vi.fn(); const triggerPipPulseMock = vi.fn(); const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise>(); const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 })); vi.mock("../App", () => ({ sendMessage: (msg: unknown) => sendMessageMock(msg), triggerRing: () => triggerRingMock(), triggerPipPulse: () => triggerPipPulseMock(), })); vi.mock("../../shared/import", () => ({ parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args), })); vi.mock("../../shared/favicon", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, faviconUrl: () => null }; }); vi.mock("../../crypto/totp", () => ({ generateTOTP: () => ({ code: "123 456", remaining: 24 }), parseOtpauthURI: (value: string) => value.startsWith("otpauth://") ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const } : null, })); vi.mock("../components/GeneratorDrawer", () => ({ GeneratorDrawer: () => null, })); vi.mock("@zxcvbn-ts/core", () => ({ zxcvbn: (value: string) => zxcvbnMock(value), zxcvbnOptions: { setOptions: vi.fn() }, })); vi.mock("@zxcvbn-ts/language-common", () => ({ adjacencyGraphs: {}, dictionary: {}, })); vi.mock("@zxcvbn-ts/language-en", () => ({ translations: {}, dictionary: {}, })); describe("popup contract tests", () => { const disposers: Array<() => void> = []; beforeEach(() => { document.body.innerHTML = ""; sendMessageMock.mockReset(); triggerRingMock.mockReset(); triggerPipPulseMock.mockReset(); parseImportSourceMock.mockReset(); zxcvbnMock.mockReset(); zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 })); parseImportSourceMock.mockResolvedValue({ format: "unknown", label: "Unknown format", entries: [], totalItems: 0, skippedItems: 0, requiresPassword: false, }); vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { return window.setTimeout(() => cb(0), 0) as unknown as number; }); vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id)); vi.stubGlobal("URL", { createObjectURL: vi.fn(() => "blob:peach-test"), revokeObjectURL: vi.fn(), }); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); (globalThis as { chrome?: typeof chrome }).chrome = { runtime: { getManifest: () => ({ version: "1.4.47" }), onMessage: { addListener: vi.fn() }, }, } as unknown as typeof chrome; }); afterEach(() => { while (disposers.length > 0) { disposers.pop()?.(); } vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function mount(view: JSX.Element | (() => JSX.Element)) { const mounted = renderView(view); disposers.push(mounted.dispose); return mounted.container; } function sampleEntry(overrides: Partial = {}): VaultEntry { return { id: "entry-1", type: "login", title: "GitHub", username: "june", password: "secret123", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP", canary: { webhookUrl: "https://example.com/hook", }, createdAt: 1, modifiedAt: 1, ...overrides, }; } function sampleImportResult( overrides: Partial = {} ): ImportParseResult { return { format: "csv", label: "CSV import", entries: [ { type: "login", title: "Imported site", username: "imported-user", password: "imported-pass", url: "https://imported.test", }, ], totalItems: 1, skippedItems: 0, requiresPassword: false, ...overrides, }; } function settingsProps(overrides: Partial[0]> = {}) { return { entriesCount: 1, breachMonitoring: true, lockOnClose: true, autoLockMinutes: 5, trashRetentionDays: 30, theme: "dark" as const, accentColor: "peach" as const, textSize: "medium" as const, reduceMotion: false, onBreachMonitoringChange: vi.fn(), onLockOnCloseChange: vi.fn(), onAutoLockMinutesChange: vi.fn(), onTrashRetentionChange: vi.fn(), onThemeChange: vi.fn(), onAccentChange: vi.fn(), onTextSizeChange: vi.fn(), onReduceMotionChange: vi.fn(), shortcuts: DEFAULT_BINDINGS, onShortcutsChange: vi.fn(), onBack: vi.fn(), onImport: vi.fn(), onExport: vi.fn(), onBreachCheckDone: vi.fn(), ...overrides, }; } async function selectImportFile( container: HTMLElement, contents = '{"export":"test"}' ) { // The Import view now opens onto a menu — click into the file subview // before locating the file input. Idempotent: a second click won't re-fire // because the menu unmounts once the subview renders. const fileCard = findButton(container, "Choose file →"); if (fileCard) { fileCard.click(); await flushView(); } const input = container.querySelector('input[type="file"]') as HTMLInputElement; const file = new File([contents], "import.json", { type: "application/json" }); Object.defineProperty(input, "files", { configurable: true, value: [file], }); input.dispatchEvent(new Event("change", { bubbles: true })); await flushView(); await flushView(); } function findButton(container: HTMLElement, text: string): HTMLButtonElement { return Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === text ) as HTMLButtonElement; } it("Lock sends UNLOCK with the entered passphrase", async () => { sendMessageMock.mockResolvedValue({ ok: true }); const onUnlock = vi.fn(async () => undefined); const container = mount(); const password = container.querySelector("#unlock-password") as HTMLInputElement; inputText(password, "correct horse battery staple"); (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "UNLOCK", payload: { password: "correct horse battery staple" }, }); expect(onUnlock).toHaveBeenCalled(); }); it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => { const container = mount(); const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement; const password = container.querySelector("#master-password") as HTMLInputElement; const confirm = container.querySelector("#confirm-password") as HTMLInputElement; inputText(password, "short"); inputText(confirm, "short"); await flushView(); expect(createButton.disabled).toBe(true); zxcvbnMock.mockImplementation(() => ({ score: 1 })); inputText(password, "weakweakweak"); inputText(confirm, "weakweakweak"); await flushView(); expect(createButton.disabled).toBe(true); }); it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => { // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock sendMessageMock.mockImplementation( async (msg: unknown) => { const m = msg as { type: string; payload?: { password?: string } }; if (m.type === "CREATE_VAULT") return { ok: true }; if (m.type === "GET_RECOVERY_WORDS") { // Return 12 fake words — must pass findInvalidWords return { ok: true, data: [ "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", ], }; } return { ok: true }; } ); zxcvbnMock.mockImplementation(() => ({ score: 3 })); const onUnlock = vi.fn(async () => undefined); const container = mount(); const password = container.querySelector("#master-password") as HTMLInputElement; const confirm = container.querySelector("#confirm-password") as HTMLInputElement; const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement; inputText(password, "correct horse battery staple"); inputText(confirm, "correct horse battery staple"); await flushView(); expect(createButton.disabled).toBe(false); pressEnter(confirm); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "CREATE_VAULT", payload: { password: "correct horse battery staple" }, }); // After CREATE_VAULT success, the recovery word display is shown. // onUnlock is NOT called yet — user must first confirm their recovery words. expect(onUnlock).not.toHaveBeenCalled(); }); it("Recovery words flow submits the new master password and completes", async () => { sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type?: string }).type; if (type === "VALIDATE_RECOVERY_WORDS") { return { ok: true, data: { valid: true, invalid: [] } }; } if (type === "RECOVER_WITH_WORDS") return { ok: true }; return { ok: true }; }); const canvasContext = { clearRect: vi.fn(), fillText: vi.fn(), beginPath: vi.fn(), closePath: vi.fn(), arc: vi.fn(), fill: vi.fn(), stroke: vi.fn(), save: vi.fn(), restore: vi.fn(), translate: vi.fn(), rotate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), measureText: vi.fn(() => ({ width: 0 })), setTransform: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), } as unknown as CanvasRenderingContext2D; vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext); const onRecoveryComplete = vi.fn(async () => undefined); const container = mount( ); const recoveryWords = WORD_LIST.slice(0, 12); for (let i = 0; i < recoveryWords.length; i++) { const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement; inputText(input, recoveryWords[i]!); } await flushView(); findButton(container, "verify words").click(); await flushView(); expect(container.textContent).toContain("new master password"); const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement; const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement; inputText(newPassword, "fresh-recovery-password"); inputText(confirmPassword, "fresh-recovery-password"); await flushView(); findButton(container, "recover vault").click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "RECOVER_WITH_WORDS", payload: { words: recoveryWords, newPassword: "fresh-recovery-password", }, }); expect(onRecoveryComplete).toHaveBeenCalled(); }); it("Export menu surfaces both backup formats", async () => { const container = mount(); await flushView(); expect(container.textContent).toContain("PeachScript"); expect(container.textContent).toContain("CSV"); }); it("Lock can open directly on backup restore from onboarding", async () => { const container = mount(() => ( )); await flushView(); expect(container.textContent).toContain("PeachScript"); expect(container.textContent).toContain("Codex"); expect(container.textContent).toContain("CSV"); expect(container.querySelector("#master-password")).toBeNull(); }); it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => { const device = { deviceId: "phone-1", deviceName: "June's Phone", deviceType: "android" as const, longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1, trustSource: "direct" as const, }; sendMessageMock.mockImplementation(async (msg: unknown) => { const m = msg as { type: string }; if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } }; return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); const openSync = Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""), ); openSync?.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); const remove = Array.from(container.querySelectorAll("button")).find( (b) => (b.textContent ?? "").trim() === "remove", ); expect(remove).toBeTruthy(); // Guard against a vacuous pass: the disclosure must not already be on // screen before the user asks to remove anything. expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull(); remove!.click(); for (let i = 0; i < 10; i++) await Promise.resolve(); // Revocation must never be presented as securing the vault against the // removed device — recovery words survive it and restore everything. const note = container.querySelector(".peach-unpair-confirm-note"); expect(note).toBeTruthy(); const text = note!.textContent ?? ""; expect(text).toContain("master password"); expect(text).toContain("recovery words"); expect(text).toContain("future changes"); }); it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => { const device = { deviceId: "phone-1", deviceName: "June's Phone", deviceType: "android" as const, longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1, trustSource: "direct" as const, }; let paired: typeof device[] = [device]; sendMessageMock.mockImplementation(async (msg: any) => { if (msg?.type === "LIST_PAIRED_DEVICES") { return { ok: true, data: { devices: [...paired] } }; } if (msg?.type === "UNPAIR_DEVICE") { // Mirror the real handler: UNPAIR_DEVICE drops the row from the // registry. The next LIST_PAIRED_DEVICES must not see it. paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId); return { ok: true }; } return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); Array.from(container.querySelectorAll("button")) .find((b) => /sync/i.test(b.textContent ?? "")) ?.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); // The list itself: extracting the relay selector must not take the hub with it. expect(container.textContent).toContain("PAIRED DEVICES"); expect(container.textContent).toContain("June's Phone"); expect(container.textContent).toContain("Android"); const syncNowButton = container.querySelector( 'button[aria-label="Sync this vault now"]', )!; const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label")); const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!; const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!; expect( syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); Array.from(container.querySelectorAll("button")) .find((b) => (b.textContent ?? "").trim() === "remove")! .click(); for (let i = 0; i < 10; i++) await Promise.resolve(); const confirm = Array.from( container.querySelectorAll(".peach-unpair-confirm button"), ).find((b) => (b.textContent ?? "").trim() === "remove"); confirm!.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "UNPAIR_DEVICE", payload: { deviceId: "phone-1" }, }); // The list must be refreshed and the device must be gone. The regression // we're guarding against: a Settings view that calls UNPAIR_DEVICE but // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI. expect(container.textContent).not.toContain("June's Phone"); expect(container.textContent).not.toContain("PAIRED DEVICES"); }); it("Settings keeps connection and recovery details collapsed until requested", async () => { sendMessageMock.mockImplementation(async (msg: any) => { if (msg?.type === "LIST_PAIRED_DEVICES") { return { ok: true, data: { devices: [] } }; } return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); Array.from(container.querySelectorAll("button")) .find((button) => /sync/i.test(button.textContent ?? "")) ?.click(); for (let i = 0; i < 20; i++) await Promise.resolve(); expect(container.textContent).toContain("SYNC STATUS"); expect(container.textContent).toContain("ADD A DEVICE"); expect(container.textContent).not.toContain("Public relay"); expect(container.textContent).not.toContain("Rotate sync identity"); const connection = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("CONNECTION")); connection?.click(); await Promise.resolve(); expect(container.textContent).toContain("Public relay"); const recovery = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("RECOVERY & SECURITY")); recovery?.click(); await Promise.resolve(); expect(container.textContent).toContain("Rotate sync identity"); }); it("Settings requires current, new, and confirm passwords before enabling save", async () => { sendMessageMock.mockResolvedValue({ ok: true, data: null }); const container = mount( ); await flushView(); expect(container.querySelectorAll(".stk-card").length).toBe(7); // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67] // "open settings to card I (Peach Pro) instead of card II (Protection)". expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro"); const changeButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "change" ) as HTMLButtonElement; changeButton.click(); await flushView(); const saveButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "save new password" ) as HTMLButtonElement; expect(saveButton.disabled).toBe(true); const inputs = container.querySelectorAll('input[type="password"]'); inputText(inputs[0] as HTMLInputElement, "current-pass"); inputText(inputs[1] as HTMLInputElement, "new-pass-12345"); await flushView(); expect(saveButton.disabled).toBe(true); inputText(inputs[2] as HTMLInputElement, "new-pass-12345"); await flushView(); expect(saveButton.disabled).toBe(false); }); it("Settings feedback highlights the selected category and hides the form after submit", async () => { sendMessageMock.mockResolvedValue({ ok: true, data: null }); const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({ ok: true, json: async () => ({ success: true }), })); vi.stubGlobal("fetch", fetchMock); const container = mount( ); await flushView(); const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement; expect(feedbackCard.classList.contains("is-active")).toBe(true); const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[]; const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!; const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!; expect(bugButton.getAttribute("aria-pressed")).toBe("true"); expect(featureButton.getAttribute("aria-pressed")).toBe("false"); featureButton.click(); await flushView(); expect(bugButton.getAttribute("aria-pressed")).toBe("false"); expect(featureButton.getAttribute("aria-pressed")).toBe("true"); expect(featureButton.classList.contains("is-active")).toBe(true); inputText( container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement, ' Please make this easier to see & safer.' ); const sendButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "Send" ) as HTMLButtonElement; sendButton.click(); await vi.waitFor(() => { expect(container.textContent).toContain("Thank you"); }); const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); expect(requestBody.subject).toContain("Feature request"); expect(requestBody.message).toBe( "<script>alert("xss")</script> Please make this easier to see & safer." ); expect(requestBody.message).not.toContain("