contracts.test.tsx

26.8 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 both 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("CSV");258  });259260  it("Settings requires current, new, and confirm passwords before enabling save", async () => {261    sendMessageMock.mockResolvedValue({ ok: true, data: null });262    const container = mount(263      <SettingsView264        entriesCount={1}265        breachMonitoring={true}266        lockOnClose={true}267        autoLockMinutes={5}268        trashRetentionDays={30}269        theme="dark"270        accentColor="peach"271        textSize="medium"272        reduceMotion={false}273        onBreachMonitoringChange={vi.fn()}274        onLockOnCloseChange={vi.fn()}275        onAutoLockMinutesChange={vi.fn()}276        onTrashRetentionChange={vi.fn()}277        onThemeChange={vi.fn()}278        onAccentChange={vi.fn()}279        onTextSizeChange={vi.fn()}280        onReduceMotionChange={vi.fn()}281        shortcuts={DEFAULT_BINDINGS}282        onShortcutsChange={vi.fn()}283        onBack={vi.fn()}284        onImport={vi.fn()}285        onExport={vi.fn()}286        onBreachCheckDone={vi.fn()}287      />288    );289    await flushView();290291    expect(container.querySelectorAll(".stk-card").length).toBe(7);292    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Protection");293294    const changeButton = Array.from(container.querySelectorAll("button")).find(295      (candidate) => candidate.textContent?.trim() === "change"296    ) as HTMLButtonElement;297    changeButton.click();298    await flushView();299300    const saveButton = Array.from(container.querySelectorAll("button")).find(301      (candidate) => candidate.textContent?.trim() === "save new password"302    ) as HTMLButtonElement;303    expect(saveButton.disabled).toBe(true);304305    const inputs = container.querySelectorAll('input[type="password"]');306    inputText(inputs[0] as HTMLInputElement, "current-pass");307    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");308    await flushView();309    expect(saveButton.disabled).toBe(true);310311    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");312    await flushView();313    expect(saveButton.disabled).toBe(false);314  });315316  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {317    sendMessageMock.mockImplementation(async (msg: unknown) => {318      const m = msg as { type: string };319      if (m.type === "GET_RECOVERY_WORDS") {320        return {321          ok: true,322          data: [323            "abandon", "ability", "able", "about", "above", "absent",324            "absorb", "abstract", "absurd", "abuse", "access", "accident",325          ],326        };327      }328      return { ok: true, data: null };329    });330    const onAutoLockMinutesChange = vi.fn();331    const onThemeChange = vi.fn();332    const container = mount(333      <SettingsView334        entriesCount={1}335        breachMonitoring={true}336        lockOnClose={true}337        autoLockMinutes={5}338        trashRetentionDays={30}339        theme="dark"340        accentColor="peach"341        textSize="medium"342        reduceMotion={false}343        onBreachMonitoringChange={vi.fn()}344        onLockOnCloseChange={vi.fn()}345        onAutoLockMinutesChange={onAutoLockMinutesChange}346        onTrashRetentionChange={vi.fn()}347        onThemeChange={onThemeChange}348        onAccentChange={vi.fn()}349        onTextSizeChange={vi.fn()}350        onReduceMotionChange={vi.fn()}351        shortcuts={DEFAULT_BINDINGS}352        onShortcutsChange={vi.fn()}353        onBack={vi.fn()}354        onImport={vi.fn()}355        onExport={vi.fn()}356        onBreachCheckDone={vi.fn()}357      />358    );359    await flushView();360    sendMessageMock.mockClear();361362    // ── Auto-lock + theme changes (before password change) ─────────────363    const selects = container.querySelectorAll("select");364    changeSelect(selects[0] as HTMLSelectElement, "15");365    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);366367    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;368    appearanceCard.click();369    await flushView();370    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");371    const themeButton = Array.from(container.querySelectorAll("button")).find(372      (candidate) => candidate.textContent?.trim() === "Switch to light"373    ) as HTMLButtonElement;374    themeButton.click();375    expect(onThemeChange).toHaveBeenCalledWith("light");376377    // ── Password change → recovery words display ────────────────────────378    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;379    protectionCard.click();380    await flushView();381382    const changeButton = Array.from(container.querySelectorAll("button")).find(383      (candidate) => candidate.textContent?.trim() === "change"384    ) as HTMLButtonElement;385    changeButton.click();386    await flushView();387388    const inputs = container.querySelectorAll('input[type="password"]');389    inputText(inputs[0] as HTMLInputElement, "current-pass");390    inputText(inputs[1] as HTMLInputElement, "new-password-123");391    inputText(inputs[2] as HTMLInputElement, "new-password-123");392    await flushView();393394    const saveButton = Array.from(container.querySelectorAll("button")).find(395      (candidate) => candidate.textContent?.trim() === "save new password"396    ) as HTMLButtonElement;397    saveButton.click();398    await flushView();399400    expect(sendMessageMock).toHaveBeenCalledWith({401      type: "CHANGE_PASSWORD",402      payload: {403        currentPassword: "current-pass",404        newPassword: "new-password-123",405      },406    });407408    // After a successful password change the recovery word display replaces settings.409    expect(container.textContent).toContain("save your recovery words");410    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });411  });412413  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {414    sendMessageMock.mockResolvedValue({ ok: true });415    const container = mount(416      <EntryView417        entry={sampleEntry()}418        onDelete={vi.fn()}419        onFieldSaved={vi.fn(async () => undefined)}420      />421    );422423    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;424    passwordButton.click();425    await flushView();426427    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {428      type: "COPY",429      payload: { text: "secret123", label: "password", alreadyWritten: false },430    });431    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {432      type: "FIRE_CANARY_PING",433      payload: {434        webhookUrl: "https://example.com/hook",435        entryTitle: "GitHub",436        trigger: "password_copied",437      },438    });439440    sendMessageMock.mockClear();441    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;442    usernameButton.click();443    await flushView();444445    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {446      type: "COPY",447      payload: { text: "june", label: "username", alreadyWritten: false },448    });449    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {450      type: "FIRE_CANARY_PING",451      payload: {452        webhookUrl: "https://example.com/hook",453        entryTitle: "GitHub",454        trigger: "username_copied",455      },456    });457  });458459  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {460    sendMessageMock.mockResolvedValue({ ok: true });461    const onFieldSaved = vi.fn(async () => undefined);462    const container = mount(463      <EntryView464        entry={sampleEntry()}465        onDelete={vi.fn()}466        onFieldSaved={onFieldSaved}467      />468    );469470    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;471    passwordValue.click();472    await flushView();473474    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;475    inputText(passwordInput, "");476    await flushView();477478    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;479    saveButton.click();480    await flushView();481482    expect(sendMessageMock).not.toHaveBeenCalledWith(483      expect.objectContaining({ type: "UPDATE_ENTRY" })484    );485    expect(onFieldSaved).not.toHaveBeenCalled();486487    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;488    totpMain.click();489490    await vi.waitFor(() => {491      expect(sendMessageMock).toHaveBeenLastCalledWith({492        type: "COPY",493        payload: { text: "123456", label: "totp", alreadyWritten: false },494      });495    }, { timeout: 500 });496  });497498  it("Entry closes an unchanged inline editor when focus leaves it", async () => {499    sendMessageMock.mockResolvedValue({ ok: true });500    const container = mount(501      <EntryView502        entry={sampleEntry()}503        onDelete={vi.fn()}504        onFieldSaved={vi.fn(async () => undefined)}505      />506    );507508    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;509    usernameValue.click();510    await flushView();511512    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;513    usernameInput.blur();514    await flushView();515516    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();517    expect(sendMessageMock).not.toHaveBeenCalledWith(518      expect.objectContaining({ type: "UPDATE_ENTRY" })519    );520  });521522  it("Entry lets TOTP secrets be edited inline", async () => {523    sendMessageMock.mockResolvedValue({ ok: true });524    const onFieldSaved = vi.fn(async () => undefined);525    const container = mount(526      <EntryView527        entry={sampleEntry()}528        onDelete={vi.fn()}529        onFieldSaved={onFieldSaved}530      />531    );532533    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;534    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));535    await flushView();536537    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;538    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");539    await flushView();540541    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;542    saveButton.click();543    await flushView();544545    expect(sendMessageMock).toHaveBeenCalledWith({546      type: "UPDATE_ENTRY",547      payload: {548        id: "entry-1",549        patch: { totp: "NB2W45DFOIZA====" },550      },551    });552    expect(onFieldSaved).toHaveBeenCalled();553  });554555  it("Vault seeds and focuses search when a search seed is provided", async () => {556    const container = mount(557      <VaultView558        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}559        sortOrder="url-first"560        searchSeed="g"561        searchSeedNonce={1}562        onSelectEntry={vi.fn()}563        onSortOrderChange={vi.fn()}564        onAdd={vi.fn()}565        onClose={vi.fn()}566      />567    );568569    await flushView();570571    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;572    expect(search.value).toBe("g");573    expect(document.activeElement).toBe(search);574  });575576  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {577    parseImportSourceMock.mockResolvedValue(578      sampleImportResult({579        entries: [580          {581            type: "login",582            title: "Imported site",583            username: "imported-user",584            password: "imported-pass",585            url: "https://imported.test",586          },587        ],588      })589    );590    sendMessageMock.mockImplementation(async (msg: unknown) => {591      const type = (msg as { type?: string }).type;592      if (type === "GET_ENTRIES") {593        return {594          ok: true,595          data: [596            sampleEntry({597              id: "existing-1",598              title: "Imported site",599              username: "imported-user",600              url: "https://imported.test",601            }),602          ],603        };604      }605      if (type === "BATCH_ADD_ENTRIES") {606        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };607      }608      return { ok: true };609    });610    const onImport = vi.fn(async () => undefined);611    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);612    await flushView();613    sendMessageMock.mockClear();614615    await selectImportFile(container);616    findButton(container, "Import 1").click();617    await flushView();618619    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);620    expect(sendMessageMock).toHaveBeenCalledWith({621      type: "BATCH_ADD_ENTRIES",622      payload: {623        entries: [624          expect.objectContaining({625            title: "Imported site",626            username: "imported-user",627            password: "imported-pass",628            url: "https://imported.test",629          }),630        ],631      },632    });633    expect(onImport).toHaveBeenCalled();634    expect(container.textContent).toContain("1 entry appears to be duplicates");635  });636637  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {638    parseImportSourceMock.mockResolvedValue(sampleImportResult());639    sendMessageMock.mockImplementation(async (msg: unknown) => {640      const type = (msg as { type?: string }).type;641      if (type === "GET_ENTRIES") {642        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };643      }644      if (type === "CLEAR_VAULT_ENTRIES") {645        return { ok: true };646      }647      if (type === "BATCH_ADD_ENTRIES") {648        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };649      }650      return { ok: true };651    });652    const onImport = vi.fn(async () => undefined);653    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);654    await flushView();655    sendMessageMock.mockClear();656657    await selectImportFile(container);658    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();659    await flushView();660661    findButton(container, "Replace vault with 1").click();662    await flushView();663664    expect(sendMessageMock).not.toHaveBeenCalledWith(665      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })666    );667    expect(container.textContent).toContain("Replace forever? This cannot be undone.");668669    findButton(container, "Yes, replace forever").click();670    await flushView();671672    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });673    expect(sendMessageMock).toHaveBeenCalledWith({674      type: "BATCH_ADD_ENTRIES",675      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },676    });677    expect(onImport).toHaveBeenCalled();678  });679680  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {681    parseImportSourceMock.mockResolvedValue(sampleImportResult());682    sendMessageMock.mockImplementation(async (msg: unknown) => {683      const type = (msg as { type?: string }).type;684      if (type === "CREATE_VAULT") return { ok: true };685      if (type === "BATCH_ADD_ENTRIES") {686        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };687      }688      return { ok: true };689    });690    const onImport = vi.fn(async () => undefined);691    const container = mount(692      <ImportView693        mode="onboarding"694        replacesExistingVault={true}695        onBack={vi.fn()}696        onImport={onImport}697      />698    );699700    await selectImportFile(container);701702    const createButton = findButton(container, "Create vault + import 1");703    expect(createButton.disabled).toBe(true);704    expect(container.textContent).toContain("Restoring now replaces the locked local vault");705706    const masterInput = container.querySelector(707      'input[placeholder="new master password"]'708    ) as HTMLInputElement;709    const confirmInput = container.querySelector(710      'input[placeholder="confirm master password"]'711    ) as HTMLInputElement;712    inputText(masterInput, "fresh-master-password");713    inputText(confirmInput, "fresh-master-password");714    await flushView();715716    expect(createButton.disabled).toBe(false);717    createButton.click();718    await flushView();719720    expect(sendMessageMock).toHaveBeenCalledWith({721      type: "CREATE_VAULT",722      payload: { password: "fresh-master-password" },723    });724    expect(sendMessageMock).toHaveBeenCalledWith({725      type: "BATCH_ADD_ENTRIES",726      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },727    });728    expect(onImport).toHaveBeenCalled();729  });730731  it("Import encrypted backups reparse with the backup password before import", async () => {732    parseImportSourceMock733      .mockResolvedValueOnce(734        sampleImportResult({735          format: "peach-json",736          label: "Peach encrypted backup",737          entries: [],738          totalItems: 0,739          requiresPassword: true,740        })741      )742      .mockResolvedValueOnce(743        sampleImportResult({744          format: "peach-json",745          label: "Peach encrypted backup",746        })747      );748    sendMessageMock.mockImplementation(async (msg: unknown) => {749      const type = (msg as { type?: string }).type;750      if (type === "GET_ENTRIES") return { ok: true, data: [] };751      if (type === "BATCH_ADD_ENTRIES") {752        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };753      }754      return { ok: true };755    });756    const onImport = vi.fn(async () => undefined);757    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);758    await flushView();759    sendMessageMock.mockClear();760761    await selectImportFile(container, '{"type":"peach-export"}');762    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;763    inputText(password, "backup-passphrase");764    pressEnter(password);765    await flushView();766    await flushView();767768    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);769    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");770771    findButton(container, "Import 1").click();772    await flushView();773774    expect(sendMessageMock).toHaveBeenCalledWith({775      type: "BATCH_ADD_ENTRIES",776      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },777    });778    expect(onImport).toHaveBeenCalled();779  });780});