contracts.test.tsx

26.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 confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;243    const button = Array.from(container.querySelectorAll("button")).find(244      (candidate) => candidate.textContent?.trim() === "Download backup file"245    ) as HTMLButtonElement;246247    inputText(password, "too-short");248    inputText(confirm, "too-short");249    await flushView();250251    expect(button.disabled).toBe(true);252    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();253  });254255  it("Export keeps encrypted export disabled when passwords do not match", async () => {256    const container = mount(<ExportView onBack={vi.fn()} />);257258    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;259    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;260    const button = Array.from(container.querySelectorAll("button")).find(261      (candidate) => candidate.textContent?.trim() === "Download backup file"262    ) as HTMLButtonElement;263264    inputText(password, "correct horse battery staple");265    inputText(confirm, "different horse battery staple");266    await flushView();267268    expect(button.disabled).toBe(true);269    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();270  });271272  it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {273    sendMessageMock.mockResolvedValue({274      ok: true,275      data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],276    });277    exportEntriesToPeachJsonMock.mockResolvedValue({278      version: 2,279      type: "peach-export",280    });281    const container = mount(<ExportView onBack={vi.fn()} />);282283    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;284    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;285    const button = Array.from(container.querySelectorAll("button")).find(286      (candidate) => candidate.textContent?.trim() === "Download backup file"287    ) as HTMLButtonElement;288289    inputText(password, "correct horse battery staple");290    inputText(confirm, "correct horse battery staple");291    await flushView();292    button.click();293    await flushView();294295    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });296    expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(297      [298        expect.objectContaining({ id: "entry-1" }),299        expect.objectContaining({ id: "deleted", deletedAt: 2 }),300      ],301      "correct horse battery staple"302    );303  });304305  it("Settings requires current, new, and confirm passwords before enabling save", async () => {306    sendMessageMock.mockResolvedValue({ ok: true, data: null });307    const container = mount(308      <SettingsView309        entriesCount={1}310        breachMonitoring={true}311        lockOnClose={true}312        autoLockMinutes={5}313        trashRetentionDays={30}314        theme="dark"315        accentColor="peach"316        onBreachMonitoringChange={vi.fn()}317        onLockOnCloseChange={vi.fn()}318        onAutoLockMinutesChange={vi.fn()}319        onTrashRetentionChange={vi.fn()}320        onThemeChange={vi.fn()}321        onAccentChange={vi.fn()}322        onBack={vi.fn()}323        onImport={vi.fn()}324        onExport={vi.fn()}325        onBreachCheckDone={vi.fn()}326      />327    );328    await flushView();329330    const changeButton = Array.from(container.querySelectorAll("button")).find(331      (candidate) => candidate.textContent?.trim() === "change"332    ) as HTMLButtonElement;333    changeButton.click();334    await flushView();335336    const saveButton = Array.from(container.querySelectorAll("button")).find(337      (candidate) => candidate.textContent?.trim() === "save new password"338    ) as HTMLButtonElement;339    expect(saveButton.disabled).toBe(true);340341    const inputs = container.querySelectorAll('input[type="password"]');342    inputText(inputs[0] as HTMLInputElement, "current-pass");343    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");344    await flushView();345    expect(saveButton.disabled).toBe(true);346347    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");348    await flushView();349    expect(saveButton.disabled).toBe(false);350  });351352  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {353    sendMessageMock.mockResolvedValue({ ok: true, data: null });354    const onAutoLockMinutesChange = vi.fn();355    const onThemeChange = vi.fn();356    const container = mount(357      <SettingsView358        entriesCount={1}359        breachMonitoring={true}360        lockOnClose={true}361        autoLockMinutes={5}362        trashRetentionDays={30}363        theme="dark"364        accentColor="peach"365        onBreachMonitoringChange={vi.fn()}366        onLockOnCloseChange={vi.fn()}367        onAutoLockMinutesChange={onAutoLockMinutesChange}368        onTrashRetentionChange={vi.fn()}369        onThemeChange={onThemeChange}370        onAccentChange={vi.fn()}371        onBack={vi.fn()}372        onImport={vi.fn()}373        onExport={vi.fn()}374        onBreachCheckDone={vi.fn()}375      />376    );377    await flushView();378    sendMessageMock.mockClear();379380    const selects = container.querySelectorAll("select");381    changeSelect(selects[0] as HTMLSelectElement, "15");382    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);383384    const themeButton = Array.from(container.querySelectorAll("button")).find(385      (candidate) => candidate.textContent?.trim() === "Switch to light"386    ) as HTMLButtonElement;387    themeButton.click();388    expect(onThemeChange).toHaveBeenCalledWith("light");389390    const changeButton = Array.from(container.querySelectorAll("button")).find(391      (candidate) => candidate.textContent?.trim() === "change"392    ) as HTMLButtonElement;393    changeButton.click();394    await flushView();395396    const inputs = container.querySelectorAll('input[type="password"]');397    inputText(inputs[0] as HTMLInputElement, "current-pass");398    inputText(inputs[1] as HTMLInputElement, "new-password-123");399    inputText(inputs[2] as HTMLInputElement, "new-password-123");400    await flushView();401402    const saveButton = Array.from(container.querySelectorAll("button")).find(403      (candidate) => candidate.textContent?.trim() === "save new password"404    ) as HTMLButtonElement;405    saveButton.click();406    await flushView();407408    expect(sendMessageMock).toHaveBeenCalledWith({409      type: "CHANGE_PASSWORD",410      payload: {411        currentPassword: "current-pass",412        newPassword: "new-password-123",413      },414    });415  });416417  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {418    sendMessageMock.mockResolvedValue({ ok: true });419    const container = mount(420      <EntryView421        entry={sampleEntry()}422        onDelete={vi.fn()}423        onFieldSaved={vi.fn(async () => undefined)}424      />425    );426427    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;428    passwordButton.click();429    await flushView();430431    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {432      type: "COPY",433      payload: { text: "secret123", label: "password" },434    });435    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {436      type: "FIRE_CANARY_PING",437      payload: {438        webhookUrl: "https://example.com/hook",439        entryTitle: "GitHub",440        trigger: "password_copied",441      },442    });443444    sendMessageMock.mockClear();445    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;446    usernameButton.click();447    await flushView();448449    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {450      type: "COPY",451      payload: { text: "june", label: "username" },452    });453    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {454      type: "FIRE_CANARY_PING",455      payload: {456        webhookUrl: "https://example.com/hook",457        entryTitle: "GitHub",458        trigger: "username_copied",459      },460    });461  });462463  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {464    sendMessageMock.mockResolvedValue({ ok: true });465    const onFieldSaved = vi.fn(async () => undefined);466    const container = mount(467      <EntryView468        entry={sampleEntry()}469        onDelete={vi.fn()}470        onFieldSaved={onFieldSaved}471      />472    );473474    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;475    passwordValue.click();476    await flushView();477478    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;479    inputText(passwordInput, "");480    await flushView();481482    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;483    saveButton.click();484    await flushView();485486    expect(sendMessageMock).not.toHaveBeenCalledWith(487      expect.objectContaining({ type: "UPDATE_ENTRY" })488    );489    expect(onFieldSaved).not.toHaveBeenCalled();490491    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;492    totpMain.click();493    await flushView();494495    expect(sendMessageMock).toHaveBeenLastCalledWith({496      type: "COPY",497      payload: { text: "123456", label: "totp" },498    });499  });500501  it("Entry closes an unchanged inline editor when focus leaves it", async () => {502    sendMessageMock.mockResolvedValue({ ok: true });503    const container = mount(504      <EntryView505        entry={sampleEntry()}506        onDelete={vi.fn()}507        onFieldSaved={vi.fn(async () => undefined)}508      />509    );510511    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;512    usernameValue.click();513    await flushView();514515    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;516    usernameInput.blur();517    await flushView();518519    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();520    expect(sendMessageMock).not.toHaveBeenCalledWith(521      expect.objectContaining({ type: "UPDATE_ENTRY" })522    );523  });524525  it("Entry lets TOTP secrets be edited inline", async () => {526    sendMessageMock.mockResolvedValue({ ok: true });527    const onFieldSaved = vi.fn(async () => undefined);528    const container = mount(529      <EntryView530        entry={sampleEntry()}531        onDelete={vi.fn()}532        onFieldSaved={onFieldSaved}533      />534    );535536    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;537    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));538    await flushView();539540    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;541    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");542    await flushView();543544    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;545    saveButton.click();546    await flushView();547548    expect(sendMessageMock).toHaveBeenCalledWith({549      type: "UPDATE_ENTRY",550      payload: {551        id: "entry-1",552        patch: { totp: "NB2W45DFOIZA====" },553      },554    });555    expect(onFieldSaved).toHaveBeenCalled();556  });557558  it("Vault seeds and focuses search when a search seed is provided", async () => {559    const container = mount(560      <VaultView561        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}562        sortOrder="url-first"563        searchSeed="g"564        searchSeedNonce={1}565        onSelectEntry={vi.fn()}566        onSortOrderChange={vi.fn()}567        onAdd={vi.fn()}568        onClose={vi.fn()}569      />570    );571572    await flushView();573574    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;575    expect(search.value).toBe("g");576    expect(document.activeElement).toBe(search);577  });578579  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {580    parseImportSourceMock.mockResolvedValue(581      sampleImportResult({582        entries: [583          {584            type: "login",585            title: "Imported site",586            username: "imported-user",587            password: "imported-pass",588            url: "https://imported.test",589          },590        ],591      })592    );593    sendMessageMock.mockImplementation(async (msg: unknown) => {594      const type = (msg as { type?: string }).type;595      if (type === "GET_ENTRIES") {596        return {597          ok: true,598          data: [599            sampleEntry({600              id: "existing-1",601              title: "Imported site",602              username: "imported-user",603              url: "https://imported.test",604            }),605          ],606        };607      }608      if (type === "BATCH_ADD_ENTRIES") {609        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };610      }611      return { ok: true };612    });613    const onImport = vi.fn(async () => undefined);614    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);615    await flushView();616    sendMessageMock.mockClear();617618    await selectImportFile(container);619    findButton(container, "Import 1").click();620    await flushView();621622    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);623    expect(sendMessageMock).toHaveBeenCalledWith({624      type: "BATCH_ADD_ENTRIES",625      payload: {626        entries: [627          expect.objectContaining({628            title: "Imported site",629            username: "imported-user",630            password: "imported-pass",631            url: "https://imported.test",632          }),633        ],634      },635    });636    expect(onImport).toHaveBeenCalled();637    expect(container.textContent).toContain("1 entry appears to be duplicates");638  });639640  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {641    parseImportSourceMock.mockResolvedValue(sampleImportResult());642    sendMessageMock.mockImplementation(async (msg: unknown) => {643      const type = (msg as { type?: string }).type;644      if (type === "GET_ENTRIES") {645        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };646      }647      if (type === "CLEAR_VAULT_ENTRIES") {648        return { ok: true };649      }650      if (type === "BATCH_ADD_ENTRIES") {651        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };652      }653      return { ok: true };654    });655    const onImport = vi.fn(async () => undefined);656    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);657    await flushView();658    sendMessageMock.mockClear();659660    await selectImportFile(container);661    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();662    await flushView();663664    findButton(container, "Replace vault with 1").click();665    await flushView();666667    expect(sendMessageMock).not.toHaveBeenCalledWith(668      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })669    );670    expect(container.textContent).toContain("Replace forever? This cannot be undone.");671672    findButton(container, "Yes, replace forever").click();673    await flushView();674675    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });676    expect(sendMessageMock).toHaveBeenCalledWith({677      type: "BATCH_ADD_ENTRIES",678      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },679    });680    expect(onImport).toHaveBeenCalled();681  });682683  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {684    parseImportSourceMock.mockResolvedValue(sampleImportResult());685    sendMessageMock.mockImplementation(async (msg: unknown) => {686      const type = (msg as { type?: string }).type;687      if (type === "CREATE_VAULT") return { ok: true };688      if (type === "BATCH_ADD_ENTRIES") {689        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };690      }691      return { ok: true };692    });693    const onImport = vi.fn(async () => undefined);694    const container = mount(695      <ImportView696        mode="onboarding"697        replacesExistingVault={true}698        onBack={vi.fn()}699        onImport={onImport}700      />701    );702703    await selectImportFile(container);704705    const createButton = findButton(container, "Create vault + import 1");706    expect(createButton.disabled).toBe(true);707    expect(container.textContent).toContain("Restoring now replaces the locked local vault");708709    const inputs = container.querySelectorAll('input[type="password"]');710    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");711    inputText(inputs[1] as HTMLInputElement, "fresh-master-password");712    await flushView();713714    expect(createButton.disabled).toBe(false);715    createButton.click();716    await flushView();717718    expect(sendMessageMock).toHaveBeenCalledWith({719      type: "CREATE_VAULT",720      payload: { password: "fresh-master-password" },721    });722    expect(sendMessageMock).toHaveBeenCalledWith({723      type: "BATCH_ADD_ENTRIES",724      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },725    });726    expect(onImport).toHaveBeenCalled();727  });728729  it("Import encrypted backups reparse with the backup password before import", async () => {730    parseImportSourceMock731      .mockResolvedValueOnce(732        sampleImportResult({733          format: "peach-json",734          label: "Peach encrypted backup",735          entries: [],736          totalItems: 0,737          requiresPassword: true,738        })739      )740      .mockResolvedValueOnce(741        sampleImportResult({742          format: "peach-json",743          label: "Peach encrypted backup",744        })745      );746    sendMessageMock.mockImplementation(async (msg: unknown) => {747      const type = (msg as { type?: string }).type;748      if (type === "GET_ENTRIES") return { ok: true, data: [] };749      if (type === "BATCH_ADD_ENTRIES") {750        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };751      }752      return { ok: true };753    });754    const onImport = vi.fn(async () => undefined);755    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);756    await flushView();757    sendMessageMock.mockClear();758759    await selectImportFile(container, '{"type":"peach-export"}');760    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;761    inputText(password, "backup-passphrase");762    pressEnter(password);763    await flushView();764    await flushView();765766    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);767    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");768769    findButton(container, "Import 1").click();770    await flushView();771772    expect(sendMessageMock).toHaveBeenCalledWith({773      type: "BATCH_ADD_ENTRIES",774      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },775    });776    expect(onImport).toHaveBeenCalled();777  });778});