contracts.test.tsx

25.4 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 { EntryView } from "./Entry";12import { ImportView } from "./Import";13import { VaultView } from "./Vault";14import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1516const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();17const triggerRingMock = vi.fn();18const triggerPipPulseMock = vi.fn();19const exportEntriesToPeachJsonMock = 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/backup", () => ({30  exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),31}));3233vi.mock("../../shared/import", () => ({34  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),35}));3637vi.mock("../../shared/favicon", () => ({38  faviconUrl: () => null,39}));4041vi.mock("../../crypto/totp", () => ({42  generateTOTP: () => ({ code: "123 456", remaining: 24 }),43  parseOtpauthURI: (value: string) =>44    value.startsWith("otpauth://")45      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }46      : null,47}));4849vi.mock("../components/GeneratorDrawer", () => ({50  GeneratorDrawer: () => null,51}));5253vi.mock("./CJKImport", () => ({54  CJKImportView: () => null,55}));5657vi.mock("./CJKExport", () => ({58  CJKExportView: () => null,59}));6061vi.mock("zxcvbn", () => ({62  default: (value: string) => zxcvbnMock(value),63}));6465describe("popup contract tests", () => {66  const disposers: Array<() => void> = [];6768  beforeEach(() => {69    document.body.innerHTML = "";70    sendMessageMock.mockReset();71    triggerRingMock.mockReset();72    triggerPipPulseMock.mockReset();73    exportEntriesToPeachJsonMock.mockReset();74    parseImportSourceMock.mockReset();75    zxcvbnMock.mockReset();76    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));77    parseImportSourceMock.mockResolvedValue({78      format: "unknown",79      label: "Unknown format",80      entries: [],81      totalItems: 0,82      skippedItems: 0,83      requiresPassword: false,84    });8586    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {87      return window.setTimeout(() => cb(0), 0) as unknown as number;88    });89    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));90    vi.stubGlobal("URL", {91      createObjectURL: vi.fn(() => "blob:peach-test"),92      revokeObjectURL: vi.fn(),93    });94    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9596    (globalThis as { chrome?: typeof chrome }).chrome = {97      runtime: {98        getManifest: () => ({ version: "1.4.47" }),99        onMessage: { addListener: vi.fn() },100      },101    } as unknown as typeof chrome;102  });103104  afterEach(() => {105    while (disposers.length > 0) {106      disposers.pop()?.();107    }108    vi.unstubAllGlobals();109  });110111  function mount(view: JSX.Element) {112    const mounted = renderView(view);113    disposers.push(mounted.dispose);114    return mounted.container;115  }116117  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {118    return {119      id: "entry-1",120      type: "login",121      title: "GitHub",122      username: "june",123      password: "secret123",124      url: "https://github.com",125      totp: "JBSWY3DPEHPK3PXP",126      canary: {127        webhookUrl: "https://example.com/hook",128      },129      createdAt: 1,130      modifiedAt: 1,131      ...overrides,132    };133  }134135  function sampleImportResult(136    overrides: Partial<ImportParseResult> = {}137  ): ImportParseResult {138    return {139      format: "csv",140      label: "CSV import",141      entries: [142        {143          type: "login",144          title: "Imported site",145          username: "imported-user",146          password: "imported-pass",147          url: "https://imported.test",148        },149      ],150      totalItems: 1,151      skippedItems: 0,152      requiresPassword: false,153      ...overrides,154    };155  }156157  async function selectImportFile(158    container: HTMLElement,159    contents = '{"export":"test"}'160  ) {161    const input = container.querySelector('input[type="file"]') as HTMLInputElement;162    const file = new File([contents], "import.json", { type: "application/json" });163    Object.defineProperty(input, "files", {164      configurable: true,165      value: [file],166    });167    input.dispatchEvent(new Event("change", { bubbles: true }));168    await flushView();169    await flushView();170  }171172  function findButton(container: HTMLElement, text: string): HTMLButtonElement {173    return Array.from(container.querySelectorAll("button")).find(174      (candidate) => candidate.textContent?.trim() === text175    ) as HTMLButtonElement;176  }177178  it("Lock sends UNLOCK with the entered passphrase", async () => {179    sendMessageMock.mockResolvedValue({ ok: true });180    const onUnlock = vi.fn(async () => undefined);181    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);182183    const password = container.querySelector("#unlock-password") as HTMLInputElement;184    inputText(password, "correct horse battery staple");185    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();186    await flushView();187188    expect(sendMessageMock).toHaveBeenCalledWith({189      type: "UNLOCK",190      payload: { password: "correct horse battery staple" },191    });192    expect(onUnlock).toHaveBeenCalled();193  });194195  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {196    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);197    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;198    const password = container.querySelector("#master-password") as HTMLInputElement;199    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;200201    inputText(password, "short");202    inputText(confirm, "short");203    await flushView();204    expect(createButton.disabled).toBe(true);205206    zxcvbnMock.mockImplementation(() => ({ score: 1 }));207    inputText(password, "weakweakweak");208    inputText(confirm, "weakweakweak");209    await flushView();210    expect(createButton.disabled).toBe(true);211  });212213  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {214    sendMessageMock.mockResolvedValue({ ok: true });215    zxcvbnMock.mockImplementation(() => ({ score: 3 }));216    const onUnlock = vi.fn(async () => undefined);217    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);218219    const password = container.querySelector("#master-password") as HTMLInputElement;220    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;221    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;222223    inputText(password, "correct horse battery staple");224    inputText(confirm, "correct horse battery staple");225    await flushView();226    expect(createButton.disabled).toBe(false);227228    pressEnter(confirm);229    await flushView();230231    expect(sendMessageMock).toHaveBeenCalledWith({232      type: "CREATE_VAULT",233      payload: { password: "correct horse battery staple" },234    });235    expect(onUnlock).toHaveBeenCalled();236  });237238  it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {239    const container = mount(<ExportView onBack={vi.fn()} />);240241    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;242    const button = Array.from(container.querySelectorAll("button")).find(243      (candidate) => candidate.textContent?.trim() === "Download backup file"244    ) as HTMLButtonElement;245246    inputText(password, "too-short");247    await flushView();248249    expect(button.disabled).toBe(true);250    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();251  });252253  it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {254    sendMessageMock.mockResolvedValue({255      ok: true,256      data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],257    });258    exportEntriesToPeachJsonMock.mockResolvedValue({259      version: 2,260      type: "peach-export",261    });262    const container = mount(<ExportView onBack={vi.fn()} />);263264    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;265    const button = Array.from(container.querySelectorAll("button")).find(266      (candidate) => candidate.textContent?.trim() === "Download backup file"267    ) as HTMLButtonElement;268269    inputText(password, "correct horse battery staple");270    await flushView();271    button.click();272    await flushView();273274    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });275    expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(276      [277        expect.objectContaining({ id: "entry-1" }),278        expect.objectContaining({ id: "deleted", deletedAt: 2 }),279      ],280      "correct horse battery staple"281    );282  });283284  it("Settings requires current, new, and confirm passwords before enabling save", async () => {285    sendMessageMock.mockResolvedValue({ ok: true, data: null });286    const container = mount(287      <SettingsView288        entriesCount={1}289        breachMonitoring={true}290        lockOnClose={true}291        autoLockMinutes={5}292        trashRetentionDays={30}293        theme="dark"294        accentColor="peach"295        onBreachMonitoringChange={vi.fn()}296        onLockOnCloseChange={vi.fn()}297        onAutoLockMinutesChange={vi.fn()}298        onTrashRetentionChange={vi.fn()}299        onThemeChange={vi.fn()}300        onAccentChange={vi.fn()}301        onBack={vi.fn()}302        onImport={vi.fn()}303        onExport={vi.fn()}304        onBreachCheckDone={vi.fn()}305      />306    );307    await flushView();308309    const changeButton = Array.from(container.querySelectorAll("button")).find(310      (candidate) => candidate.textContent?.trim() === "change"311    ) as HTMLButtonElement;312    changeButton.click();313    await flushView();314315    const saveButton = Array.from(container.querySelectorAll("button")).find(316      (candidate) => candidate.textContent?.trim() === "save new password"317    ) as HTMLButtonElement;318    expect(saveButton.disabled).toBe(true);319320    const inputs = container.querySelectorAll('input[type="password"]');321    inputText(inputs[0] as HTMLInputElement, "current-pass");322    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");323    await flushView();324    expect(saveButton.disabled).toBe(true);325326    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");327    await flushView();328    expect(saveButton.disabled).toBe(false);329  });330331  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {332    sendMessageMock.mockResolvedValue({ ok: true, data: null });333    const onAutoLockMinutesChange = vi.fn();334    const onThemeChange = vi.fn();335    const container = mount(336      <SettingsView337        entriesCount={1}338        breachMonitoring={true}339        lockOnClose={true}340        autoLockMinutes={5}341        trashRetentionDays={30}342        theme="dark"343        accentColor="peach"344        onBreachMonitoringChange={vi.fn()}345        onLockOnCloseChange={vi.fn()}346        onAutoLockMinutesChange={onAutoLockMinutesChange}347        onTrashRetentionChange={vi.fn()}348        onThemeChange={onThemeChange}349        onAccentChange={vi.fn()}350        onBack={vi.fn()}351        onImport={vi.fn()}352        onExport={vi.fn()}353        onBreachCheckDone={vi.fn()}354      />355    );356    await flushView();357    sendMessageMock.mockClear();358359    const selects = container.querySelectorAll("select");360    changeSelect(selects[0] as HTMLSelectElement, "15");361    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);362363    const themeButton = Array.from(container.querySelectorAll("button")).find(364      (candidate) => candidate.textContent?.trim() === "Switch to light"365    ) as HTMLButtonElement;366    themeButton.click();367    expect(onThemeChange).toHaveBeenCalledWith("light");368369    const changeButton = Array.from(container.querySelectorAll("button")).find(370      (candidate) => candidate.textContent?.trim() === "change"371    ) as HTMLButtonElement;372    changeButton.click();373    await flushView();374375    const inputs = container.querySelectorAll('input[type="password"]');376    inputText(inputs[0] as HTMLInputElement, "current-pass");377    inputText(inputs[1] as HTMLInputElement, "new-password-123");378    inputText(inputs[2] as HTMLInputElement, "new-password-123");379    await flushView();380381    const saveButton = Array.from(container.querySelectorAll("button")).find(382      (candidate) => candidate.textContent?.trim() === "save new password"383    ) as HTMLButtonElement;384    saveButton.click();385    await flushView();386387    expect(sendMessageMock).toHaveBeenCalledWith({388      type: "CHANGE_PASSWORD",389      payload: {390        currentPassword: "current-pass",391        newPassword: "new-password-123",392      },393    });394  });395396  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {397    sendMessageMock.mockResolvedValue({ ok: true });398    const container = mount(399      <EntryView400        entry={sampleEntry()}401        onDelete={vi.fn()}402        onFieldSaved={vi.fn(async () => undefined)}403      />404    );405406    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;407    passwordButton.click();408    await flushView();409410    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {411      type: "COPY",412      payload: { text: "secret123", label: "password" },413    });414    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {415      type: "FIRE_CANARY_PING",416      payload: {417        webhookUrl: "https://example.com/hook",418        entryTitle: "GitHub",419        trigger: "password_copied",420      },421    });422423    sendMessageMock.mockClear();424    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;425    usernameButton.click();426    await flushView();427428    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {429      type: "COPY",430      payload: { text: "june", label: "username" },431    });432    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {433      type: "FIRE_CANARY_PING",434      payload: {435        webhookUrl: "https://example.com/hook",436        entryTitle: "GitHub",437        trigger: "username_copied",438      },439    });440  });441442  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {443    sendMessageMock.mockResolvedValue({ ok: true });444    const onFieldSaved = vi.fn(async () => undefined);445    const container = mount(446      <EntryView447        entry={sampleEntry()}448        onDelete={vi.fn()}449        onFieldSaved={onFieldSaved}450      />451    );452453    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;454    passwordValue.click();455    await flushView();456457    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;458    inputText(passwordInput, "");459    await flushView();460461    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;462    saveButton.click();463    await flushView();464465    expect(sendMessageMock).not.toHaveBeenCalledWith(466      expect.objectContaining({ type: "UPDATE_ENTRY" })467    );468    expect(onFieldSaved).not.toHaveBeenCalled();469470    const totpButton = container.querySelector('button[aria-label="Copy TOTP code"]') as HTMLButtonElement;471    totpButton.click();472    await flushView();473474    expect(sendMessageMock).toHaveBeenLastCalledWith({475      type: "COPY",476      payload: { text: "123456", label: "totp" },477    });478  });479480  it("Entry closes an unchanged inline editor when focus leaves it", async () => {481    sendMessageMock.mockResolvedValue({ ok: true });482    const container = mount(483      <EntryView484        entry={sampleEntry()}485        onDelete={vi.fn()}486        onFieldSaved={vi.fn(async () => undefined)}487      />488    );489490    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;491    usernameValue.click();492    await flushView();493494    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;495    usernameInput.blur();496    await flushView();497498    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();499    expect(sendMessageMock).not.toHaveBeenCalledWith(500      expect.objectContaining({ type: "UPDATE_ENTRY" })501    );502  });503504  it("Entry lets TOTP secrets be edited inline", async () => {505    sendMessageMock.mockResolvedValue({ ok: true });506    const onFieldSaved = vi.fn(async () => undefined);507    const container = mount(508      <EntryView509        entry={sampleEntry()}510        onDelete={vi.fn()}511        onFieldSaved={onFieldSaved}512      />513    );514515    const totpMain = container.querySelector(".peach-totp-main.peach-inline-clickable") as HTMLDivElement;516    totpMain.click();517    await flushView();518519    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;520    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");521    await flushView();522523    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;524    saveButton.click();525    await flushView();526527    expect(sendMessageMock).toHaveBeenCalledWith({528      type: "UPDATE_ENTRY",529      payload: {530        id: "entry-1",531        patch: { totp: "NB2W45DFOIZA====" },532      },533    });534    expect(onFieldSaved).toHaveBeenCalled();535  });536537  it("Vault seeds and focuses search when a search seed is provided", async () => {538    const container = mount(539      <VaultView540        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}541        sortOrder="url-first"542        searchSeed="g"543        searchSeedNonce={1}544        onSelectEntry={vi.fn()}545        onSortOrderChange={vi.fn()}546        onAdd={vi.fn()}547        onClose={vi.fn()}548      />549    );550551    await flushView();552553    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;554    expect(search.value).toBe("g");555    expect(document.activeElement).toBe(search);556  });557558  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {559    parseImportSourceMock.mockResolvedValue(560      sampleImportResult({561        entries: [562          {563            type: "login",564            title: "Imported site",565            username: "imported-user",566            password: "imported-pass",567            url: "https://imported.test",568          },569        ],570      })571    );572    sendMessageMock.mockImplementation(async (msg: unknown) => {573      const type = (msg as { type?: string }).type;574      if (type === "GET_ENTRIES") {575        return {576          ok: true,577          data: [578            sampleEntry({579              id: "existing-1",580              title: "Imported site",581              username: "imported-user",582              url: "https://imported.test",583            }),584          ],585        };586      }587      if (type === "BATCH_ADD_ENTRIES") {588        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };589      }590      return { ok: true };591    });592    const onImport = vi.fn(async () => undefined);593    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);594    await flushView();595    sendMessageMock.mockClear();596597    await selectImportFile(container);598    findButton(container, "Import 1").click();599    await flushView();600601    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);602    expect(sendMessageMock).toHaveBeenCalledWith({603      type: "BATCH_ADD_ENTRIES",604      payload: {605        entries: [606          expect.objectContaining({607            title: "Imported site",608            username: "imported-user",609            password: "imported-pass",610            url: "https://imported.test",611          }),612        ],613      },614    });615    expect(onImport).toHaveBeenCalled();616    expect(container.textContent).toContain("1 entry appears to be duplicates");617  });618619  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {620    parseImportSourceMock.mockResolvedValue(sampleImportResult());621    sendMessageMock.mockImplementation(async (msg: unknown) => {622      const type = (msg as { type?: string }).type;623      if (type === "GET_ENTRIES") {624        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };625      }626      if (type === "CLEAR_VAULT_ENTRIES") {627        return { ok: true };628      }629      if (type === "BATCH_ADD_ENTRIES") {630        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };631      }632      return { ok: true };633    });634    const onImport = vi.fn(async () => undefined);635    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);636    await flushView();637    sendMessageMock.mockClear();638639    await selectImportFile(container);640    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();641    await flushView();642643    findButton(container, "Replace vault with 1").click();644    await flushView();645646    expect(sendMessageMock).not.toHaveBeenCalledWith(647      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })648    );649    expect(container.textContent).toContain("Replace forever? This cannot be undone.");650651    findButton(container, "Yes, replace forever").click();652    await flushView();653654    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });655    expect(sendMessageMock).toHaveBeenCalledWith({656      type: "BATCH_ADD_ENTRIES",657      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },658    });659    expect(onImport).toHaveBeenCalled();660  });661662  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {663    parseImportSourceMock.mockResolvedValue(sampleImportResult());664    sendMessageMock.mockImplementation(async (msg: unknown) => {665      const type = (msg as { type?: string }).type;666      if (type === "CREATE_VAULT") return { ok: true };667      if (type === "BATCH_ADD_ENTRIES") {668        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };669      }670      return { ok: true };671    });672    const onImport = vi.fn(async () => undefined);673    const container = mount(674      <ImportView675        mode="onboarding"676        replacesExistingVault={true}677        onBack={vi.fn()}678        onImport={onImport}679      />680    );681682    await selectImportFile(container);683684    const createButton = findButton(container, "Create vault + import 1");685    expect(createButton.disabled).toBe(true);686    expect(container.textContent).toContain("Restoring now replaces the locked local vault");687688    const inputs = container.querySelectorAll('input[type="password"]');689    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");690    inputText(inputs[1] as HTMLInputElement, "fresh-master-password");691    await flushView();692693    expect(createButton.disabled).toBe(false);694    createButton.click();695    await flushView();696697    expect(sendMessageMock).toHaveBeenCalledWith({698      type: "CREATE_VAULT",699      payload: { password: "fresh-master-password" },700    });701    expect(sendMessageMock).toHaveBeenCalledWith({702      type: "BATCH_ADD_ENTRIES",703      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },704    });705    expect(onImport).toHaveBeenCalled();706  });707708  it("Import encrypted backups reparse with the backup password before import", async () => {709    parseImportSourceMock710      .mockResolvedValueOnce(711        sampleImportResult({712          format: "peach-json",713          label: "Peach encrypted backup",714          entries: [],715          totalItems: 0,716          requiresPassword: true,717        })718      )719      .mockResolvedValueOnce(720        sampleImportResult({721          format: "peach-json",722          label: "Peach encrypted backup",723        })724      );725    sendMessageMock.mockImplementation(async (msg: unknown) => {726      const type = (msg as { type?: string }).type;727      if (type === "GET_ENTRIES") return { ok: true, data: [] };728      if (type === "BATCH_ADD_ENTRIES") {729        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };730      }731      return { ok: true };732    });733    const onImport = vi.fn(async () => undefined);734    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);735    await flushView();736    sendMessageMock.mockClear();737738    await selectImportFile(container, '{"type":"peach-export"}');739    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;740    inputText(password, "backup-passphrase");741    pressEnter(password);742    await flushView();743    await flushView();744745    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);746    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");747748    findButton(container, "Import 1").click();749    await flushView();750751    expect(sendMessageMock).toHaveBeenCalledWith({752      type: "BATCH_ADD_ENTRIES",753      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },754    });755    expect(onImport).toHaveBeenCalled();756  });757});