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 exportEntriesToPeachJsonMock = vi.fn();21const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();22const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2324vi.mock("../App", () => ({25 sendMessage: (msg: unknown) => sendMessageMock(msg),26 triggerRing: () => triggerRingMock(),27 triggerPipPulse: () => triggerPipPulseMock(),28}));2930vi.mock("../../shared/backup", () => ({31 exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),32}));3334vi.mock("../../shared/import", () => ({35 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),36}));3738vi.mock("../../shared/favicon", () => ({39 faviconUrl: () => null,40}));4142vi.mock("../../crypto/totp", () => ({43 generateTOTP: () => ({ code: "123 456", remaining: 24 }),44 parseOtpauthURI: (value: string) =>45 value.startsWith("otpauth://")46 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }47 : null,48}));4950vi.mock("../components/GeneratorDrawer", () => ({51 GeneratorDrawer: () => null,52}));5354vi.mock("./CJKImport", () => ({55 CJKImportView: () => null,56}));5758vi.mock("./CJKExport", () => ({59 CJKExportView: () => null,60}));6162vi.mock("zxcvbn", () => ({63 default: (value: string) => zxcvbnMock(value),64}));6566describe("popup contract tests", () => {67 const disposers: Array<() => void> = [];6869 beforeEach(() => {70 document.body.innerHTML = "";71 sendMessageMock.mockReset();72 triggerRingMock.mockReset();73 triggerPipPulseMock.mockReset();74 exportEntriesToPeachJsonMock.mockReset();75 parseImportSourceMock.mockReset();76 zxcvbnMock.mockReset();77 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));78 parseImportSourceMock.mockResolvedValue({79 format: "unknown",80 label: "Unknown format",81 entries: [],82 totalItems: 0,83 skippedItems: 0,84 requiresPassword: false,85 });8687 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {88 return window.setTimeout(() => cb(0), 0) as unknown as number;89 });90 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));91 vi.stubGlobal("URL", {92 createObjectURL: vi.fn(() => "blob:peach-test"),93 revokeObjectURL: vi.fn(),94 });95 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9697 (globalThis as { chrome?: typeof chrome }).chrome = {98 runtime: {99 getManifest: () => ({ version: "1.4.47" }),100 onMessage: { addListener: vi.fn() },101 },102 } as unknown as typeof chrome;103 });104105 afterEach(() => {106 while (disposers.length > 0) {107 disposers.pop()?.();108 }109 vi.unstubAllGlobals();110 });111112 function mount(view: JSX.Element) {113 const mounted = renderView(view);114 disposers.push(mounted.dispose);115 return mounted.container;116 }117118 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {119 return {120 id: "entry-1",121 type: "login",122 title: "GitHub",123 username: "june",124 password: "secret123",125 url: "https://github.com",126 totp: "JBSWY3DPEHPK3PXP",127 canary: {128 webhookUrl: "https://example.com/hook",129 },130 createdAt: 1,131 modifiedAt: 1,132 ...overrides,133 };134 }135136 function sampleImportResult(137 overrides: Partial<ImportParseResult> = {}138 ): ImportParseResult {139 return {140 format: "csv",141 label: "CSV import",142 entries: [143 {144 type: "login",145 title: "Imported site",146 username: "imported-user",147 password: "imported-pass",148 url: "https://imported.test",149 },150 ],151 totalItems: 1,152 skippedItems: 0,153 requiresPassword: false,154 ...overrides,155 };156 }157158 async function selectImportFile(159 container: HTMLElement,160 contents = '{"export":"test"}'161 ) {162 const input = container.querySelector('input[type="file"]') as HTMLInputElement;163 const file = new File([contents], "import.json", { type: "application/json" });164 Object.defineProperty(input, "files", {165 configurable: true,166 value: [file],167 });168 input.dispatchEvent(new Event("change", { bubbles: true }));169 await flushView();170 await flushView();171 }172173 function findButton(container: HTMLElement, text: string): HTMLButtonElement {174 return Array.from(container.querySelectorAll("button")).find(175 (candidate) => candidate.textContent?.trim() === text176 ) as HTMLButtonElement;177 }178179 it("Lock sends UNLOCK with the entered passphrase", async () => {180 sendMessageMock.mockResolvedValue({ ok: true });181 const onUnlock = vi.fn(async () => undefined);182 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);183184 const password = container.querySelector("#unlock-password") as HTMLInputElement;185 inputText(password, "correct horse battery staple");186 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();187 await flushView();188189 expect(sendMessageMock).toHaveBeenCalledWith({190 type: "UNLOCK",191 payload: { password: "correct horse battery staple" },192 });193 expect(onUnlock).toHaveBeenCalled();194 });195196 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {197 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);198 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;199 const password = container.querySelector("#master-password") as HTMLInputElement;200 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;201202 inputText(password, "short");203 inputText(confirm, "short");204 await flushView();205 expect(createButton.disabled).toBe(true);206207 zxcvbnMock.mockImplementation(() => ({ score: 1 }));208 inputText(password, "weakweakweak");209 inputText(confirm, "weakweakweak");210 await flushView();211 expect(createButton.disabled).toBe(true);212 });213214 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {215 sendMessageMock.mockResolvedValue({ ok: true });216 zxcvbnMock.mockImplementation(() => ({ score: 3 }));217 const onUnlock = vi.fn(async () => undefined);218 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);219220 const password = container.querySelector("#master-password") as HTMLInputElement;221 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;222 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;223224 inputText(password, "correct horse battery staple");225 inputText(confirm, "correct horse battery staple");226 await flushView();227 expect(createButton.disabled).toBe(false);228229 pressEnter(confirm);230 await flushView();231232 expect(sendMessageMock).toHaveBeenCalledWith({233 type: "CREATE_VAULT",234 payload: { password: "correct horse battery staple" },235 });236 expect(onUnlock).toHaveBeenCalled();237 });238239 it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {240 const container = mount(<ExportView onBack={vi.fn()} />);241242 const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;243 const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;244 const button = Array.from(container.querySelectorAll("button")).find(245 (candidate) => candidate.textContent?.trim() === "Download backup file"246 ) as HTMLButtonElement;247248 inputText(password, "too-short");249 inputText(confirm, "too-short");250 await flushView();251252 expect(button.disabled).toBe(true);253 expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();254 });255256 it("Export keeps encrypted export disabled when passwords do not match", async () => {257 const container = mount(<ExportView onBack={vi.fn()} />);258259 const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;260 const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;261 const button = Array.from(container.querySelectorAll("button")).find(262 (candidate) => candidate.textContent?.trim() === "Download backup file"263 ) as HTMLButtonElement;264265 inputText(password, "correct horse battery staple");266 inputText(confirm, "different horse battery staple");267 await flushView();268269 expect(button.disabled).toBe(true);270 expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();271 });272273 it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {274 sendMessageMock.mockResolvedValue({275 ok: true,276 data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],277 });278 exportEntriesToPeachJsonMock.mockResolvedValue({279 version: 2,280 type: "peach-export",281 });282 const container = mount(<ExportView onBack={vi.fn()} />);283284 const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;285 const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;286 const button = Array.from(container.querySelectorAll("button")).find(287 (candidate) => candidate.textContent?.trim() === "Download backup file"288 ) as HTMLButtonElement;289290 inputText(password, "correct horse battery staple");291 inputText(confirm, "correct horse battery staple");292 await flushView();293 button.click();294 await flushView();295296 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });297 expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(298 [299 expect.objectContaining({ id: "entry-1" }),300 expect.objectContaining({ id: "deleted", deletedAt: 2 }),301 ],302 "correct horse battery staple"303 );304 });305306 it("Settings requires current, new, and confirm passwords before enabling save", async () => {307 sendMessageMock.mockResolvedValue({ ok: true, data: null });308 const container = mount(309 <SettingsView310 entriesCount={1}311 breachMonitoring={true}312 lockOnClose={true}313 autoLockMinutes={5}314 trashRetentionDays={30}315 theme="dark"316 accentColor="peach"317 onBreachMonitoringChange={vi.fn()}318 onLockOnCloseChange={vi.fn()}319 onAutoLockMinutesChange={vi.fn()}320 onTrashRetentionChange={vi.fn()}321 onThemeChange={vi.fn()}322 onAccentChange={vi.fn()}323 shortcuts={DEFAULT_BINDINGS}324 onShortcutsChange={vi.fn()}325 onBack={vi.fn()}326 onImport={vi.fn()}327 onExport={vi.fn()}328 onBreachCheckDone={vi.fn()}329 />330 );331 await flushView();332333 const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(334 (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"335 ) as HTMLButtonElement;336 securityRow.click();337 await flushView();338339 const changeButton = Array.from(container.querySelectorAll("button")).find(340 (candidate) => candidate.textContent?.trim() === "change"341 ) as HTMLButtonElement;342 changeButton.click();343 await flushView();344345 const saveButton = Array.from(container.querySelectorAll("button")).find(346 (candidate) => candidate.textContent?.trim() === "save new password"347 ) as HTMLButtonElement;348 expect(saveButton.disabled).toBe(true);349350 const inputs = container.querySelectorAll('input[type="password"]');351 inputText(inputs[0] as HTMLInputElement, "current-pass");352 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");353 await flushView();354 expect(saveButton.disabled).toBe(true);355356 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");357 await flushView();358 expect(saveButton.disabled).toBe(false);359 });360361 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {362 sendMessageMock.mockResolvedValue({ ok: true, data: null });363 const onAutoLockMinutesChange = vi.fn();364 const onThemeChange = vi.fn();365 const container = mount(366 <SettingsView367 entriesCount={1}368 breachMonitoring={true}369 lockOnClose={true}370 autoLockMinutes={5}371 trashRetentionDays={30}372 theme="dark"373 accentColor="peach"374 onBreachMonitoringChange={vi.fn()}375 onLockOnCloseChange={vi.fn()}376 onAutoLockMinutesChange={onAutoLockMinutesChange}377 onTrashRetentionChange={vi.fn()}378 onThemeChange={onThemeChange}379 onAccentChange={vi.fn()}380 shortcuts={DEFAULT_BINDINGS}381 onShortcutsChange={vi.fn()}382 onBack={vi.fn()}383 onImport={vi.fn()}384 onExport={vi.fn()}385 onBreachCheckDone={vi.fn()}386 />387 );388 await flushView();389 sendMessageMock.mockClear();390391 const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;392393 const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(394 (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"395 ) as HTMLButtonElement;396 securityRow.click();397 await flushView();398399 const selects = container.querySelectorAll("select");400 changeSelect(selects[0] as HTMLSelectElement, "15");401 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);402403 const changeButton = Array.from(container.querySelectorAll("button")).find(404 (candidate) => candidate.textContent?.trim() === "change"405 ) as HTMLButtonElement;406 changeButton.click();407 await flushView();408409 const inputs = container.querySelectorAll('input[type="password"]');410 inputText(inputs[0] as HTMLInputElement, "current-pass");411 inputText(inputs[1] as HTMLInputElement, "new-password-123");412 inputText(inputs[2] as HTMLInputElement, "new-password-123");413 await flushView();414415 const saveButton = Array.from(container.querySelectorAll("button")).find(416 (candidate) => candidate.textContent?.trim() === "save new password"417 ) as HTMLButtonElement;418 saveButton.click();419 await flushView();420421 headerBack.click();422 await flushView();423 const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(424 (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"425 ) as HTMLButtonElement;426 appearanceRow.click();427 await flushView();428 const themeButton = Array.from(container.querySelectorAll("button")).find(429 (candidate) => candidate.textContent?.trim() === "Switch to light"430 ) as HTMLButtonElement;431 themeButton.click();432 expect(onThemeChange).toHaveBeenCalledWith("light");433434 expect(sendMessageMock).toHaveBeenCalledWith({435 type: "CHANGE_PASSWORD",436 payload: {437 currentPassword: "current-pass",438 newPassword: "new-password-123",439 },440 });441 });442443 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {444 sendMessageMock.mockResolvedValue({ ok: true });445 const container = mount(446 <EntryView447 entry={sampleEntry()}448 onDelete={vi.fn()}449 onFieldSaved={vi.fn(async () => undefined)}450 />451 );452453 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;454 passwordButton.click();455 await flushView();456457 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {458 type: "COPY",459 payload: { text: "secret123", label: "password", alreadyWritten: false },460 });461 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {462 type: "FIRE_CANARY_PING",463 payload: {464 webhookUrl: "https://example.com/hook",465 entryTitle: "GitHub",466 trigger: "password_copied",467 },468 });469470 sendMessageMock.mockClear();471 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;472 usernameButton.click();473 await flushView();474475 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {476 type: "COPY",477 payload: { text: "june", label: "username", alreadyWritten: false },478 });479 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {480 type: "FIRE_CANARY_PING",481 payload: {482 webhookUrl: "https://example.com/hook",483 entryTitle: "GitHub",484 trigger: "username_copied",485 },486 });487 });488489 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {490 sendMessageMock.mockResolvedValue({ ok: true });491 const onFieldSaved = vi.fn(async () => undefined);492 const container = mount(493 <EntryView494 entry={sampleEntry()}495 onDelete={vi.fn()}496 onFieldSaved={onFieldSaved}497 />498 );499500 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;501 passwordValue.click();502 await flushView();503504 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;505 inputText(passwordInput, "");506 await flushView();507508 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;509 saveButton.click();510 await flushView();511512 expect(sendMessageMock).not.toHaveBeenCalledWith(513 expect.objectContaining({ type: "UPDATE_ENTRY" })514 );515 expect(onFieldSaved).not.toHaveBeenCalled();516517 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;518 totpMain.click();519 await flushView();520521 expect(sendMessageMock).toHaveBeenLastCalledWith({522 type: "COPY",523 payload: { text: "123456", label: "totp", alreadyWritten: false },524 });525 });526527 it("Entry closes an unchanged inline editor when focus leaves it", async () => {528 sendMessageMock.mockResolvedValue({ ok: true });529 const container = mount(530 <EntryView531 entry={sampleEntry()}532 onDelete={vi.fn()}533 onFieldSaved={vi.fn(async () => undefined)}534 />535 );536537 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;538 usernameValue.click();539 await flushView();540541 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;542 usernameInput.blur();543 await flushView();544545 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();546 expect(sendMessageMock).not.toHaveBeenCalledWith(547 expect.objectContaining({ type: "UPDATE_ENTRY" })548 );549 });550551 it("Entry lets TOTP secrets be edited inline", async () => {552 sendMessageMock.mockResolvedValue({ ok: true });553 const onFieldSaved = vi.fn(async () => undefined);554 const container = mount(555 <EntryView556 entry={sampleEntry()}557 onDelete={vi.fn()}558 onFieldSaved={onFieldSaved}559 />560 );561562 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;563 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));564 await flushView();565566 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;567 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");568 await flushView();569570 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;571 saveButton.click();572 await flushView();573574 expect(sendMessageMock).toHaveBeenCalledWith({575 type: "UPDATE_ENTRY",576 payload: {577 id: "entry-1",578 patch: { totp: "NB2W45DFOIZA====" },579 },580 });581 expect(onFieldSaved).toHaveBeenCalled();582 });583584 it("Vault seeds and focuses search when a search seed is provided", async () => {585 const container = mount(586 <VaultView587 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}588 sortOrder="url-first"589 searchSeed="g"590 searchSeedNonce={1}591 onSelectEntry={vi.fn()}592 onSortOrderChange={vi.fn()}593 onAdd={vi.fn()}594 onClose={vi.fn()}595 />596 );597598 await flushView();599600 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;601 expect(search.value).toBe("g");602 expect(document.activeElement).toBe(search);603 });604605 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {606 parseImportSourceMock.mockResolvedValue(607 sampleImportResult({608 entries: [609 {610 type: "login",611 title: "Imported site",612 username: "imported-user",613 password: "imported-pass",614 url: "https://imported.test",615 },616 ],617 })618 );619 sendMessageMock.mockImplementation(async (msg: unknown) => {620 const type = (msg as { type?: string }).type;621 if (type === "GET_ENTRIES") {622 return {623 ok: true,624 data: [625 sampleEntry({626 id: "existing-1",627 title: "Imported site",628 username: "imported-user",629 url: "https://imported.test",630 }),631 ],632 };633 }634 if (type === "BATCH_ADD_ENTRIES") {635 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };636 }637 return { ok: true };638 });639 const onImport = vi.fn(async () => undefined);640 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);641 await flushView();642 sendMessageMock.mockClear();643644 await selectImportFile(container);645 findButton(container, "Import 1").click();646 await flushView();647648 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);649 expect(sendMessageMock).toHaveBeenCalledWith({650 type: "BATCH_ADD_ENTRIES",651 payload: {652 entries: [653 expect.objectContaining({654 title: "Imported site",655 username: "imported-user",656 password: "imported-pass",657 url: "https://imported.test",658 }),659 ],660 },661 });662 expect(onImport).toHaveBeenCalled();663 expect(container.textContent).toContain("1 entry appears to be duplicates");664 });665666 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {667 parseImportSourceMock.mockResolvedValue(sampleImportResult());668 sendMessageMock.mockImplementation(async (msg: unknown) => {669 const type = (msg as { type?: string }).type;670 if (type === "GET_ENTRIES") {671 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };672 }673 if (type === "CLEAR_VAULT_ENTRIES") {674 return { ok: true };675 }676 if (type === "BATCH_ADD_ENTRIES") {677 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };678 }679 return { ok: true };680 });681 const onImport = vi.fn(async () => undefined);682 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);683 await flushView();684 sendMessageMock.mockClear();685686 await selectImportFile(container);687 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();688 await flushView();689690 findButton(container, "Replace vault with 1").click();691 await flushView();692693 expect(sendMessageMock).not.toHaveBeenCalledWith(694 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })695 );696 expect(container.textContent).toContain("Replace forever? This cannot be undone.");697698 findButton(container, "Yes, replace forever").click();699 await flushView();700701 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });702 expect(sendMessageMock).toHaveBeenCalledWith({703 type: "BATCH_ADD_ENTRIES",704 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },705 });706 expect(onImport).toHaveBeenCalled();707 });708709 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {710 parseImportSourceMock.mockResolvedValue(sampleImportResult());711 sendMessageMock.mockImplementation(async (msg: unknown) => {712 const type = (msg as { type?: string }).type;713 if (type === "CREATE_VAULT") return { ok: true };714 if (type === "BATCH_ADD_ENTRIES") {715 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };716 }717 return { ok: true };718 });719 const onImport = vi.fn(async () => undefined);720 const container = mount(721 <ImportView722 mode="onboarding"723 replacesExistingVault={true}724 onBack={vi.fn()}725 onImport={onImport}726 />727 );728729 await selectImportFile(container);730731 const createButton = findButton(container, "Create vault + import 1");732 expect(createButton.disabled).toBe(true);733 expect(container.textContent).toContain("Restoring now replaces the locked local vault");734735 const inputs = container.querySelectorAll('input[type="password"]');736 inputText(inputs[0] as HTMLInputElement, "fresh-master-password");737 inputText(inputs[1] as HTMLInputElement, "fresh-master-password");738 await flushView();739740 expect(createButton.disabled).toBe(false);741 createButton.click();742 await flushView();743744 expect(sendMessageMock).toHaveBeenCalledWith({745 type: "CREATE_VAULT",746 payload: { password: "fresh-master-password" },747 });748 expect(sendMessageMock).toHaveBeenCalledWith({749 type: "BATCH_ADD_ENTRIES",750 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },751 });752 expect(onImport).toHaveBeenCalled();753 });754755 it("Import encrypted backups reparse with the backup password before import", async () => {756 parseImportSourceMock757 .mockResolvedValueOnce(758 sampleImportResult({759 format: "peach-json",760 label: "Peach encrypted backup",761 entries: [],762 totalItems: 0,763 requiresPassword: true,764 })765 )766 .mockResolvedValueOnce(767 sampleImportResult({768 format: "peach-json",769 label: "Peach encrypted backup",770 })771 );772 sendMessageMock.mockImplementation(async (msg: unknown) => {773 const type = (msg as { type?: string }).type;774 if (type === "GET_ENTRIES") return { ok: true, data: [] };775 if (type === "BATCH_ADD_ENTRIES") {776 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };777 }778 return { ok: true };779 });780 const onImport = vi.fn(async () => undefined);781 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);782 await flushView();783 sendMessageMock.mockClear();784785 await selectImportFile(container, '{"type":"peach-export"}');786 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;787 inputText(password, "backup-passphrase");788 pressEnter(password);789 await flushView();790 await flushView();791792 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);793 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");794795 findButton(container, "Import 1").click();796 await flushView();797798 expect(sendMessageMock).toHaveBeenCalledWith({799 type: "BATCH_ADD_ENTRIES",800 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },801 });802 expect(onImport).toHaveBeenCalled();803 });804});