// @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 { 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 { 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", () => ({ 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", () => ({ default: (value: string) => zxcvbnMock(value), })); 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.unstubAllGlobals(); }); function mount(view: 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, }; } 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("Export menu surfaces both backup formats", async () => { const container = mount(); await flushView(); expect(container.textContent).toContain("PeachScript"); expect(container.textContent).toContain("CSV"); }); it("Settings requires current, new, and confirm passwords before enabling save", async () => { sendMessageMock.mockResolvedValue({ ok: true, data: null }); const container = mount( ); await flushView(); const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find( (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security" ) as HTMLButtonElement; securityRow.click(); await flushView(); 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 sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => { sendMessageMock.mockImplementation(async (msg: unknown) => { const m = msg as { type: string }; if (m.type === "GET_RECOVERY_WORDS") { return { ok: true, data: [ "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", ], }; } return { ok: true, data: null }; }); const onAutoLockMinutesChange = vi.fn(); const onThemeChange = vi.fn(); const container = mount( ); await flushView(); sendMessageMock.mockClear(); function headerBack() { return container.querySelector(".peach-overlay-header button") as HTMLButtonElement; } // ── Auto-lock + theme changes (before password change) ───────────── const securityRow = () => Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find( (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security" ) as HTMLButtonElement; securityRow().click(); await flushView(); const selects = container.querySelectorAll("select"); changeSelect(selects[0] as HTMLSelectElement, "15"); expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15); headerBack().click(); await flushView(); const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find( (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance" ) as HTMLButtonElement; appearanceRow.click(); await flushView(); const themeButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "Switch to light" ) as HTMLButtonElement; themeButton.click(); expect(onThemeChange).toHaveBeenCalledWith("light"); // ── Password change → recovery words display ──────────────────────── headerBack().click(); await flushView(); securityRow().click(); await flushView(); const changeButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "change" ) as HTMLButtonElement; changeButton.click(); await flushView(); const inputs = container.querySelectorAll('input[type="password"]'); inputText(inputs[0] as HTMLInputElement, "current-pass"); inputText(inputs[1] as HTMLInputElement, "new-password-123"); inputText(inputs[2] as HTMLInputElement, "new-password-123"); await flushView(); const saveButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "save new password" ) as HTMLButtonElement; saveButton.click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "CHANGE_PASSWORD", payload: { currentPassword: "current-pass", newPassword: "new-password-123", }, }); // After a successful password change the recovery word display replaces settings. expect(container.textContent).toContain("save your recovery words"); expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" }); }); it("Entry copy actions send COPY and canary ping messages for password and username", async () => { sendMessageMock.mockResolvedValue({ ok: true }); const container = mount( undefined)} /> ); const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement; passwordButton.click(); await flushView(); expect(sendMessageMock).toHaveBeenNthCalledWith(1, { type: "COPY", payload: { text: "secret123", label: "password", alreadyWritten: false }, }); expect(sendMessageMock).toHaveBeenNthCalledWith(2, { type: "FIRE_CANARY_PING", payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "password_copied", }, }); sendMessageMock.mockClear(); const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement; usernameButton.click(); await flushView(); expect(sendMessageMock).toHaveBeenNthCalledWith(1, { type: "COPY", payload: { text: "june", label: "username", alreadyWritten: false }, }); expect(sendMessageMock).toHaveBeenNthCalledWith(2, { type: "FIRE_CANARY_PING", payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "username_copied", }, }); }); it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => { sendMessageMock.mockResolvedValue({ ok: true }); const onFieldSaved = vi.fn(async () => undefined); const container = mount( ); const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement; passwordValue.click(); await flushView(); const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement; inputText(passwordInput, ""); await flushView(); const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement; saveButton.click(); await flushView(); expect(sendMessageMock).not.toHaveBeenCalledWith( expect.objectContaining({ type: "UPDATE_ENTRY" }) ); expect(onFieldSaved).not.toHaveBeenCalled(); const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement; totpMain.click(); await vi.waitFor(() => { expect(sendMessageMock).toHaveBeenLastCalledWith({ type: "COPY", payload: { text: "123456", label: "totp", alreadyWritten: false }, }); }, { timeout: 500 }); }); it("Entry closes an unchanged inline editor when focus leaves it", async () => { sendMessageMock.mockResolvedValue({ ok: true }); const container = mount( undefined)} /> ); const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement; usernameValue.click(); await flushView(); const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement; usernameInput.blur(); await flushView(); expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull(); expect(sendMessageMock).not.toHaveBeenCalledWith( expect.objectContaining({ type: "UPDATE_ENTRY" }) ); }); it("Entry lets TOTP secrets be edited inline", async () => { sendMessageMock.mockResolvedValue({ ok: true }); const onFieldSaved = vi.fn(async () => undefined); const container = mount( ); const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement; totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); await flushView(); const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement; inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach"); await flushView(); const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement; saveButton.click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "UPDATE_ENTRY", payload: { id: "entry-1", patch: { totp: "NB2W45DFOIZA====" }, }, }); expect(onFieldSaved).toHaveBeenCalled(); }); it("Vault seeds and focuses search when a search seed is provided", async () => { const container = mount( ); await flushView(); const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement; expect(search.value).toBe("g"); expect(document.activeElement).toBe(search); }); it("Import existing-vault add mode parses a file and batches entries into the vault", async () => { parseImportSourceMock.mockResolvedValue( sampleImportResult({ entries: [ { type: "login", title: "Imported site", username: "imported-user", password: "imported-pass", url: "https://imported.test", }, ], }) ); sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type?: string }).type; if (type === "GET_ENTRIES") { return { ok: true, data: [ sampleEntry({ id: "existing-1", title: "Imported site", username: "imported-user", url: "https://imported.test", }), ], }; } if (type === "BATCH_ADD_ENTRIES") { return { ok: true, data: [sampleEntry({ id: "imported-1" })] }; } return { ok: true }; }); const onImport = vi.fn(async () => undefined); const container = mount(); await flushView(); sendMessageMock.mockClear(); await selectImportFile(container); findButton(container, "Import 1").click(); await flushView(); expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined); expect(sendMessageMock).toHaveBeenCalledWith({ type: "BATCH_ADD_ENTRIES", payload: { entries: [ expect.objectContaining({ title: "Imported site", username: "imported-user", password: "imported-pass", url: "https://imported.test", }), ], }, }); expect(onImport).toHaveBeenCalled(); expect(container.textContent).toContain("1 entry appears to be duplicates"); }); it("Import replace mode requires confirmation before clearing and replacing the vault", async () => { parseImportSourceMock.mockResolvedValue(sampleImportResult()); sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type?: string }).type; if (type === "GET_ENTRIES") { return { ok: true, data: [sampleEntry({ id: "existing-1" })] }; } if (type === "CLEAR_VAULT_ENTRIES") { return { ok: true }; } if (type === "BATCH_ADD_ENTRIES") { return { ok: true, data: [sampleEntry({ id: "imported-1" })] }; } return { ok: true }; }); const onImport = vi.fn(async () => undefined); const container = mount(); await flushView(); sendMessageMock.mockClear(); await selectImportFile(container); (container.querySelector('input[value="replace"]') as HTMLInputElement).click(); await flushView(); findButton(container, "Replace vault with 1").click(); await flushView(); expect(sendMessageMock).not.toHaveBeenCalledWith( expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" }) ); expect(container.textContent).toContain("Replace forever? This cannot be undone."); findButton(container, "Yes, replace forever").click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" }); expect(sendMessageMock).toHaveBeenCalledWith({ type: "BATCH_ADD_ENTRIES", payload: { entries: [expect.objectContaining({ title: "Imported site" })] }, }); expect(onImport).toHaveBeenCalled(); }); it("Import onboarding mode waits for a matching master password before creating a vault", async () => { parseImportSourceMock.mockResolvedValue(sampleImportResult()); sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type?: string }).type; if (type === "CREATE_VAULT") return { ok: true }; if (type === "BATCH_ADD_ENTRIES") { return { ok: true, data: [sampleEntry({ id: "imported-1" })] }; } return { ok: true }; }); const onImport = vi.fn(async () => undefined); const container = mount( ); await selectImportFile(container); const createButton = findButton(container, "Create vault + import 1"); expect(createButton.disabled).toBe(true); expect(container.textContent).toContain("Restoring now replaces the locked local vault"); const masterInput = container.querySelector( 'input[placeholder="new master password"]' ) as HTMLInputElement; const confirmInput = container.querySelector( 'input[placeholder="confirm master password"]' ) as HTMLInputElement; inputText(masterInput, "fresh-master-password"); inputText(confirmInput, "fresh-master-password"); await flushView(); expect(createButton.disabled).toBe(false); createButton.click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "CREATE_VAULT", payload: { password: "fresh-master-password" }, }); expect(sendMessageMock).toHaveBeenCalledWith({ type: "BATCH_ADD_ENTRIES", payload: { entries: [expect.objectContaining({ title: "Imported site" })] }, }); expect(onImport).toHaveBeenCalled(); }); it("Import encrypted backups reparse with the backup password before import", async () => { parseImportSourceMock .mockResolvedValueOnce( sampleImportResult({ format: "peach-json", label: "Peach encrypted backup", entries: [], totalItems: 0, requiresPassword: true, }) ) .mockResolvedValueOnce( sampleImportResult({ format: "peach-json", label: "Peach encrypted backup", }) ); sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type?: string }).type; if (type === "GET_ENTRIES") return { ok: true, data: [] }; if (type === "BATCH_ADD_ENTRIES") { return { ok: true, data: [sampleEntry({ id: "imported-1" })] }; } return { ok: true }; }); const onImport = vi.fn(async () => undefined); const container = mount(); await flushView(); sendMessageMock.mockClear(); await selectImportFile(container, '{"type":"peach-export"}'); const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement; inputText(password, "backup-passphrase"); pressEnter(password); await flushView(); await flushView(); expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined); expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase"); findButton(container, "Import 1").click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "BATCH_ADD_ENTRIES", payload: { entries: [expect.objectContaining({ title: "Imported site" })] }, }); expect(onImport).toHaveBeenCalled(); }); });