contracts.test.tsx

26.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("zxcvbn", () => ({50  default: (value: string) => zxcvbnMock(value),51}));5253describe("popup contract tests", () => {54  const disposers: Array<() => void> = [];5556  beforeEach(() => {57    document.body.innerHTML = "";58    sendMessageMock.mockReset();59    triggerRingMock.mockReset();60    triggerPipPulseMock.mockReset();61    parseImportSourceMock.mockReset();62    zxcvbnMock.mockReset();63    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));64    parseImportSourceMock.mockResolvedValue({65      format: "unknown",66      label: "Unknown format",67      entries: [],68      totalItems: 0,69      skippedItems: 0,70      requiresPassword: false,71    });7273    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {74      return window.setTimeout(() => cb(0), 0) as unknown as number;75    });76    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));77    vi.stubGlobal("URL", {78      createObjectURL: vi.fn(() => "blob:peach-test"),79      revokeObjectURL: vi.fn(),80    });81    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8283    (globalThis as { chrome?: typeof chrome }).chrome = {84      runtime: {85        getManifest: () => ({ version: "1.4.47" }),86        onMessage: { addListener: vi.fn() },87      },88    } as unknown as typeof chrome;89  });9091  afterEach(() => {92    while (disposers.length > 0) {93      disposers.pop()?.();94    }95    vi.unstubAllGlobals();96  });9798  function mount(view: JSX.Element) {99    const mounted = renderView(view);100    disposers.push(mounted.dispose);101    return mounted.container;102  }103104  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {105    return {106      id: "entry-1",107      type: "login",108      title: "GitHub",109      username: "june",110      password: "secret123",111      url: "https://github.com",112      totp: "JBSWY3DPEHPK3PXP",113      canary: {114        webhookUrl: "https://example.com/hook",115      },116      createdAt: 1,117      modifiedAt: 1,118      ...overrides,119    };120  }121122  function sampleImportResult(123    overrides: Partial<ImportParseResult> = {}124  ): ImportParseResult {125    return {126      format: "csv",127      label: "CSV import",128      entries: [129        {130          type: "login",131          title: "Imported site",132          username: "imported-user",133          password: "imported-pass",134          url: "https://imported.test",135        },136      ],137      totalItems: 1,138      skippedItems: 0,139      requiresPassword: false,140      ...overrides,141    };142  }143144  async function selectImportFile(145    container: HTMLElement,146    contents = '{"export":"test"}'147  ) {148    // The Import view now opens onto a menu — click into the file subview149    // before locating the file input. Idempotent: a second click won't re-fire150    // because the menu unmounts once the subview renders.151    const fileCard = findButton(container, "Choose file →");152    if (fileCard) {153      fileCard.click();154      await flushView();155    }156    const input = container.querySelector('input[type="file"]') as HTMLInputElement;157    const file = new File([contents], "import.json", { type: "application/json" });158    Object.defineProperty(input, "files", {159      configurable: true,160      value: [file],161    });162    input.dispatchEvent(new Event("change", { bubbles: true }));163    await flushView();164    await flushView();165  }166167  function findButton(container: HTMLElement, text: string): HTMLButtonElement {168    return Array.from(container.querySelectorAll("button")).find(169      (candidate) => candidate.textContent?.trim() === text170    ) as HTMLButtonElement;171  }172173  it("Lock sends UNLOCK with the entered passphrase", async () => {174    sendMessageMock.mockResolvedValue({ ok: true });175    const onUnlock = vi.fn(async () => undefined);176    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);177178    const password = container.querySelector("#unlock-password") as HTMLInputElement;179    inputText(password, "correct horse battery staple");180    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();181    await flushView();182183    expect(sendMessageMock).toHaveBeenCalledWith({184      type: "UNLOCK",185      payload: { password: "correct horse battery staple" },186    });187    expect(onUnlock).toHaveBeenCalled();188  });189190  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {191    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);192    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;193    const password = container.querySelector("#master-password") as HTMLInputElement;194    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;195196    inputText(password, "short");197    inputText(confirm, "short");198    await flushView();199    expect(createButton.disabled).toBe(true);200201    zxcvbnMock.mockImplementation(() => ({ score: 1 }));202    inputText(password, "weakweakweak");203    inputText(confirm, "weakweakweak");204    await flushView();205    expect(createButton.disabled).toBe(true);206  });207208  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {209    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock210    sendMessageMock.mockImplementation(211      async (msg: unknown) => {212        const m = msg as { type: string; payload?: { password?: string } };213        if (m.type === "CREATE_VAULT") return { ok: true };214        if (m.type === "GET_RECOVERY_WORDS") {215          // Return 12 fake words — must pass findInvalidWords216          return {217            ok: true,218            data: [219              "abandon", "ability", "able", "about", "above", "absent",220              "absorb", "abstract", "absurd", "abuse", "access", "accident",221            ],222          };223        }224        return { ok: true };225      }226    );227    zxcvbnMock.mockImplementation(() => ({ score: 3 }));228    const onUnlock = vi.fn(async () => undefined);229    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);230231    const password = container.querySelector("#master-password") as HTMLInputElement;232    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;233    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;234235    inputText(password, "correct horse battery staple");236    inputText(confirm, "correct horse battery staple");237    await flushView();238    expect(createButton.disabled).toBe(false);239240    pressEnter(confirm);241    await flushView();242243    expect(sendMessageMock).toHaveBeenCalledWith({244      type: "CREATE_VAULT",245      payload: { password: "correct horse battery staple" },246    });247    // After CREATE_VAULT success, the recovery word display is shown.248    // onUnlock is NOT called yet — user must first confirm their recovery words.249    expect(onUnlock).not.toHaveBeenCalled();250  });251252  it("Export menu surfaces the three v2.5.0 backup formats", async () => {253    const container = mount(<ExportView onBack={vi.fn()} />);254    await flushView();255256    expect(container.textContent).toContain("PeachScript");257    expect(container.textContent).toContain("print backup");258    expect(container.textContent).toContain("CSV");259  });260261  it("Settings requires current, new, and confirm passwords before enabling save", async () => {262    sendMessageMock.mockResolvedValue({ ok: true, data: null });263    const container = mount(264      <SettingsView265        entriesCount={1}266        breachMonitoring={true}267        lockOnClose={true}268        autoLockMinutes={5}269        trashRetentionDays={30}270        theme="dark"271        accentColor="peach"272        displayFont="cormorant-italic"273        textSize="medium"274        reduceMotion={false}275        onBreachMonitoringChange={vi.fn()}276        onLockOnCloseChange={vi.fn()}277        onAutoLockMinutesChange={vi.fn()}278        onTrashRetentionChange={vi.fn()}279        onThemeChange={vi.fn()}280        onAccentChange={vi.fn()}281        onDisplayFontChange={vi.fn()}282        onTextSizeChange={vi.fn()}283        onReduceMotionChange={vi.fn()}284        shortcuts={DEFAULT_BINDINGS}285        onShortcutsChange={vi.fn()}286        onBack={vi.fn()}287        onImport={vi.fn()}288        onExport={vi.fn()}289        onBreachCheckDone={vi.fn()}290      />291    );292    await flushView();293294    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(295      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"296    ) as HTMLButtonElement;297    securityRow.click();298    await flushView();299300    const changeButton = Array.from(container.querySelectorAll("button")).find(301      (candidate) => candidate.textContent?.trim() === "change"302    ) as HTMLButtonElement;303    changeButton.click();304    await flushView();305306    const saveButton = Array.from(container.querySelectorAll("button")).find(307      (candidate) => candidate.textContent?.trim() === "save new password"308    ) as HTMLButtonElement;309    expect(saveButton.disabled).toBe(true);310311    const inputs = container.querySelectorAll('input[type="password"]');312    inputText(inputs[0] as HTMLInputElement, "current-pass");313    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");314    await flushView();315    expect(saveButton.disabled).toBe(true);316317    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");318    await flushView();319    expect(saveButton.disabled).toBe(false);320  });321322  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {323    sendMessageMock.mockResolvedValue({ ok: true, data: null });324    const onAutoLockMinutesChange = vi.fn();325    const onThemeChange = vi.fn();326    const container = mount(327      <SettingsView328        entriesCount={1}329        breachMonitoring={true}330        lockOnClose={true}331        autoLockMinutes={5}332        trashRetentionDays={30}333        theme="dark"334        accentColor="peach"335        displayFont="cormorant-italic"336        textSize="medium"337        reduceMotion={false}338        onBreachMonitoringChange={vi.fn()}339        onLockOnCloseChange={vi.fn()}340        onAutoLockMinutesChange={onAutoLockMinutesChange}341        onTrashRetentionChange={vi.fn()}342        onThemeChange={onThemeChange}343        onAccentChange={vi.fn()}344        onDisplayFontChange={vi.fn()}345        onTextSizeChange={vi.fn()}346        onReduceMotionChange={vi.fn()}347        shortcuts={DEFAULT_BINDINGS}348        onShortcutsChange={vi.fn()}349        onBack={vi.fn()}350        onImport={vi.fn()}351        onExport={vi.fn()}352        onBreachCheckDone={vi.fn()}353      />354    );355    await flushView();356    sendMessageMock.mockClear();357358    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;359360    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(361      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"362    ) as HTMLButtonElement;363    securityRow.click();364    await flushView();365366    const selects = container.querySelectorAll("select");367    changeSelect(selects[0] as HTMLSelectElement, "15");368    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);369370    const changeButton = Array.from(container.querySelectorAll("button")).find(371      (candidate) => candidate.textContent?.trim() === "change"372    ) as HTMLButtonElement;373    changeButton.click();374    await flushView();375376    const inputs = container.querySelectorAll('input[type="password"]');377    inputText(inputs[0] as HTMLInputElement, "current-pass");378    inputText(inputs[1] as HTMLInputElement, "new-password-123");379    inputText(inputs[2] as HTMLInputElement, "new-password-123");380    await flushView();381382    const saveButton = Array.from(container.querySelectorAll("button")).find(383      (candidate) => candidate.textContent?.trim() === "save new password"384    ) as HTMLButtonElement;385    saveButton.click();386    await flushView();387388    headerBack.click();389    await flushView();390    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(391      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"392    ) as HTMLButtonElement;393    appearanceRow.click();394    await flushView();395    const themeButton = Array.from(container.querySelectorAll("button")).find(396      (candidate) => candidate.textContent?.trim() === "Switch to light"397    ) as HTMLButtonElement;398    themeButton.click();399    expect(onThemeChange).toHaveBeenCalledWith("light");400401    expect(sendMessageMock).toHaveBeenCalledWith({402      type: "CHANGE_PASSWORD",403      payload: {404        currentPassword: "current-pass",405        newPassword: "new-password-123",406      },407    });408  });409410  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {411    sendMessageMock.mockResolvedValue({ ok: true });412    const container = mount(413      <EntryView414        entry={sampleEntry()}415        onDelete={vi.fn()}416        onFieldSaved={vi.fn(async () => undefined)}417      />418    );419420    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;421    passwordButton.click();422    await flushView();423424    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {425      type: "COPY",426      payload: { text: "secret123", label: "password", alreadyWritten: false },427    });428    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {429      type: "FIRE_CANARY_PING",430      payload: {431        webhookUrl: "https://example.com/hook",432        entryTitle: "GitHub",433        trigger: "password_copied",434      },435    });436437    sendMessageMock.mockClear();438    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;439    usernameButton.click();440    await flushView();441442    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {443      type: "COPY",444      payload: { text: "june", label: "username", alreadyWritten: false },445    });446    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {447      type: "FIRE_CANARY_PING",448      payload: {449        webhookUrl: "https://example.com/hook",450        entryTitle: "GitHub",451        trigger: "username_copied",452      },453    });454  });455456  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {457    sendMessageMock.mockResolvedValue({ ok: true });458    const onFieldSaved = vi.fn(async () => undefined);459    const container = mount(460      <EntryView461        entry={sampleEntry()}462        onDelete={vi.fn()}463        onFieldSaved={onFieldSaved}464      />465    );466467    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;468    passwordValue.click();469    await flushView();470471    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;472    inputText(passwordInput, "");473    await flushView();474475    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;476    saveButton.click();477    await flushView();478479    expect(sendMessageMock).not.toHaveBeenCalledWith(480      expect.objectContaining({ type: "UPDATE_ENTRY" })481    );482    expect(onFieldSaved).not.toHaveBeenCalled();483484    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;485    totpMain.click();486    await flushView();487488    expect(sendMessageMock).toHaveBeenLastCalledWith({489      type: "COPY",490      payload: { text: "123456", label: "totp", alreadyWritten: false },491    });492  });493494  it("Entry closes an unchanged inline editor when focus leaves it", async () => {495    sendMessageMock.mockResolvedValue({ ok: true });496    const container = mount(497      <EntryView498        entry={sampleEntry()}499        onDelete={vi.fn()}500        onFieldSaved={vi.fn(async () => undefined)}501      />502    );503504    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;505    usernameValue.click();506    await flushView();507508    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;509    usernameInput.blur();510    await flushView();511512    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();513    expect(sendMessageMock).not.toHaveBeenCalledWith(514      expect.objectContaining({ type: "UPDATE_ENTRY" })515    );516  });517518  it("Entry lets TOTP secrets be edited inline", async () => {519    sendMessageMock.mockResolvedValue({ ok: true });520    const onFieldSaved = vi.fn(async () => undefined);521    const container = mount(522      <EntryView523        entry={sampleEntry()}524        onDelete={vi.fn()}525        onFieldSaved={onFieldSaved}526      />527    );528529    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;530    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));531    await flushView();532533    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;534    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");535    await flushView();536537    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;538    saveButton.click();539    await flushView();540541    expect(sendMessageMock).toHaveBeenCalledWith({542      type: "UPDATE_ENTRY",543      payload: {544        id: "entry-1",545        patch: { totp: "NB2W45DFOIZA====" },546      },547    });548    expect(onFieldSaved).toHaveBeenCalled();549  });550551  it("Vault seeds and focuses search when a search seed is provided", async () => {552    const container = mount(553      <VaultView554        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}555        sortOrder="url-first"556        searchSeed="g"557        searchSeedNonce={1}558        onSelectEntry={vi.fn()}559        onSortOrderChange={vi.fn()}560        onAdd={vi.fn()}561        onClose={vi.fn()}562      />563    );564565    await flushView();566567    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;568    expect(search.value).toBe("g");569    expect(document.activeElement).toBe(search);570  });571572  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {573    parseImportSourceMock.mockResolvedValue(574      sampleImportResult({575        entries: [576          {577            type: "login",578            title: "Imported site",579            username: "imported-user",580            password: "imported-pass",581            url: "https://imported.test",582          },583        ],584      })585    );586    sendMessageMock.mockImplementation(async (msg: unknown) => {587      const type = (msg as { type?: string }).type;588      if (type === "GET_ENTRIES") {589        return {590          ok: true,591          data: [592            sampleEntry({593              id: "existing-1",594              title: "Imported site",595              username: "imported-user",596              url: "https://imported.test",597            }),598          ],599        };600      }601      if (type === "BATCH_ADD_ENTRIES") {602        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };603      }604      return { ok: true };605    });606    const onImport = vi.fn(async () => undefined);607    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);608    await flushView();609    sendMessageMock.mockClear();610611    await selectImportFile(container);612    findButton(container, "Import 1").click();613    await flushView();614615    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);616    expect(sendMessageMock).toHaveBeenCalledWith({617      type: "BATCH_ADD_ENTRIES",618      payload: {619        entries: [620          expect.objectContaining({621            title: "Imported site",622            username: "imported-user",623            password: "imported-pass",624            url: "https://imported.test",625          }),626        ],627      },628    });629    expect(onImport).toHaveBeenCalled();630    expect(container.textContent).toContain("1 entry appears to be duplicates");631  });632633  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {634    parseImportSourceMock.mockResolvedValue(sampleImportResult());635    sendMessageMock.mockImplementation(async (msg: unknown) => {636      const type = (msg as { type?: string }).type;637      if (type === "GET_ENTRIES") {638        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };639      }640      if (type === "CLEAR_VAULT_ENTRIES") {641        return { ok: true };642      }643      if (type === "BATCH_ADD_ENTRIES") {644        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };645      }646      return { ok: true };647    });648    const onImport = vi.fn(async () => undefined);649    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);650    await flushView();651    sendMessageMock.mockClear();652653    await selectImportFile(container);654    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();655    await flushView();656657    findButton(container, "Replace vault with 1").click();658    await flushView();659660    expect(sendMessageMock).not.toHaveBeenCalledWith(661      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })662    );663    expect(container.textContent).toContain("Replace forever? This cannot be undone.");664665    findButton(container, "Yes, replace forever").click();666    await flushView();667668    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });669    expect(sendMessageMock).toHaveBeenCalledWith({670      type: "BATCH_ADD_ENTRIES",671      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },672    });673    expect(onImport).toHaveBeenCalled();674  });675676  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {677    parseImportSourceMock.mockResolvedValue(sampleImportResult());678    sendMessageMock.mockImplementation(async (msg: unknown) => {679      const type = (msg as { type?: string }).type;680      if (type === "CREATE_VAULT") return { ok: true };681      if (type === "BATCH_ADD_ENTRIES") {682        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };683      }684      return { ok: true };685    });686    const onImport = vi.fn(async () => undefined);687    const container = mount(688      <ImportView689        mode="onboarding"690        replacesExistingVault={true}691        onBack={vi.fn()}692        onImport={onImport}693      />694    );695696    await selectImportFile(container);697698    const createButton = findButton(container, "Create vault + import 1");699    expect(createButton.disabled).toBe(true);700    expect(container.textContent).toContain("Restoring now replaces the locked local vault");701702    const masterInput = container.querySelector(703      'input[placeholder="new master password"]'704    ) as HTMLInputElement;705    const confirmInput = container.querySelector(706      'input[placeholder="confirm master password"]'707    ) as HTMLInputElement;708    inputText(masterInput, "fresh-master-password");709    inputText(confirmInput, "fresh-master-password");710    await flushView();711712    expect(createButton.disabled).toBe(false);713    createButton.click();714    await flushView();715716    expect(sendMessageMock).toHaveBeenCalledWith({717      type: "CREATE_VAULT",718      payload: { password: "fresh-master-password" },719    });720    expect(sendMessageMock).toHaveBeenCalledWith({721      type: "BATCH_ADD_ENTRIES",722      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },723    });724    expect(onImport).toHaveBeenCalled();725  });726727  it("Import encrypted backups reparse with the backup password before import", async () => {728    parseImportSourceMock729      .mockResolvedValueOnce(730        sampleImportResult({731          format: "peach-json",732          label: "Peach encrypted backup",733          entries: [],734          totalItems: 0,735          requiresPassword: true,736        })737      )738      .mockResolvedValueOnce(739        sampleImportResult({740          format: "peach-json",741          label: "Peach encrypted backup",742        })743      );744    sendMessageMock.mockImplementation(async (msg: unknown) => {745      const type = (msg as { type?: string }).type;746      if (type === "GET_ENTRIES") return { ok: true, data: [] };747      if (type === "BATCH_ADD_ENTRIES") {748        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };749      }750      return { ok: true };751    });752    const onImport = vi.fn(async () => undefined);753    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);754    await flushView();755    sendMessageMock.mockClear();756757    await selectImportFile(container, '{"type":"peach-export"}');758    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;759    inputText(password, "backup-passphrase");760    pressEnter(password);761    await flushView();762    await flushView();763764    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);765    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");766767    findButton(container, "Import 1").click();768    await flushView();769770    expect(sendMessageMock).toHaveBeenCalledWith({771      type: "BATCH_ADD_ENTRIES",772      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },773    });774    expect(onImport).toHaveBeenCalled();775  });776});