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("Settings requires current, new, and confirm passwords before enabling save", async () => {261 sendMessageMock.mockResolvedValue({ ok: true, data: null });262 const container = mount(263 <SettingsView264 entriesCount={1}265 breachMonitoring={true}266 lockOnClose={true}267 autoLockMinutes={5}268 trashRetentionDays={30}269 theme="dark"270 accentColor="peach"271 textSize="medium"272 reduceMotion={false}273 onBreachMonitoringChange={vi.fn()}274 onLockOnCloseChange={vi.fn()}275 onAutoLockMinutesChange={vi.fn()}276 onTrashRetentionChange={vi.fn()}277 onThemeChange={vi.fn()}278 onAccentChange={vi.fn()}279 onTextSizeChange={vi.fn()}280 onReduceMotionChange={vi.fn()}281 shortcuts={DEFAULT_BINDINGS}282 onShortcutsChange={vi.fn()}283 onBack={vi.fn()}284 onImport={vi.fn()}285 onExport={vi.fn()}286 onBreachCheckDone={vi.fn()}287 />288 );289 await flushView();290291 expect(container.querySelectorAll(".stk-card").length).toBe(7);292 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Protection");293294 const changeButton = Array.from(container.querySelectorAll("button")).find(295 (candidate) => candidate.textContent?.trim() === "change"296 ) as HTMLButtonElement;297 changeButton.click();298 await flushView();299300 const saveButton = Array.from(container.querySelectorAll("button")).find(301 (candidate) => candidate.textContent?.trim() === "save new password"302 ) as HTMLButtonElement;303 expect(saveButton.disabled).toBe(true);304305 const inputs = container.querySelectorAll('input[type="password"]');306 inputText(inputs[0] as HTMLInputElement, "current-pass");307 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");308 await flushView();309 expect(saveButton.disabled).toBe(true);310311 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");312 await flushView();313 expect(saveButton.disabled).toBe(false);314 });315316 it("Settings feedback highlights the selected category and hides the form after submit", async () => {317 sendMessageMock.mockResolvedValue({ ok: true, data: null });318 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({319 ok: true,320 json: async () => ({ success: true }),321 }));322 vi.stubGlobal("fetch", fetchMock);323324 const container = mount(325 <SettingsView326 entriesCount={1}327 breachMonitoring={true}328 lockOnClose={true}329 autoLockMinutes={5}330 trashRetentionDays={30}331 theme="dark"332 accentColor="peach"333 textSize="medium"334 reduceMotion={false}335 initialSection="feedback"336 onBreachMonitoringChange={vi.fn()}337 onLockOnCloseChange={vi.fn()}338 onAutoLockMinutesChange={vi.fn()}339 onTrashRetentionChange={vi.fn()}340 onThemeChange={vi.fn()}341 onAccentChange={vi.fn()}342 onTextSizeChange={vi.fn()}343 onReduceMotionChange={vi.fn()}344 shortcuts={DEFAULT_BINDINGS}345 onShortcutsChange={vi.fn()}346 onBack={vi.fn()}347 onImport={vi.fn()}348 onExport={vi.fn()}349 onBreachCheckDone={vi.fn()}350 />351 );352 await flushView();353354 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;355 expect(feedbackCard.classList.contains("is-active")).toBe(true);356357 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];358 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;359 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;360 expect(bugButton.getAttribute("aria-pressed")).toBe("true");361 expect(featureButton.getAttribute("aria-pressed")).toBe("false");362363 featureButton.click();364 await flushView();365366 expect(bugButton.getAttribute("aria-pressed")).toBe("false");367 expect(featureButton.getAttribute("aria-pressed")).toBe("true");368 expect(featureButton.classList.contains("is-active")).toBe(true);369370 inputText(371 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,372 '<script>alert("xss")</script> Please make this easier to see & safer.'373 );374 const sendButton = Array.from(container.querySelectorAll("button")).find(375 (candidate) => candidate.textContent?.trim() === "Send"376 ) as HTMLButtonElement;377 sendButton.click();378379 await vi.waitFor(() => {380 expect(container.textContent).toContain("Thank you");381 });382383 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));384 expect(requestBody.subject).toContain("Feature request");385 expect(requestBody.message).toBe(386 "<script>alert("xss")</script> Please make this easier to see & safer."387 );388 expect(requestBody.message).not.toContain("<script>");389 expect(container.textContent).toContain("Someone will process it soon");390 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();391 expect(container.querySelector(".peach-feedback-submit")).toBeNull();392 });393394 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {395 sendMessageMock.mockImplementation(async (msg: unknown) => {396 const m = msg as { type: string };397 if (m.type === "GET_RECOVERY_WORDS") {398 return {399 ok: true,400 data: [401 "abandon", "ability", "able", "about", "above", "absent",402 "absorb", "abstract", "absurd", "abuse", "access", "accident",403 ],404 };405 }406 return { ok: true, data: null };407 });408 const onAutoLockMinutesChange = vi.fn();409 const onThemeChange = vi.fn();410 const container = mount(411 <SettingsView412 entriesCount={1}413 breachMonitoring={true}414 lockOnClose={true}415 autoLockMinutes={5}416 trashRetentionDays={30}417 theme="dark"418 accentColor="peach"419 textSize="medium"420 reduceMotion={false}421 onBreachMonitoringChange={vi.fn()}422 onLockOnCloseChange={vi.fn()}423 onAutoLockMinutesChange={onAutoLockMinutesChange}424 onTrashRetentionChange={vi.fn()}425 onThemeChange={onThemeChange}426 onAccentChange={vi.fn()}427 onTextSizeChange={vi.fn()}428 onReduceMotionChange={vi.fn()}429 shortcuts={DEFAULT_BINDINGS}430 onShortcutsChange={vi.fn()}431 onBack={vi.fn()}432 onImport={vi.fn()}433 onExport={vi.fn()}434 onBreachCheckDone={vi.fn()}435 />436 );437 await flushView();438 sendMessageMock.mockClear();439440 441 const selects = container.querySelectorAll("select");442 changeSelect(selects[0] as HTMLSelectElement, "15");443 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);444445 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;446 appearanceCard.click();447 await flushView();448 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");449 const themeButton = Array.from(container.querySelectorAll("button")).find(450 (candidate) => candidate.textContent?.trim() === "Switch to light"451 ) as HTMLButtonElement;452 themeButton.click();453 expect(onThemeChange).toHaveBeenCalledWith("light");454455 456 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;457 protectionCard.click();458 await flushView();459460 const changeButton = Array.from(container.querySelectorAll("button")).find(461 (candidate) => candidate.textContent?.trim() === "change"462 ) as HTMLButtonElement;463 changeButton.click();464 await flushView();465466 const inputs = container.querySelectorAll('input[type="password"]');467 inputText(inputs[0] as HTMLInputElement, "current-pass");468 inputText(inputs[1] as HTMLInputElement, "new-password-123");469 inputText(inputs[2] as HTMLInputElement, "new-password-123");470 await flushView();471472 const saveButton = Array.from(container.querySelectorAll("button")).find(473 (candidate) => candidate.textContent?.trim() === "save new password"474 ) as HTMLButtonElement;475 saveButton.click();476 await flushView();477478 expect(sendMessageMock).toHaveBeenCalledWith({479 type: "CHANGE_PASSWORD",480 payload: {481 currentPassword: "current-pass",482 newPassword: "new-password-123",483 },484 });485486 487 expect(container.textContent).toContain("save your recovery words");488 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });489 });490491 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {492 sendMessageMock.mockResolvedValue({ ok: true });493 const container = mount(494 <EntryView495 entry={sampleEntry()}496 onDelete={vi.fn()}497 onFieldSaved={vi.fn(async () => undefined)}498 />499 );500501 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;502 passwordButton.click();503 await flushView();504505 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {506 type: "COPY",507 payload: { text: "secret123", label: "password", alreadyWritten: false },508 });509 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {510 type: "FIRE_CANARY_PING",511 payload: {512 webhookUrl: "https://example.com/hook",513 entryTitle: "GitHub",514 trigger: "password_copied",515 },516 });517518 sendMessageMock.mockClear();519 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;520 usernameButton.click();521 await flushView();522523 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {524 type: "COPY",525 payload: { text: "june", label: "username", alreadyWritten: false },526 });527 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {528 type: "FIRE_CANARY_PING",529 payload: {530 webhookUrl: "https://example.com/hook",531 entryTitle: "GitHub",532 trigger: "username_copied",533 },534 });535 });536537 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {538 sendMessageMock.mockResolvedValue({ ok: true });539 const onFieldSaved = vi.fn(async () => undefined);540 const container = mount(541 <EntryView542 entry={sampleEntry()}543 onDelete={vi.fn()}544 onFieldSaved={onFieldSaved}545 />546 );547548 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;549 passwordValue.click();550 await flushView();551552 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;553 inputText(passwordInput, "");554 await flushView();555556 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;557 saveButton.click();558 await flushView();559560 expect(sendMessageMock).not.toHaveBeenCalledWith(561 expect.objectContaining({ type: "UPDATE_ENTRY" })562 );563 expect(onFieldSaved).not.toHaveBeenCalled();564565 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;566 totpMain.click();567568 await vi.waitFor(() => {569 expect(sendMessageMock).toHaveBeenLastCalledWith({570 type: "COPY",571 payload: { text: "123456", label: "totp", alreadyWritten: false },572 });573 }, { timeout: 500 });574 });575576 it("Entry closes an unchanged inline editor when focus leaves it", async () => {577 sendMessageMock.mockResolvedValue({ ok: true });578 const container = mount(579 <EntryView580 entry={sampleEntry()}581 onDelete={vi.fn()}582 onFieldSaved={vi.fn(async () => undefined)}583 />584 );585586 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;587 usernameValue.click();588 await flushView();589590 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;591 usernameInput.blur();592 await flushView();593594 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();595 expect(sendMessageMock).not.toHaveBeenCalledWith(596 expect.objectContaining({ type: "UPDATE_ENTRY" })597 );598 });599600 it("Entry lets TOTP secrets be edited inline", async () => {601 sendMessageMock.mockResolvedValue({ ok: true });602 const onFieldSaved = vi.fn(async () => undefined);603 const container = mount(604 <EntryView605 entry={sampleEntry()}606 onDelete={vi.fn()}607 onFieldSaved={onFieldSaved}608 />609 );610611 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;612 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));613 await flushView();614615 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;616 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");617 await flushView();618619 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;620 saveButton.click();621 await flushView();622623 expect(sendMessageMock).toHaveBeenCalledWith({624 type: "UPDATE_ENTRY",625 payload: {626 id: "entry-1",627 patch: { totp: "NB2W45DFOIZA====" },628 },629 });630 expect(onFieldSaved).toHaveBeenCalled();631 });632633 it("Vault seeds and focuses search when a search seed is provided", async () => {634 const container = mount(635 <VaultView636 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}637 sortOrder="url-first"638 searchSeed="g"639 searchSeedNonce={1}640 onSelectEntry={vi.fn()}641 onSortOrderChange={vi.fn()}642 onAdd={vi.fn()}643 onClose={vi.fn()}644 />645 );646647 await flushView();648649 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;650 expect(search.value).toBe("g");651 expect(document.activeElement).toBe(search);652 });653654 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {655 parseImportSourceMock.mockResolvedValue(656 sampleImportResult({657 entries: [658 {659 type: "login",660 title: "Imported site",661 username: "imported-user",662 password: "imported-pass",663 url: "https://imported.test",664 },665 ],666 })667 );668 sendMessageMock.mockImplementation(async (msg: unknown) => {669 const type = (msg as { type?: string }).type;670 if (type === "GET_ENTRIES") {671 return {672 ok: true,673 data: [674 sampleEntry({675 id: "existing-1",676 title: "Imported site",677 username: "imported-user",678 url: "https://imported.test",679 }),680 ],681 };682 }683 if (type === "BATCH_ADD_ENTRIES") {684 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };685 }686 return { ok: true };687 });688 const onImport = vi.fn(async () => undefined);689 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);690 await flushView();691 sendMessageMock.mockClear();692693 await selectImportFile(container);694 findButton(container, "Import 1").click();695 await flushView();696697 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);698 expect(sendMessageMock).toHaveBeenCalledWith({699 type: "BATCH_ADD_ENTRIES",700 payload: {701 entries: [702 expect.objectContaining({703 title: "Imported site",704 username: "imported-user",705 password: "imported-pass",706 url: "https://imported.test",707 }),708 ],709 },710 });711 expect(onImport).toHaveBeenCalled();712 expect(container.textContent).toContain("1 entry appears to be duplicates");713 });714715 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {716 parseImportSourceMock.mockResolvedValue(sampleImportResult());717 sendMessageMock.mockImplementation(async (msg: unknown) => {718 const type = (msg as { type?: string }).type;719 if (type === "GET_ENTRIES") {720 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };721 }722 if (type === "CLEAR_VAULT_ENTRIES") {723 return { ok: true };724 }725 if (type === "BATCH_ADD_ENTRIES") {726 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };727 }728 return { ok: true };729 });730 const onImport = vi.fn(async () => undefined);731 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);732 await flushView();733 sendMessageMock.mockClear();734735 await selectImportFile(container);736 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();737 await flushView();738739 findButton(container, "Replace vault with 1").click();740 await flushView();741742 expect(sendMessageMock).not.toHaveBeenCalledWith(743 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })744 );745 expect(container.textContent).toContain("Replace forever? This cannot be undone.");746747 findButton(container, "Yes, replace forever").click();748 await flushView();749750 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });751 expect(sendMessageMock).toHaveBeenCalledWith({752 type: "BATCH_ADD_ENTRIES",753 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },754 });755 expect(onImport).toHaveBeenCalled();756 });757758 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {759 parseImportSourceMock.mockResolvedValue(sampleImportResult());760 sendMessageMock.mockImplementation(async (msg: unknown) => {761 const type = (msg as { type?: string }).type;762 if (type === "CREATE_VAULT") return { ok: true };763 if (type === "BATCH_ADD_ENTRIES") {764 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };765 }766 return { ok: true };767 });768 const onImport = vi.fn(async () => undefined);769 const container = mount(770 <ImportView771 mode="onboarding"772 replacesExistingVault={true}773 onBack={vi.fn()}774 onImport={onImport}775 />776 );777778 await selectImportFile(container);779780 const createButton = findButton(container, "Create vault + import 1");781 expect(createButton.disabled).toBe(true);782 expect(container.textContent).toContain("Restoring now replaces the locked local vault");783784 const masterInput = container.querySelector(785 'input[placeholder="new master password"]'786 ) as HTMLInputElement;787 const confirmInput = container.querySelector(788 'input[placeholder="confirm master password"]'789 ) as HTMLInputElement;790 inputText(masterInput, "fresh-master-password");791 inputText(confirmInput, "fresh-master-password");792 await flushView();793794 expect(createButton.disabled).toBe(false);795 createButton.click();796 await flushView();797798 expect(sendMessageMock).toHaveBeenCalledWith({799 type: "CREATE_VAULT",800 payload: { password: "fresh-master-password" },801 });802 expect(sendMessageMock).toHaveBeenCalledWith({803 type: "BATCH_ADD_ENTRIES",804 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },805 });806 expect(onImport).toHaveBeenCalled();807 });808809 it("Import encrypted backups reparse with the backup password before import", async () => {810 parseImportSourceMock811 .mockResolvedValueOnce(812 sampleImportResult({813 format: "peach-json",814 label: "Peach encrypted backup",815 entries: [],816 totalItems: 0,817 requiresPassword: true,818 })819 )820 .mockResolvedValueOnce(821 sampleImportResult({822 format: "peach-json",823 label: "Peach encrypted backup",824 })825 );826 sendMessageMock.mockImplementation(async (msg: unknown) => {827 const type = (msg as { type?: string }).type;828 if (type === "GET_ENTRIES") return { ok: true, data: [] };829 if (type === "BATCH_ADD_ENTRIES") {830 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };831 }832 return { ok: true };833 });834 const onImport = vi.fn(async () => undefined);835 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);836 await flushView();837 sendMessageMock.mockClear();838839 await selectImportFile(container, '{"type":"peach-export"}');840 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;841 inputText(password, "backup-passphrase");842 pressEnter(password);843 await flushView();844 await flushView();845846 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);847 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");848849 findButton(container, "Import 1").click();850 await flushView();851852 expect(sendMessageMock).toHaveBeenCalledWith({853 type: "BATCH_ADD_ENTRIES",854 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },855 });856 expect(onImport).toHaveBeenCalled();857 });858});