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 undefined304 );305 });306307 it("Settings requires current, new, and confirm passwords before enabling save", async () => {308 sendMessageMock.mockResolvedValue({ ok: true, data: null });309 const container = mount(310 <SettingsView311 entriesCount={1}312 breachMonitoring={true}313 lockOnClose={true}314 autoLockMinutes={5}315 trashRetentionDays={30}316 theme="dark"317 accentColor="peach"318 onBreachMonitoringChange={vi.fn()}319 onLockOnCloseChange={vi.fn()}320 onAutoLockMinutesChange={vi.fn()}321 onTrashRetentionChange={vi.fn()}322 onThemeChange={vi.fn()}323 onAccentChange={vi.fn()}324 shortcuts={DEFAULT_BINDINGS}325 onShortcutsChange={vi.fn()}326 onBack={vi.fn()}327 onImport={vi.fn()}328 onExport={vi.fn()}329 onBreachCheckDone={vi.fn()}330 />331 );332 await flushView();333334 const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(335 (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"336 ) as HTMLButtonElement;337 securityRow.click();338 await flushView();339340 const changeButton = Array.from(container.querySelectorAll("button")).find(341 (candidate) => candidate.textContent?.trim() === "change"342 ) as HTMLButtonElement;343 changeButton.click();344 await flushView();345346 const saveButton = Array.from(container.querySelectorAll("button")).find(347 (candidate) => candidate.textContent?.trim() === "save new password"348 ) as HTMLButtonElement;349 expect(saveButton.disabled).toBe(true);350351 const inputs = container.querySelectorAll('input[type="password"]');352 inputText(inputs[0] as HTMLInputElement, "current-pass");353 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");354 await flushView();355 expect(saveButton.disabled).toBe(true);356357 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");358 await flushView();359 expect(saveButton.disabled).toBe(false);360 });361362 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {363 sendMessageMock.mockResolvedValue({ ok: true, data: null });364 const onAutoLockMinutesChange = vi.fn();365 const onThemeChange = vi.fn();366 const container = mount(367 <SettingsView368 entriesCount={1}369 breachMonitoring={true}370 lockOnClose={true}371 autoLockMinutes={5}372 trashRetentionDays={30}373 theme="dark"374 accentColor="peach"375 onBreachMonitoringChange={vi.fn()}376 onLockOnCloseChange={vi.fn()}377 onAutoLockMinutesChange={onAutoLockMinutesChange}378 onTrashRetentionChange={vi.fn()}379 onThemeChange={onThemeChange}380 onAccentChange={vi.fn()}381 shortcuts={DEFAULT_BINDINGS}382 onShortcutsChange={vi.fn()}383 onBack={vi.fn()}384 onImport={vi.fn()}385 onExport={vi.fn()}386 onBreachCheckDone={vi.fn()}387 />388 );389 await flushView();390 sendMessageMock.mockClear();391392 const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;393394 const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(395 (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"396 ) as HTMLButtonElement;397 securityRow.click();398 await flushView();399400 const selects = container.querySelectorAll("select");401 changeSelect(selects[0] as HTMLSelectElement, "15");402 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);403404 const changeButton = Array.from(container.querySelectorAll("button")).find(405 (candidate) => candidate.textContent?.trim() === "change"406 ) as HTMLButtonElement;407 changeButton.click();408 await flushView();409410 const inputs = container.querySelectorAll('input[type="password"]');411 inputText(inputs[0] as HTMLInputElement, "current-pass");412 inputText(inputs[1] as HTMLInputElement, "new-password-123");413 inputText(inputs[2] as HTMLInputElement, "new-password-123");414 await flushView();415416 const saveButton = Array.from(container.querySelectorAll("button")).find(417 (candidate) => candidate.textContent?.trim() === "save new password"418 ) as HTMLButtonElement;419 saveButton.click();420 await flushView();421422 headerBack.click();423 await flushView();424 const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(425 (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"426 ) as HTMLButtonElement;427 appearanceRow.click();428 await flushView();429 const themeButton = Array.from(container.querySelectorAll("button")).find(430 (candidate) => candidate.textContent?.trim() === "Switch to light"431 ) as HTMLButtonElement;432 themeButton.click();433 expect(onThemeChange).toHaveBeenCalledWith("light");434435 expect(sendMessageMock).toHaveBeenCalledWith({436 type: "CHANGE_PASSWORD",437 payload: {438 currentPassword: "current-pass",439 newPassword: "new-password-123",440 },441 });442 });443444 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {445 sendMessageMock.mockResolvedValue({ ok: true });446 const container = mount(447 <EntryView448 entry={sampleEntry()}449 onDelete={vi.fn()}450 onFieldSaved={vi.fn(async () => undefined)}451 />452 );453454 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;455 passwordButton.click();456 await flushView();457458 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {459 type: "COPY",460 payload: { text: "secret123", label: "password", alreadyWritten: false },461 });462 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {463 type: "FIRE_CANARY_PING",464 payload: {465 webhookUrl: "https://example.com/hook",466 entryTitle: "GitHub",467 trigger: "password_copied",468 },469 });470471 sendMessageMock.mockClear();472 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;473 usernameButton.click();474 await flushView();475476 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {477 type: "COPY",478 payload: { text: "june", label: "username", alreadyWritten: false },479 });480 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {481 type: "FIRE_CANARY_PING",482 payload: {483 webhookUrl: "https://example.com/hook",484 entryTitle: "GitHub",485 trigger: "username_copied",486 },487 });488 });489490 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {491 sendMessageMock.mockResolvedValue({ ok: true });492 const onFieldSaved = vi.fn(async () => undefined);493 const container = mount(494 <EntryView495 entry={sampleEntry()}496 onDelete={vi.fn()}497 onFieldSaved={onFieldSaved}498 />499 );500501 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;502 passwordValue.click();503 await flushView();504505 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;506 inputText(passwordInput, "");507 await flushView();508509 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;510 saveButton.click();511 await flushView();512513 expect(sendMessageMock).not.toHaveBeenCalledWith(514 expect.objectContaining({ type: "UPDATE_ENTRY" })515 );516 expect(onFieldSaved).not.toHaveBeenCalled();517518 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;519 totpMain.click();520 await flushView();521522 expect(sendMessageMock).toHaveBeenLastCalledWith({523 type: "COPY",524 payload: { text: "123456", label: "totp", alreadyWritten: false },525 });526 });527528 it("Entry closes an unchanged inline editor when focus leaves it", async () => {529 sendMessageMock.mockResolvedValue({ ok: true });530 const container = mount(531 <EntryView532 entry={sampleEntry()}533 onDelete={vi.fn()}534 onFieldSaved={vi.fn(async () => undefined)}535 />536 );537538 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;539 usernameValue.click();540 await flushView();541542 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;543 usernameInput.blur();544 await flushView();545546 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();547 expect(sendMessageMock).not.toHaveBeenCalledWith(548 expect.objectContaining({ type: "UPDATE_ENTRY" })549 );550 });551552 it("Entry lets TOTP secrets be edited inline", async () => {553 sendMessageMock.mockResolvedValue({ ok: true });554 const onFieldSaved = vi.fn(async () => undefined);555 const container = mount(556 <EntryView557 entry={sampleEntry()}558 onDelete={vi.fn()}559 onFieldSaved={onFieldSaved}560 />561 );562563 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;564 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));565 await flushView();566567 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;568 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");569 await flushView();570571 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;572 saveButton.click();573 await flushView();574575 expect(sendMessageMock).toHaveBeenCalledWith({576 type: "UPDATE_ENTRY",577 payload: {578 id: "entry-1",579 patch: { totp: "NB2W45DFOIZA====" },580 },581 });582 expect(onFieldSaved).toHaveBeenCalled();583 });584585 it("Vault seeds and focuses search when a search seed is provided", async () => {586 const container = mount(587 <VaultView588 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}589 sortOrder="url-first"590 searchSeed="g"591 searchSeedNonce={1}592 onSelectEntry={vi.fn()}593 onSortOrderChange={vi.fn()}594 onAdd={vi.fn()}595 onClose={vi.fn()}596 />597 );598599 await flushView();600601 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;602 expect(search.value).toBe("g");603 expect(document.activeElement).toBe(search);604 });605606 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {607 parseImportSourceMock.mockResolvedValue(608 sampleImportResult({609 entries: [610 {611 type: "login",612 title: "Imported site",613 username: "imported-user",614 password: "imported-pass",615 url: "https://imported.test",616 },617 ],618 })619 );620 sendMessageMock.mockImplementation(async (msg: unknown) => {621 const type = (msg as { type?: string }).type;622 if (type === "GET_ENTRIES") {623 return {624 ok: true,625 data: [626 sampleEntry({627 id: "existing-1",628 title: "Imported site",629 username: "imported-user",630 url: "https://imported.test",631 }),632 ],633 };634 }635 if (type === "BATCH_ADD_ENTRIES") {636 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };637 }638 return { ok: true };639 });640 const onImport = vi.fn(async () => undefined);641 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);642 await flushView();643 sendMessageMock.mockClear();644645 await selectImportFile(container);646 findButton(container, "Import 1").click();647 await flushView();648649 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);650 expect(sendMessageMock).toHaveBeenCalledWith({651 type: "BATCH_ADD_ENTRIES",652 payload: {653 entries: [654 expect.objectContaining({655 title: "Imported site",656 username: "imported-user",657 password: "imported-pass",658 url: "https://imported.test",659 }),660 ],661 },662 });663 expect(onImport).toHaveBeenCalled();664 expect(container.textContent).toContain("1 entry appears to be duplicates");665 });666667 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {668 parseImportSourceMock.mockResolvedValue(sampleImportResult());669 sendMessageMock.mockImplementation(async (msg: unknown) => {670 const type = (msg as { type?: string }).type;671 if (type === "GET_ENTRIES") {672 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };673 }674 if (type === "CLEAR_VAULT_ENTRIES") {675 return { ok: true };676 }677 if (type === "BATCH_ADD_ENTRIES") {678 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };679 }680 return { ok: true };681 });682 const onImport = vi.fn(async () => undefined);683 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);684 await flushView();685 sendMessageMock.mockClear();686687 await selectImportFile(container);688 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();689 await flushView();690691 findButton(container, "Replace vault with 1").click();692 await flushView();693694 expect(sendMessageMock).not.toHaveBeenCalledWith(695 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })696 );697 expect(container.textContent).toContain("Replace forever? This cannot be undone.");698699 findButton(container, "Yes, replace forever").click();700 await flushView();701702 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });703 expect(sendMessageMock).toHaveBeenCalledWith({704 type: "BATCH_ADD_ENTRIES",705 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },706 });707 expect(onImport).toHaveBeenCalled();708 });709710 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {711 parseImportSourceMock.mockResolvedValue(sampleImportResult());712 sendMessageMock.mockImplementation(async (msg: unknown) => {713 const type = (msg as { type?: string }).type;714 if (type === "CREATE_VAULT") return { ok: true };715 if (type === "BATCH_ADD_ENTRIES") {716 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };717 }718 return { ok: true };719 });720 const onImport = vi.fn(async () => undefined);721 const container = mount(722 <ImportView723 mode="onboarding"724 replacesExistingVault={true}725 onBack={vi.fn()}726 onImport={onImport}727 />728 );729730 await selectImportFile(container);731732 const createButton = findButton(container, "Create vault + import 1");733 expect(createButton.disabled).toBe(true);734 expect(container.textContent).toContain("Restoring now replaces the locked local vault");735736 const inputs = container.querySelectorAll('input[type="password"]');737 inputText(inputs[0] as HTMLInputElement, "fresh-master-password");738 inputText(inputs[1] as HTMLInputElement, "fresh-master-password");739 await flushView();740741 expect(createButton.disabled).toBe(false);742 createButton.click();743 await flushView();744745 expect(sendMessageMock).toHaveBeenCalledWith({746 type: "CREATE_VAULT",747 payload: { password: "fresh-master-password" },748 });749 expect(sendMessageMock).toHaveBeenCalledWith({750 type: "BATCH_ADD_ENTRIES",751 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },752 });753 expect(onImport).toHaveBeenCalled();754 });755756 it("Import encrypted backups reparse with the backup password before import", async () => {757 parseImportSourceMock758 .mockResolvedValueOnce(759 sampleImportResult({760 format: "peach-json",761 label: "Peach encrypted backup",762 entries: [],763 totalItems: 0,764 requiresPassword: true,765 })766 )767 .mockResolvedValueOnce(768 sampleImportResult({769 format: "peach-json",770 label: "Peach encrypted backup",771 })772 );773 sendMessageMock.mockImplementation(async (msg: unknown) => {774 const type = (msg as { type?: string }).type;775 if (type === "GET_ENTRIES") return { ok: true, data: [] };776 if (type === "BATCH_ADD_ENTRIES") {777 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };778 }779 return { ok: true };780 });781 const onImport = vi.fn(async () => undefined);782 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);783 await flushView();784 sendMessageMock.mockClear();785786 await selectImportFile(container, '{"type":"peach-export"}');787 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;788 inputText(password, "backup-passphrase");789 pressEnter(password);790 await flushView();791 await flushView();792793 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);794 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");795796 findButton(container, "Import 1").click();797 await flushView();798799 expect(sendMessageMock).toHaveBeenCalledWith({800 type: "BATCH_ADD_ENTRIES",801 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },802 });803 expect(onImport).toHaveBeenCalled();804 });805});