contracts.test.tsx

25.5 KB · raw

1// @vitest-environment jsdom23import 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("./CJKImport", () => ({50  CJKImportView: () => null,51}));5253vi.mock("zxcvbn", () => ({54  default: (value: string) => zxcvbnMock(value),55}));5657describe("popup contract tests", () => {58  const disposers: Array<() => void> = [];5960  beforeEach(() => {61    document.body.innerHTML = "";62    sendMessageMock.mockReset();63    triggerRingMock.mockReset();64    triggerPipPulseMock.mockReset();65    parseImportSourceMock.mockReset();66    zxcvbnMock.mockReset();67    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));68    parseImportSourceMock.mockResolvedValue({69      format: "unknown",70      label: "Unknown format",71      entries: [],72      totalItems: 0,73      skippedItems: 0,74      requiresPassword: false,75    });7677    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {78      return window.setTimeout(() => cb(0), 0) as unknown as number;79    });80    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));81    vi.stubGlobal("URL", {82      createObjectURL: vi.fn(() => "blob:peach-test"),83      revokeObjectURL: vi.fn(),84    });85    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8687    (globalThis as { chrome?: typeof chrome }).chrome = {88      runtime: {89        getManifest: () => ({ version: "1.4.47" }),90        onMessage: { addListener: vi.fn() },91      },92    } as unknown as typeof chrome;93  });9495  afterEach(() => {96    while (disposers.length > 0) {97      disposers.pop()?.();98    }99    vi.unstubAllGlobals();100  });101102  function mount(view: JSX.Element) {103    const mounted = renderView(view);104    disposers.push(mounted.dispose);105    return mounted.container;106  }107108  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {109    return {110      id: "entry-1",111      type: "login",112      title: "GitHub",113      username: "june",114      password: "secret123",115      url: "https://github.com",116      totp: "JBSWY3DPEHPK3PXP",117      canary: {118        webhookUrl: "https://example.com/hook",119      },120      createdAt: 1,121      modifiedAt: 1,122      ...overrides,123    };124  }125126  function sampleImportResult(127    overrides: Partial<ImportParseResult> = {}128  ): ImportParseResult {129    return {130      format: "csv",131      label: "CSV import",132      entries: [133        {134          type: "login",135          title: "Imported site",136          username: "imported-user",137          password: "imported-pass",138          url: "https://imported.test",139        },140      ],141      totalItems: 1,142      skippedItems: 0,143      requiresPassword: false,144      ...overrides,145    };146  }147148  async function selectImportFile(149    container: HTMLElement,150    contents = '{"export":"test"}'151  ) {152    const input = container.querySelector('input[type="file"]') as HTMLInputElement;153    const file = new File([contents], "import.json", { type: "application/json" });154    Object.defineProperty(input, "files", {155      configurable: true,156      value: [file],157    });158    input.dispatchEvent(new Event("change", { bubbles: true }));159    await flushView();160    await flushView();161  }162163  function findButton(container: HTMLElement, text: string): HTMLButtonElement {164    return Array.from(container.querySelectorAll("button")).find(165      (candidate) => candidate.textContent?.trim() === text166    ) as HTMLButtonElement;167  }168169  it("Lock sends UNLOCK with the entered passphrase", async () => {170    sendMessageMock.mockResolvedValue({ ok: true });171    const onUnlock = vi.fn(async () => undefined);172    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);173174    const password = container.querySelector("#unlock-password") as HTMLInputElement;175    inputText(password, "correct horse battery staple");176    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();177    await flushView();178179    expect(sendMessageMock).toHaveBeenCalledWith({180      type: "UNLOCK",181      payload: { password: "correct horse battery staple" },182    });183    expect(onUnlock).toHaveBeenCalled();184  });185186  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {187    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);188    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;189    const password = container.querySelector("#master-password") as HTMLInputElement;190    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;191192    inputText(password, "short");193    inputText(confirm, "short");194    await flushView();195    expect(createButton.disabled).toBe(true);196197    zxcvbnMock.mockImplementation(() => ({ score: 1 }));198    inputText(password, "weakweakweak");199    inputText(confirm, "weakweakweak");200    await flushView();201    expect(createButton.disabled).toBe(true);202  });203204  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {205    sendMessageMock.mockResolvedValue({ ok: true });206    zxcvbnMock.mockImplementation(() => ({ score: 3 }));207    const onUnlock = vi.fn(async () => undefined);208    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);209210    const password = container.querySelector("#master-password") as HTMLInputElement;211    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;212    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;213214    inputText(password, "correct horse battery staple");215    inputText(confirm, "correct horse battery staple");216    await flushView();217    expect(createButton.disabled).toBe(false);218219    pressEnter(confirm);220    await flushView();221222    expect(sendMessageMock).toHaveBeenCalledWith({223      type: "CREATE_VAULT",224      payload: { password: "correct horse battery staple" },225    });226    expect(onUnlock).toHaveBeenCalled();227  });228229  it("Export menu surfaces the three v2.5.0 backup formats", async () => {230    const container = mount(<ExportView onBack={vi.fn()} />);231    await flushView();232233    expect(container.textContent).toContain("PeachScript");234    expect(container.textContent).toContain("print backup");235    expect(container.textContent).toContain("CSV");236  });237238  it("Settings requires current, new, and confirm passwords before enabling save", async () => {239    sendMessageMock.mockResolvedValue({ ok: true, data: null });240    const container = mount(241      <SettingsView242        entriesCount={1}243        breachMonitoring={true}244        lockOnClose={true}245        autoLockMinutes={5}246        trashRetentionDays={30}247        theme="dark"248        accentColor="peach"249        displayFont="cormorant-italic"250        textSize="medium"251        reduceMotion={false}252        onBreachMonitoringChange={vi.fn()}253        onLockOnCloseChange={vi.fn()}254        onAutoLockMinutesChange={vi.fn()}255        onTrashRetentionChange={vi.fn()}256        onThemeChange={vi.fn()}257        onAccentChange={vi.fn()}258        onDisplayFontChange={vi.fn()}259        onTextSizeChange={vi.fn()}260        onReduceMotionChange={vi.fn()}261        shortcuts={DEFAULT_BINDINGS}262        onShortcutsChange={vi.fn()}263        onBack={vi.fn()}264        onImport={vi.fn()}265        onExport={vi.fn()}266        onBreachCheckDone={vi.fn()}267      />268    );269    await flushView();270271    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(272      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"273    ) as HTMLButtonElement;274    securityRow.click();275    await flushView();276277    const changeButton = Array.from(container.querySelectorAll("button")).find(278      (candidate) => candidate.textContent?.trim() === "change"279    ) as HTMLButtonElement;280    changeButton.click();281    await flushView();282283    const saveButton = Array.from(container.querySelectorAll("button")).find(284      (candidate) => candidate.textContent?.trim() === "save new password"285    ) as HTMLButtonElement;286    expect(saveButton.disabled).toBe(true);287288    const inputs = container.querySelectorAll('input[type="password"]');289    inputText(inputs[0] as HTMLInputElement, "current-pass");290    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");291    await flushView();292    expect(saveButton.disabled).toBe(true);293294    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");295    await flushView();296    expect(saveButton.disabled).toBe(false);297  });298299  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {300    sendMessageMock.mockResolvedValue({ ok: true, data: null });301    const onAutoLockMinutesChange = vi.fn();302    const onThemeChange = vi.fn();303    const container = mount(304      <SettingsView305        entriesCount={1}306        breachMonitoring={true}307        lockOnClose={true}308        autoLockMinutes={5}309        trashRetentionDays={30}310        theme="dark"311        accentColor="peach"312        displayFont="cormorant-italic"313        textSize="medium"314        reduceMotion={false}315        onBreachMonitoringChange={vi.fn()}316        onLockOnCloseChange={vi.fn()}317        onAutoLockMinutesChange={onAutoLockMinutesChange}318        onTrashRetentionChange={vi.fn()}319        onThemeChange={onThemeChange}320        onAccentChange={vi.fn()}321        onDisplayFontChange={vi.fn()}322        onTextSizeChange={vi.fn()}323        onReduceMotionChange={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();333    sendMessageMock.mockClear();334335    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;336337    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(338      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"339    ) as HTMLButtonElement;340    securityRow.click();341    await flushView();342343    const selects = container.querySelectorAll("select");344    changeSelect(selects[0] as HTMLSelectElement, "15");345    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);346347    const changeButton = Array.from(container.querySelectorAll("button")).find(348      (candidate) => candidate.textContent?.trim() === "change"349    ) as HTMLButtonElement;350    changeButton.click();351    await flushView();352353    const inputs = container.querySelectorAll('input[type="password"]');354    inputText(inputs[0] as HTMLInputElement, "current-pass");355    inputText(inputs[1] as HTMLInputElement, "new-password-123");356    inputText(inputs[2] as HTMLInputElement, "new-password-123");357    await flushView();358359    const saveButton = Array.from(container.querySelectorAll("button")).find(360      (candidate) => candidate.textContent?.trim() === "save new password"361    ) as HTMLButtonElement;362    saveButton.click();363    await flushView();364365    headerBack.click();366    await flushView();367    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(368      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"369    ) as HTMLButtonElement;370    appearanceRow.click();371    await flushView();372    const themeButton = Array.from(container.querySelectorAll("button")).find(373      (candidate) => candidate.textContent?.trim() === "Switch to light"374    ) as HTMLButtonElement;375    themeButton.click();376    expect(onThemeChange).toHaveBeenCalledWith("light");377378    expect(sendMessageMock).toHaveBeenCalledWith({379      type: "CHANGE_PASSWORD",380      payload: {381        currentPassword: "current-pass",382        newPassword: "new-password-123",383      },384    });385  });386387  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {388    sendMessageMock.mockResolvedValue({ ok: true });389    const container = mount(390      <EntryView391        entry={sampleEntry()}392        onDelete={vi.fn()}393        onFieldSaved={vi.fn(async () => undefined)}394      />395    );396397    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;398    passwordButton.click();399    await flushView();400401    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {402      type: "COPY",403      payload: { text: "secret123", label: "password", alreadyWritten: false },404    });405    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {406      type: "FIRE_CANARY_PING",407      payload: {408        webhookUrl: "https://example.com/hook",409        entryTitle: "GitHub",410        trigger: "password_copied",411      },412    });413414    sendMessageMock.mockClear();415    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;416    usernameButton.click();417    await flushView();418419    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {420      type: "COPY",421      payload: { text: "june", label: "username", alreadyWritten: false },422    });423    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {424      type: "FIRE_CANARY_PING",425      payload: {426        webhookUrl: "https://example.com/hook",427        entryTitle: "GitHub",428        trigger: "username_copied",429      },430    });431  });432433  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {434    sendMessageMock.mockResolvedValue({ ok: true });435    const onFieldSaved = vi.fn(async () => undefined);436    const container = mount(437      <EntryView438        entry={sampleEntry()}439        onDelete={vi.fn()}440        onFieldSaved={onFieldSaved}441      />442    );443444    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;445    passwordValue.click();446    await flushView();447448    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;449    inputText(passwordInput, "");450    await flushView();451452    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;453    saveButton.click();454    await flushView();455456    expect(sendMessageMock).not.toHaveBeenCalledWith(457      expect.objectContaining({ type: "UPDATE_ENTRY" })458    );459    expect(onFieldSaved).not.toHaveBeenCalled();460461    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;462    totpMain.click();463    await flushView();464465    expect(sendMessageMock).toHaveBeenLastCalledWith({466      type: "COPY",467      payload: { text: "123456", label: "totp", alreadyWritten: false },468    });469  });470471  it("Entry closes an unchanged inline editor when focus leaves it", async () => {472    sendMessageMock.mockResolvedValue({ ok: true });473    const container = mount(474      <EntryView475        entry={sampleEntry()}476        onDelete={vi.fn()}477        onFieldSaved={vi.fn(async () => undefined)}478      />479    );480481    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;482    usernameValue.click();483    await flushView();484485    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;486    usernameInput.blur();487    await flushView();488489    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();490    expect(sendMessageMock).not.toHaveBeenCalledWith(491      expect.objectContaining({ type: "UPDATE_ENTRY" })492    );493  });494495  it("Entry lets TOTP secrets be edited inline", async () => {496    sendMessageMock.mockResolvedValue({ ok: true });497    const onFieldSaved = vi.fn(async () => undefined);498    const container = mount(499      <EntryView500        entry={sampleEntry()}501        onDelete={vi.fn()}502        onFieldSaved={onFieldSaved}503      />504    );505506    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;507    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));508    await flushView();509510    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;511    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");512    await flushView();513514    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;515    saveButton.click();516    await flushView();517518    expect(sendMessageMock).toHaveBeenCalledWith({519      type: "UPDATE_ENTRY",520      payload: {521        id: "entry-1",522        patch: { totp: "NB2W45DFOIZA====" },523      },524    });525    expect(onFieldSaved).toHaveBeenCalled();526  });527528  it("Vault seeds and focuses search when a search seed is provided", async () => {529    const container = mount(530      <VaultView531        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}532        sortOrder="url-first"533        searchSeed="g"534        searchSeedNonce={1}535        onSelectEntry={vi.fn()}536        onSortOrderChange={vi.fn()}537        onAdd={vi.fn()}538        onClose={vi.fn()}539      />540    );541542    await flushView();543544    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;545    expect(search.value).toBe("g");546    expect(document.activeElement).toBe(search);547  });548549  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {550    parseImportSourceMock.mockResolvedValue(551      sampleImportResult({552        entries: [553          {554            type: "login",555            title: "Imported site",556            username: "imported-user",557            password: "imported-pass",558            url: "https://imported.test",559          },560        ],561      })562    );563    sendMessageMock.mockImplementation(async (msg: unknown) => {564      const type = (msg as { type?: string }).type;565      if (type === "GET_ENTRIES") {566        return {567          ok: true,568          data: [569            sampleEntry({570              id: "existing-1",571              title: "Imported site",572              username: "imported-user",573              url: "https://imported.test",574            }),575          ],576        };577      }578      if (type === "BATCH_ADD_ENTRIES") {579        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };580      }581      return { ok: true };582    });583    const onImport = vi.fn(async () => undefined);584    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);585    await flushView();586    sendMessageMock.mockClear();587588    await selectImportFile(container);589    findButton(container, "Import 1").click();590    await flushView();591592    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);593    expect(sendMessageMock).toHaveBeenCalledWith({594      type: "BATCH_ADD_ENTRIES",595      payload: {596        entries: [597          expect.objectContaining({598            title: "Imported site",599            username: "imported-user",600            password: "imported-pass",601            url: "https://imported.test",602          }),603        ],604      },605    });606    expect(onImport).toHaveBeenCalled();607    expect(container.textContent).toContain("1 entry appears to be duplicates");608  });609610  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {611    parseImportSourceMock.mockResolvedValue(sampleImportResult());612    sendMessageMock.mockImplementation(async (msg: unknown) => {613      const type = (msg as { type?: string }).type;614      if (type === "GET_ENTRIES") {615        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };616      }617      if (type === "CLEAR_VAULT_ENTRIES") {618        return { ok: true };619      }620      if (type === "BATCH_ADD_ENTRIES") {621        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };622      }623      return { ok: true };624    });625    const onImport = vi.fn(async () => undefined);626    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);627    await flushView();628    sendMessageMock.mockClear();629630    await selectImportFile(container);631    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();632    await flushView();633634    findButton(container, "Replace vault with 1").click();635    await flushView();636637    expect(sendMessageMock).not.toHaveBeenCalledWith(638      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })639    );640    expect(container.textContent).toContain("Replace forever? This cannot be undone.");641642    findButton(container, "Yes, replace forever").click();643    await flushView();644645    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });646    expect(sendMessageMock).toHaveBeenCalledWith({647      type: "BATCH_ADD_ENTRIES",648      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },649    });650    expect(onImport).toHaveBeenCalled();651  });652653  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {654    parseImportSourceMock.mockResolvedValue(sampleImportResult());655    sendMessageMock.mockImplementation(async (msg: unknown) => {656      const type = (msg as { type?: string }).type;657      if (type === "CREATE_VAULT") return { ok: true };658      if (type === "BATCH_ADD_ENTRIES") {659        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };660      }661      return { ok: true };662    });663    const onImport = vi.fn(async () => undefined);664    const container = mount(665      <ImportView666        mode="onboarding"667        replacesExistingVault={true}668        onBack={vi.fn()}669        onImport={onImport}670      />671    );672673    await selectImportFile(container);674675    const createButton = findButton(container, "Create vault + import 1");676    expect(createButton.disabled).toBe(true);677    expect(container.textContent).toContain("Restoring now replaces the locked local vault");678679    const masterInput = container.querySelector(680      'input[placeholder="new master password"]'681    ) as HTMLInputElement;682    const confirmInput = container.querySelector(683      'input[placeholder="confirm master password"]'684    ) as HTMLInputElement;685    inputText(masterInput, "fresh-master-password");686    inputText(confirmInput, "fresh-master-password");687    await flushView();688689    expect(createButton.disabled).toBe(false);690    createButton.click();691    await flushView();692693    expect(sendMessageMock).toHaveBeenCalledWith({694      type: "CREATE_VAULT",695      payload: { password: "fresh-master-password" },696    });697    expect(sendMessageMock).toHaveBeenCalledWith({698      type: "BATCH_ADD_ENTRIES",699      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },700    });701    expect(onImport).toHaveBeenCalled();702  });703704  it("Import encrypted backups reparse with the backup password before import", async () => {705    parseImportSourceMock706      .mockResolvedValueOnce(707        sampleImportResult({708          format: "peach-json",709          label: "Peach encrypted backup",710          entries: [],711          totalItems: 0,712          requiresPassword: true,713        })714      )715      .mockResolvedValueOnce(716        sampleImportResult({717          format: "peach-json",718          label: "Peach encrypted backup",719        })720      );721    sendMessageMock.mockImplementation(async (msg: unknown) => {722      const type = (msg as { type?: string }).type;723      if (type === "GET_ENTRIES") return { ok: true, data: [] };724      if (type === "BATCH_ADD_ENTRIES") {725        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };726      }727      return { ok: true };728    });729    const onImport = vi.fn(async () => undefined);730    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);731    await flushView();732    sendMessageMock.mockClear();733734    await selectImportFile(container, '{"type":"peach-export"}');735    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;736    inputText(password, "backup-passphrase");737    pressEnter(password);738    await flushView();739    await flushView();740741    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);742    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");743744    findButton(container, "Import 1").click();745    await flushView();746747    expect(sendMessageMock).toHaveBeenCalledWith({748      type: "BATCH_ADD_ENTRIES",749      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },750    });751    expect(onImport).toHaveBeenCalled();752  });753});