123import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { DEFAULT_BINDINGS } from "../../shared/shortcuts";12import { EntryView } from "./Entry";13import { ImportView } from "./Import";14import { VaultView } from "./Vault";15import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1617const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();18const triggerRingMock = vi.fn();19const triggerPipPulseMock = vi.fn();20const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();21const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2223vi.mock("../App", () => ({24 sendMessage: (msg: unknown) => sendMessageMock(msg),25 triggerRing: () => triggerRingMock(),26 triggerPipPulse: () => triggerPipPulseMock(),27}));2829vi.mock("../../shared/import", () => ({30 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),31}));3233vi.mock("../../shared/favicon", () => ({34 faviconUrl: () => null,35}));3637vi.mock("../../crypto/totp", () => ({38 generateTOTP: () => ({ code: "123 456", remaining: 24 }),39 parseOtpauthURI: (value: string) =>40 value.startsWith("otpauth://")41 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }42 : null,43}));4445vi.mock("../components/GeneratorDrawer", () => ({46 GeneratorDrawer: () => null,47}));4849vi.mock("zxcvbn", () => ({50 default: (value: string) => zxcvbnMock(value),51}));5253describe("popup contract tests", () => {54 const disposers: Array<() => void> = [];5556 beforeEach(() => {57 document.body.innerHTML = "";58 sendMessageMock.mockReset();59 triggerRingMock.mockReset();60 triggerPipPulseMock.mockReset();61 parseImportSourceMock.mockReset();62 zxcvbnMock.mockReset();63 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));64 parseImportSourceMock.mockResolvedValue({65 format: "unknown",66 label: "Unknown format",67 entries: [],68 totalItems: 0,69 skippedItems: 0,70 requiresPassword: false,71 });7273 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {74 return window.setTimeout(() => cb(0), 0) as unknown as number;75 });76 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));77 vi.stubGlobal("URL", {78 createObjectURL: vi.fn(() => "blob:peach-test"),79 revokeObjectURL: vi.fn(),80 });81 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8283 (globalThis as { chrome?: typeof chrome }).chrome = {84 runtime: {85 getManifest: () => ({ version: "1.4.47" }),86 onMessage: { addListener: vi.fn() },87 },88 } as unknown as typeof chrome;89 });9091 afterEach(() => {92 while (disposers.length > 0) {93 disposers.pop()?.();94 }95 vi.unstubAllGlobals();96 });9798 function mount(view: JSX.Element) {99 const mounted = renderView(view);100 disposers.push(mounted.dispose);101 return mounted.container;102 }103104 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {105 return {106 id: "entry-1",107 type: "login",108 title: "GitHub",109 username: "june",110 password: "secret123",111 url: "https://github.com",112 totp: "JBSWY3DPEHPK3PXP",113 canary: {114 webhookUrl: "https://example.com/hook",115 },116 createdAt: 1,117 modifiedAt: 1,118 ...overrides,119 };120 }121122 function sampleImportResult(123 overrides: Partial<ImportParseResult> = {}124 ): ImportParseResult {125 return {126 format: "csv",127 label: "CSV import",128 entries: [129 {130 type: "login",131 title: "Imported site",132 username: "imported-user",133 password: "imported-pass",134 url: "https://imported.test",135 },136 ],137 totalItems: 1,138 skippedItems: 0,139 requiresPassword: false,140 ...overrides,141 };142 }143144 async function selectImportFile(145 container: HTMLElement,146 contents = '{"export":"test"}'147 ) {148 149 150 151 const fileCard = findButton(container, "Choose file →");152 if (fileCard) {153 fileCard.click();154 await flushView();155 }156 const input = container.querySelector('input[type="file"]') as HTMLInputElement;157 const file = new File([contents], "import.json", { type: "application/json" });158 Object.defineProperty(input, "files", {159 configurable: true,160 value: [file],161 });162 input.dispatchEvent(new Event("change", { bubbles: true }));163 await flushView();164 await flushView();165 }166167 function findButton(container: HTMLElement, text: string): HTMLButtonElement {168 return Array.from(container.querySelectorAll("button")).find(169 (candidate) => candidate.textContent?.trim() === text170 ) as HTMLButtonElement;171 }172173 it("Lock sends UNLOCK with the entered passphrase", async () => {174 sendMessageMock.mockResolvedValue({ ok: true });175 const onUnlock = vi.fn(async () => undefined);176 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);177178 const password = container.querySelector("#unlock-password") as HTMLInputElement;179 inputText(password, "correct horse battery staple");180 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();181 await flushView();182183 expect(sendMessageMock).toHaveBeenCalledWith({184 type: "UNLOCK",185 payload: { password: "correct horse battery staple" },186 });187 expect(onUnlock).toHaveBeenCalled();188 });189190 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {191 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);192 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;193 const password = container.querySelector("#master-password") as HTMLInputElement;194 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;195196 inputText(password, "short");197 inputText(confirm, "short");198 await flushView();199 expect(createButton.disabled).toBe(true);200201 zxcvbnMock.mockImplementation(() => ({ score: 1 }));202 inputText(password, "weakweakweak");203 inputText(confirm, "weakweakweak");204 await flushView();205 expect(createButton.disabled).toBe(true);206 });207208 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {209 210 sendMessageMock.mockImplementation(211 async (msg: unknown) => {212 const m = msg as { type: string; payload?: { password?: string } };213 if (m.type === "CREATE_VAULT") return { ok: true };214 if (m.type === "GET_RECOVERY_WORDS") {215 216 return {217 ok: true,218 data: [219 "abandon", "ability", "able", "about", "above", "absent",220 "absorb", "abstract", "absurd", "abuse", "access", "accident",221 ],222 };223 }224 return { ok: true };225 }226 );227 zxcvbnMock.mockImplementation(() => ({ score: 3 }));228 const onUnlock = vi.fn(async () => undefined);229 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);230231 const password = container.querySelector("#master-password") as HTMLInputElement;232 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;233 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;234235 inputText(password, "correct horse battery staple");236 inputText(confirm, "correct horse battery staple");237 await flushView();238 expect(createButton.disabled).toBe(false);239240 pressEnter(confirm);241 await flushView();242243 expect(sendMessageMock).toHaveBeenCalledWith({244 type: "CREATE_VAULT",245 payload: { password: "correct horse battery staple" },246 });247 248 249 expect(onUnlock).not.toHaveBeenCalled();250 });251252 it("Export menu surfaces both backup formats", async () => {253 const container = mount(<ExportView onBack={vi.fn()} />);254 await flushView();255256 expect(container.textContent).toContain("PeachScript");257 expect(container.textContent).toContain("CSV");258 });259260 it("Lock onboarding restore options include Peach Codex", async () => {261 const onOpenCodexRestore = vi.fn();262 const container = mount(263 <LockView264 hasVault={false}265 ready={true}266 onUnlock={vi.fn()}267 onOpenCodexRestore={onOpenCodexRestore}268 />269 );270271 const restoreButton = Array.from(container.querySelectorAll("button")).find(272 (candidate) => candidate.textContent?.includes("restore from backup")273 ) as HTMLButtonElement;274 restoreButton.click();275 await flushView();276277 expect(container.textContent).toContain("PeachScript");278 expect(container.textContent).toContain("Codex");279 expect(container.textContent).toContain("CSV");280281 const codexTab = Array.from(container.querySelectorAll("button")).find(282 (candidate) => candidate.textContent?.trim() === "Codex"283 ) as HTMLButtonElement;284 codexTab.click();285 await flushView();286287 expect(container.textContent).toContain("Restore from Peach Codex");288 expect(container.textContent).toContain("Scan printed Codex pages");289290 const openCodexButton = Array.from(container.querySelectorAll("button")).find(291 (candidate) => candidate.textContent?.trim() === "open Codex restore →"292 ) as HTMLButtonElement;293 openCodexButton.click();294295 expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);296 });297298 it("Settings requires current, new, and confirm passwords before enabling save", async () => {299 sendMessageMock.mockResolvedValue({ ok: true, data: null });300 const container = mount(301 <SettingsView302 entriesCount={1}303 breachMonitoring={true}304 lockOnClose={true}305 autoLockMinutes={5}306 trashRetentionDays={30}307 theme="dark"308 accentColor="peach"309 textSize="medium"310 reduceMotion={false}311 onBreachMonitoringChange={vi.fn()}312 onLockOnCloseChange={vi.fn()}313 onAutoLockMinutesChange={vi.fn()}314 onTrashRetentionChange={vi.fn()}315 onThemeChange={vi.fn()}316 onAccentChange={vi.fn()}317 onTextSizeChange={vi.fn()}318 onReduceMotionChange={vi.fn()}319 shortcuts={DEFAULT_BINDINGS}320 onShortcutsChange={vi.fn()}321 onBack={vi.fn()}322 onImport={vi.fn()}323 onExport={vi.fn()}324 onBreachCheckDone={vi.fn()}325 />326 );327 await flushView();328329 expect(container.querySelectorAll(".stk-card").length).toBe(7);330 331 332 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");333334 const changeButton = Array.from(container.querySelectorAll("button")).find(335 (candidate) => candidate.textContent?.trim() === "change"336 ) as HTMLButtonElement;337 changeButton.click();338 await flushView();339340 const saveButton = Array.from(container.querySelectorAll("button")).find(341 (candidate) => candidate.textContent?.trim() === "save new password"342 ) as HTMLButtonElement;343 expect(saveButton.disabled).toBe(true);344345 const inputs = container.querySelectorAll('input[type="password"]');346 inputText(inputs[0] as HTMLInputElement, "current-pass");347 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");348 await flushView();349 expect(saveButton.disabled).toBe(true);350351 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");352 await flushView();353 expect(saveButton.disabled).toBe(false);354 });355356 it("Settings feedback highlights the selected category and hides the form after submit", async () => {357 sendMessageMock.mockResolvedValue({ ok: true, data: null });358 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({359 ok: true,360 json: async () => ({ success: true }),361 }));362 vi.stubGlobal("fetch", fetchMock);363364 const container = mount(365 <SettingsView366 entriesCount={1}367 breachMonitoring={true}368 lockOnClose={true}369 autoLockMinutes={5}370 trashRetentionDays={30}371 theme="dark"372 accentColor="peach"373 textSize="medium"374 reduceMotion={false}375 initialSection="feedback"376 onBreachMonitoringChange={vi.fn()}377 onLockOnCloseChange={vi.fn()}378 onAutoLockMinutesChange={vi.fn()}379 onTrashRetentionChange={vi.fn()}380 onThemeChange={vi.fn()}381 onAccentChange={vi.fn()}382 onTextSizeChange={vi.fn()}383 onReduceMotionChange={vi.fn()}384 shortcuts={DEFAULT_BINDINGS}385 onShortcutsChange={vi.fn()}386 onBack={vi.fn()}387 onImport={vi.fn()}388 onExport={vi.fn()}389 onBreachCheckDone={vi.fn()}390 />391 );392 await flushView();393394 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;395 expect(feedbackCard.classList.contains("is-active")).toBe(true);396397 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];398 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;399 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;400 expect(bugButton.getAttribute("aria-pressed")).toBe("true");401 expect(featureButton.getAttribute("aria-pressed")).toBe("false");402403 featureButton.click();404 await flushView();405406 expect(bugButton.getAttribute("aria-pressed")).toBe("false");407 expect(featureButton.getAttribute("aria-pressed")).toBe("true");408 expect(featureButton.classList.contains("is-active")).toBe(true);409410 inputText(411 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,412 '<script>alert("xss")</script> Please make this easier to see & safer.'413 );414 const sendButton = Array.from(container.querySelectorAll("button")).find(415 (candidate) => candidate.textContent?.trim() === "Send"416 ) as HTMLButtonElement;417 sendButton.click();418419 await vi.waitFor(() => {420 expect(container.textContent).toContain("Thank you");421 });422423 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));424 expect(requestBody.subject).toContain("Feature request");425 expect(requestBody.message).toBe(426 "<script>alert("xss")</script> Please make this easier to see & safer."427 );428 expect(requestBody.message).not.toContain("<script>");429 expect(container.textContent).toContain("Someone will process it soon");430 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();431 expect(container.querySelector(".peach-feedback-submit")).toBeNull();432 });433434 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {435 sendMessageMock.mockResolvedValue({ ok: true, data: null });436 const fetchMock = vi.fn();437 const requestMock = vi.fn(async () => false);438 vi.stubGlobal("fetch", fetchMock);439 vi.stubGlobal("browser", {440 permissions: {441 getAll: vi.fn(async () => ({ data_collection: [] })),442 request: requestMock,443 },444 });445446 const container = mount(447 <SettingsView448 entriesCount={1}449 breachMonitoring={true}450 lockOnClose={true}451 autoLockMinutes={5}452 trashRetentionDays={30}453 theme="dark"454 accentColor="peach"455 textSize="medium"456 reduceMotion={false}457 initialSection="feedback"458 onBreachMonitoringChange={vi.fn()}459 onLockOnCloseChange={vi.fn()}460 onAutoLockMinutesChange={vi.fn()}461 onTrashRetentionChange={vi.fn()}462 onThemeChange={vi.fn()}463 onAccentChange={vi.fn()}464 onTextSizeChange={vi.fn()}465 onReduceMotionChange={vi.fn()}466 shortcuts={DEFAULT_BINDINGS}467 onShortcutsChange={vi.fn()}468 onBack={vi.fn()}469 onImport={vi.fn()}470 onExport={vi.fn()}471 onBreachCheckDone={vi.fn()}472 />473 );474 await flushView();475476 inputText(477 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,478 "Please improve the feedback flow."479 );480 const sendButton = Array.from(container.querySelectorAll("button")).find(481 (candidate) => candidate.textContent?.trim() === "Send"482 ) as HTMLButtonElement;483 sendButton.click();484485 await vi.waitFor(() => {486 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });487 });488 expect(fetchMock).not.toHaveBeenCalled();489 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();490 });491492 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {493 sendMessageMock.mockImplementation(async (msg: unknown) => {494 const m = msg as { type: string };495 if (m.type === "GET_RECOVERY_WORDS") {496 return {497 ok: true,498 data: [499 "abandon", "ability", "able", "about", "above", "absent",500 "absorb", "abstract", "absurd", "abuse", "access", "accident",501 ],502 };503 }504 return { ok: true, data: null };505 });506 const onAutoLockMinutesChange = vi.fn();507 const onThemeChange = vi.fn();508 const container = mount(509 <SettingsView510 entriesCount={1}511 breachMonitoring={true}512 lockOnClose={true}513 autoLockMinutes={5}514 trashRetentionDays={30}515 theme="dark"516 accentColor="peach"517 textSize="medium"518 reduceMotion={false}519 onBreachMonitoringChange={vi.fn()}520 onLockOnCloseChange={vi.fn()}521 onAutoLockMinutesChange={onAutoLockMinutesChange}522 onTrashRetentionChange={vi.fn()}523 onThemeChange={onThemeChange}524 onAccentChange={vi.fn()}525 onTextSizeChange={vi.fn()}526 onReduceMotionChange={vi.fn()}527 shortcuts={DEFAULT_BINDINGS}528 onShortcutsChange={vi.fn()}529 onBack={vi.fn()}530 onImport={vi.fn()}531 onExport={vi.fn()}532 onBreachCheckDone={vi.fn()}533 />534 );535 await flushView();536 sendMessageMock.mockClear();537538 539 const selects = container.querySelectorAll("select");540 changeSelect(selects[0] as HTMLSelectElement, "15");541 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);542543 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;544 appearanceCard.click();545 await flushView();546 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");547 const themeButton = Array.from(container.querySelectorAll("button")).find(548 (candidate) => candidate.textContent?.trim() === "Switch to light"549 ) as HTMLButtonElement;550 themeButton.click();551 expect(onThemeChange).toHaveBeenCalledWith("light");552553 554 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;555 protectionCard.click();556 await flushView();557558 const changeButton = Array.from(container.querySelectorAll("button")).find(559 (candidate) => candidate.textContent?.trim() === "change"560 ) as HTMLButtonElement;561 changeButton.click();562 await flushView();563564 const inputs = container.querySelectorAll('input[type="password"]');565 inputText(inputs[0] as HTMLInputElement, "current-pass");566 inputText(inputs[1] as HTMLInputElement, "new-password-123");567 inputText(inputs[2] as HTMLInputElement, "new-password-123");568 await flushView();569570 const saveButton = Array.from(container.querySelectorAll("button")).find(571 (candidate) => candidate.textContent?.trim() === "save new password"572 ) as HTMLButtonElement;573 saveButton.click();574 await flushView();575576 expect(sendMessageMock).toHaveBeenCalledWith({577 type: "CHANGE_PASSWORD",578 payload: {579 currentPassword: "current-pass",580 newPassword: "new-password-123",581 },582 });583584 585 expect(container.textContent).toContain("save your recovery words");586 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });587 });588589 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {590 sendMessageMock.mockResolvedValue({ ok: true });591 const container = mount(592 <EntryView593 entry={sampleEntry()}594 onDelete={vi.fn()}595 onFieldSaved={vi.fn(async () => undefined)}596 />597 );598599 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;600 passwordButton.click();601 await flushView();602603 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {604 type: "COPY",605 payload: { text: "secret123", label: "password", alreadyWritten: false },606 });607 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {608 type: "FIRE_CANARY_PING",609 payload: {610 webhookUrl: "https://example.com/hook",611 entryTitle: "GitHub",612 trigger: "password_copied",613 },614 });615616 sendMessageMock.mockClear();617 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;618 usernameButton.click();619 await flushView();620621 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {622 type: "COPY",623 payload: { text: "june", label: "username", alreadyWritten: false },624 });625 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {626 type: "FIRE_CANARY_PING",627 payload: {628 webhookUrl: "https://example.com/hook",629 entryTitle: "GitHub",630 trigger: "username_copied",631 },632 });633 });634635 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {636 sendMessageMock.mockResolvedValue({ ok: true });637 const onFieldSaved = vi.fn(async () => undefined);638 const container = mount(639 <EntryView640 entry={sampleEntry()}641 onDelete={vi.fn()}642 onFieldSaved={onFieldSaved}643 />644 );645646 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;647 passwordValue.click();648 await flushView();649650 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;651 inputText(passwordInput, "");652 await flushView();653654 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;655 saveButton.click();656 await flushView();657658 expect(sendMessageMock).not.toHaveBeenCalledWith(659 expect.objectContaining({ type: "UPDATE_ENTRY" })660 );661 expect(onFieldSaved).not.toHaveBeenCalled();662663 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;664 totpMain.click();665666 await vi.waitFor(() => {667 expect(sendMessageMock).toHaveBeenLastCalledWith({668 type: "COPY",669 payload: { text: "123456", label: "totp", alreadyWritten: false },670 });671 }, { timeout: 500 });672 });673674 it("Entry closes an unchanged inline editor when focus leaves it", async () => {675 sendMessageMock.mockResolvedValue({ ok: true });676 const container = mount(677 <EntryView678 entry={sampleEntry()}679 onDelete={vi.fn()}680 onFieldSaved={vi.fn(async () => undefined)}681 />682 );683684 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;685 usernameValue.click();686 await flushView();687688 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;689 usernameInput.blur();690 await flushView();691692 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();693 expect(sendMessageMock).not.toHaveBeenCalledWith(694 expect.objectContaining({ type: "UPDATE_ENTRY" })695 );696 });697698 it("Entry lets TOTP secrets be edited inline", async () => {699 sendMessageMock.mockResolvedValue({ ok: true });700 const onFieldSaved = vi.fn(async () => undefined);701 const container = mount(702 <EntryView703 entry={sampleEntry()}704 onDelete={vi.fn()}705 onFieldSaved={onFieldSaved}706 />707 );708709 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;710 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));711 await flushView();712713 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;714 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");715 await flushView();716717 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;718 saveButton.click();719 await flushView();720721 expect(sendMessageMock).toHaveBeenCalledWith({722 type: "UPDATE_ENTRY",723 payload: {724 id: "entry-1",725 patch: { totp: "NB2W45DFOIZA====" },726 },727 });728 expect(onFieldSaved).toHaveBeenCalled();729 });730731 it("Vault seeds and focuses search when a search seed is provided", async () => {732 const container = mount(733 <VaultView734 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}735 sortOrder="url-first"736 searchSeed="g"737 searchSeedNonce={1}738 onSelectEntry={vi.fn()}739 onSortOrderChange={vi.fn()}740 onAdd={vi.fn()}741 onClose={vi.fn()}742 />743 );744745 await flushView();746747 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;748 expect(search.value).toBe("g");749 expect(document.activeElement).toBe(search);750 });751752 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {753 parseImportSourceMock.mockResolvedValue(754 sampleImportResult({755 entries: [756 {757 type: "login",758 title: "Imported site",759 username: "imported-user",760 password: "imported-pass",761 url: "https://imported.test",762 },763 ],764 })765 );766 sendMessageMock.mockImplementation(async (msg: unknown) => {767 const type = (msg as { type?: string }).type;768 if (type === "GET_ENTRIES") {769 return {770 ok: true,771 data: [772 sampleEntry({773 id: "existing-1",774 title: "Imported site",775 username: "imported-user",776 url: "https://imported.test",777 }),778 ],779 };780 }781 if (type === "BATCH_ADD_ENTRIES") {782 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };783 }784 return { ok: true };785 });786 const onImport = vi.fn(async () => undefined);787 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);788 await flushView();789 sendMessageMock.mockClear();790791 await selectImportFile(container);792 findButton(container, "Import 1").click();793 await flushView();794795 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);796 expect(sendMessageMock).toHaveBeenCalledWith({797 type: "BATCH_ADD_ENTRIES",798 payload: {799 entries: [800 expect.objectContaining({801 title: "Imported site",802 username: "imported-user",803 password: "imported-pass",804 url: "https://imported.test",805 }),806 ],807 },808 });809 expect(onImport).toHaveBeenCalled();810 expect(container.textContent).toContain("1 entry appears to be duplicates");811 });812813 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {814 parseImportSourceMock.mockResolvedValue(sampleImportResult());815 sendMessageMock.mockImplementation(async (msg: unknown) => {816 const type = (msg as { type?: string }).type;817 if (type === "GET_ENTRIES") {818 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };819 }820 if (type === "CLEAR_VAULT_ENTRIES") {821 return { ok: true };822 }823 if (type === "BATCH_ADD_ENTRIES") {824 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };825 }826 return { ok: true };827 });828 const onImport = vi.fn(async () => undefined);829 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);830 await flushView();831 sendMessageMock.mockClear();832833 await selectImportFile(container);834 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();835 await flushView();836837 findButton(container, "Replace vault with 1").click();838 await flushView();839840 expect(sendMessageMock).not.toHaveBeenCalledWith(841 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })842 );843 expect(container.textContent).toContain("Replace forever? This cannot be undone.");844845 findButton(container, "Yes, replace forever").click();846 await flushView();847848 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });849 expect(sendMessageMock).toHaveBeenCalledWith({850 type: "BATCH_ADD_ENTRIES",851 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },852 });853 expect(onImport).toHaveBeenCalled();854 });855856 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {857 parseImportSourceMock.mockResolvedValue(sampleImportResult());858 sendMessageMock.mockImplementation(async (msg: unknown) => {859 const type = (msg as { type?: string }).type;860 if (type === "CREATE_VAULT") return { ok: true };861 if (type === "BATCH_ADD_ENTRIES") {862 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };863 }864 return { ok: true };865 });866 const onImport = vi.fn(async () => undefined);867 const container = mount(868 <ImportView869 mode="onboarding"870 replacesExistingVault={true}871 onBack={vi.fn()}872 onImport={onImport}873 />874 );875876 await selectImportFile(container);877878 const createButton = findButton(container, "Create vault + import 1");879 expect(createButton.disabled).toBe(true);880 expect(container.textContent).toContain("Restoring now replaces the locked local vault");881882 const masterInput = container.querySelector(883 'input[placeholder="new master password"]'884 ) as HTMLInputElement;885 const confirmInput = container.querySelector(886 'input[placeholder="confirm master password"]'887 ) as HTMLInputElement;888 inputText(masterInput, "fresh-master-password");889 inputText(confirmInput, "fresh-master-password");890 await flushView();891892 expect(createButton.disabled).toBe(false);893 createButton.click();894 await flushView();895896 expect(sendMessageMock).toHaveBeenCalledWith({897 type: "CREATE_VAULT",898 payload: { password: "fresh-master-password" },899 });900 expect(sendMessageMock).toHaveBeenCalledWith({901 type: "BATCH_ADD_ENTRIES",902 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },903 });904 expect(onImport).toHaveBeenCalled();905 });906907 it("Import encrypted backups reparse with the backup password before import", async () => {908 parseImportSourceMock909 .mockResolvedValueOnce(910 sampleImportResult({911 format: "peach-json",912 label: "Peach encrypted backup",913 entries: [],914 totalItems: 0,915 requiresPassword: true,916 })917 )918 .mockResolvedValueOnce(919 sampleImportResult({920 format: "peach-json",921 label: "Peach encrypted backup",922 })923 );924 sendMessageMock.mockImplementation(async (msg: unknown) => {925 const type = (msg as { type?: string }).type;926 if (type === "GET_ENTRIES") return { ok: true, data: [] };927 if (type === "BATCH_ADD_ENTRIES") {928 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };929 }930 return { ok: true };931 });932 const onImport = vi.fn(async () => undefined);933 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);934 await flushView();935 sendMessageMock.mockClear();936937 await selectImportFile(container, '{"type":"peach-export"}');938 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;939 inputText(password, "backup-passphrase");940 pressEnter(password);941 await flushView();942 await flushView();943944 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);945 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");946947 findButton(container, "Import 1").click();948 await flushView();949950 expect(sendMessageMock).toHaveBeenCalledWith({951 type: "BATCH_ADD_ENTRIES",952 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },953 });954 expect(onImport).toHaveBeenCalled();955 });956});