contracts.test.tsx

27.3 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    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(292      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"293    ) as HTMLButtonElement;294    securityRow.click();295    await flushView();296297    const changeButton = Array.from(container.querySelectorAll("button")).find(298      (candidate) => candidate.textContent?.trim() === "change"299    ) as HTMLButtonElement;300    changeButton.click();301    await flushView();302303    const saveButton = Array.from(container.querySelectorAll("button")).find(304      (candidate) => candidate.textContent?.trim() === "save new password"305    ) as HTMLButtonElement;306    expect(saveButton.disabled).toBe(true);307308    const inputs = container.querySelectorAll('input[type="password"]');309    inputText(inputs[0] as HTMLInputElement, "current-pass");310    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");311    await flushView();312    expect(saveButton.disabled).toBe(true);313314    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");315    await flushView();316    expect(saveButton.disabled).toBe(false);317  });318319  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {320    sendMessageMock.mockImplementation(async (msg: unknown) => {321      const m = msg as { type: string };322      if (m.type === "GET_RECOVERY_WORDS") {323        return {324          ok: true,325          data: [326            "abandon", "ability", "able", "about", "above", "absent",327            "absorb", "abstract", "absurd", "abuse", "access", "accident",328          ],329        };330      }331      return { ok: true, data: null };332    });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        textSize="medium"345        reduceMotion={false}346        onBreachMonitoringChange={vi.fn()}347        onLockOnCloseChange={vi.fn()}348        onAutoLockMinutesChange={onAutoLockMinutesChange}349        onTrashRetentionChange={vi.fn()}350        onThemeChange={onThemeChange}351        onAccentChange={vi.fn()}352        onTextSizeChange={vi.fn()}353        onReduceMotionChange={vi.fn()}354        shortcuts={DEFAULT_BINDINGS}355        onShortcutsChange={vi.fn()}356        onBack={vi.fn()}357        onImport={vi.fn()}358        onExport={vi.fn()}359        onBreachCheckDone={vi.fn()}360      />361    );362    await flushView();363    sendMessageMock.mockClear();364365    function headerBack() {366      return container.querySelector(".peach-overlay-header button") as HTMLButtonElement;367    }368369    // ── Auto-lock + theme changes (before password change) ─────────────370    const securityRow = () =>371      Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(372        (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"373      ) as HTMLButtonElement;374375    securityRow().click();376    await flushView();377378    const selects = container.querySelectorAll("select");379    changeSelect(selects[0] as HTMLSelectElement, "15");380    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);381382    headerBack().click();383    await flushView();384385    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(386      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"387    ) as HTMLButtonElement;388    appearanceRow.click();389    await flushView();390    const themeButton = Array.from(container.querySelectorAll("button")).find(391      (candidate) => candidate.textContent?.trim() === "Switch to light"392    ) as HTMLButtonElement;393    themeButton.click();394    expect(onThemeChange).toHaveBeenCalledWith("light");395396    // ── Password change → recovery words display ────────────────────────397    headerBack().click();398    await flushView();399400    securityRow().click();401    await flushView();402403    const changeButton = Array.from(container.querySelectorAll("button")).find(404      (candidate) => candidate.textContent?.trim() === "change"405    ) as HTMLButtonElement;406    changeButton.click();407    await flushView();408409    const inputs = container.querySelectorAll('input[type="password"]');410    inputText(inputs[0] as HTMLInputElement, "current-pass");411    inputText(inputs[1] as HTMLInputElement, "new-password-123");412    inputText(inputs[2] as HTMLInputElement, "new-password-123");413    await flushView();414415    const saveButton = Array.from(container.querySelectorAll("button")).find(416      (candidate) => candidate.textContent?.trim() === "save new password"417    ) as HTMLButtonElement;418    saveButton.click();419    await flushView();420421    expect(sendMessageMock).toHaveBeenCalledWith({422      type: "CHANGE_PASSWORD",423      payload: {424        currentPassword: "current-pass",425        newPassword: "new-password-123",426      },427    });428429    // After a successful password change the recovery word display replaces settings.430    expect(container.textContent).toContain("save your recovery words");431    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });432  });433434  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {435    sendMessageMock.mockResolvedValue({ ok: true });436    const container = mount(437      <EntryView438        entry={sampleEntry()}439        onDelete={vi.fn()}440        onFieldSaved={vi.fn(async () => undefined)}441      />442    );443444    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;445    passwordButton.click();446    await flushView();447448    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {449      type: "COPY",450      payload: { text: "secret123", label: "password", alreadyWritten: false },451    });452    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {453      type: "FIRE_CANARY_PING",454      payload: {455        webhookUrl: "https://example.com/hook",456        entryTitle: "GitHub",457        trigger: "password_copied",458      },459    });460461    sendMessageMock.mockClear();462    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;463    usernameButton.click();464    await flushView();465466    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {467      type: "COPY",468      payload: { text: "june", label: "username", alreadyWritten: false },469    });470    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {471      type: "FIRE_CANARY_PING",472      payload: {473        webhookUrl: "https://example.com/hook",474        entryTitle: "GitHub",475        trigger: "username_copied",476      },477    });478  });479480  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {481    sendMessageMock.mockResolvedValue({ ok: true });482    const onFieldSaved = vi.fn(async () => undefined);483    const container = mount(484      <EntryView485        entry={sampleEntry()}486        onDelete={vi.fn()}487        onFieldSaved={onFieldSaved}488      />489    );490491    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;492    passwordValue.click();493    await flushView();494495    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;496    inputText(passwordInput, "");497    await flushView();498499    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;500    saveButton.click();501    await flushView();502503    expect(sendMessageMock).not.toHaveBeenCalledWith(504      expect.objectContaining({ type: "UPDATE_ENTRY" })505    );506    expect(onFieldSaved).not.toHaveBeenCalled();507508    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;509    totpMain.click();510511    await vi.waitFor(() => {512      expect(sendMessageMock).toHaveBeenLastCalledWith({513        type: "COPY",514        payload: { text: "123456", label: "totp", alreadyWritten: false },515      });516    }, { timeout: 500 });517  });518519  it("Entry closes an unchanged inline editor when focus leaves it", async () => {520    sendMessageMock.mockResolvedValue({ ok: true });521    const container = mount(522      <EntryView523        entry={sampleEntry()}524        onDelete={vi.fn()}525        onFieldSaved={vi.fn(async () => undefined)}526      />527    );528529    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;530    usernameValue.click();531    await flushView();532533    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;534    usernameInput.blur();535    await flushView();536537    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();538    expect(sendMessageMock).not.toHaveBeenCalledWith(539      expect.objectContaining({ type: "UPDATE_ENTRY" })540    );541  });542543  it("Entry lets TOTP secrets be edited inline", async () => {544    sendMessageMock.mockResolvedValue({ ok: true });545    const onFieldSaved = vi.fn(async () => undefined);546    const container = mount(547      <EntryView548        entry={sampleEntry()}549        onDelete={vi.fn()}550        onFieldSaved={onFieldSaved}551      />552    );553554    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;555    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));556    await flushView();557558    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;559    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");560    await flushView();561562    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;563    saveButton.click();564    await flushView();565566    expect(sendMessageMock).toHaveBeenCalledWith({567      type: "UPDATE_ENTRY",568      payload: {569        id: "entry-1",570        patch: { totp: "NB2W45DFOIZA====" },571      },572    });573    expect(onFieldSaved).toHaveBeenCalled();574  });575576  it("Vault seeds and focuses search when a search seed is provided", async () => {577    const container = mount(578      <VaultView579        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}580        sortOrder="url-first"581        searchSeed="g"582        searchSeedNonce={1}583        onSelectEntry={vi.fn()}584        onSortOrderChange={vi.fn()}585        onAdd={vi.fn()}586        onClose={vi.fn()}587      />588    );589590    await flushView();591592    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;593    expect(search.value).toBe("g");594    expect(document.activeElement).toBe(search);595  });596597  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {598    parseImportSourceMock.mockResolvedValue(599      sampleImportResult({600        entries: [601          {602            type: "login",603            title: "Imported site",604            username: "imported-user",605            password: "imported-pass",606            url: "https://imported.test",607          },608        ],609      })610    );611    sendMessageMock.mockImplementation(async (msg: unknown) => {612      const type = (msg as { type?: string }).type;613      if (type === "GET_ENTRIES") {614        return {615          ok: true,616          data: [617            sampleEntry({618              id: "existing-1",619              title: "Imported site",620              username: "imported-user",621              url: "https://imported.test",622            }),623          ],624        };625      }626      if (type === "BATCH_ADD_ENTRIES") {627        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };628      }629      return { ok: true };630    });631    const onImport = vi.fn(async () => undefined);632    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);633    await flushView();634    sendMessageMock.mockClear();635636    await selectImportFile(container);637    findButton(container, "Import 1").click();638    await flushView();639640    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);641    expect(sendMessageMock).toHaveBeenCalledWith({642      type: "BATCH_ADD_ENTRIES",643      payload: {644        entries: [645          expect.objectContaining({646            title: "Imported site",647            username: "imported-user",648            password: "imported-pass",649            url: "https://imported.test",650          }),651        ],652      },653    });654    expect(onImport).toHaveBeenCalled();655    expect(container.textContent).toContain("1 entry appears to be duplicates");656  });657658  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {659    parseImportSourceMock.mockResolvedValue(sampleImportResult());660    sendMessageMock.mockImplementation(async (msg: unknown) => {661      const type = (msg as { type?: string }).type;662      if (type === "GET_ENTRIES") {663        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };664      }665      if (type === "CLEAR_VAULT_ENTRIES") {666        return { ok: true };667      }668      if (type === "BATCH_ADD_ENTRIES") {669        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };670      }671      return { ok: true };672    });673    const onImport = vi.fn(async () => undefined);674    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);675    await flushView();676    sendMessageMock.mockClear();677678    await selectImportFile(container);679    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();680    await flushView();681682    findButton(container, "Replace vault with 1").click();683    await flushView();684685    expect(sendMessageMock).not.toHaveBeenCalledWith(686      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })687    );688    expect(container.textContent).toContain("Replace forever? This cannot be undone.");689690    findButton(container, "Yes, replace forever").click();691    await flushView();692693    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });694    expect(sendMessageMock).toHaveBeenCalledWith({695      type: "BATCH_ADD_ENTRIES",696      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },697    });698    expect(onImport).toHaveBeenCalled();699  });700701  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {702    parseImportSourceMock.mockResolvedValue(sampleImportResult());703    sendMessageMock.mockImplementation(async (msg: unknown) => {704      const type = (msg as { type?: string }).type;705      if (type === "CREATE_VAULT") return { ok: true };706      if (type === "BATCH_ADD_ENTRIES") {707        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };708      }709      return { ok: true };710    });711    const onImport = vi.fn(async () => undefined);712    const container = mount(713      <ImportView714        mode="onboarding"715        replacesExistingVault={true}716        onBack={vi.fn()}717        onImport={onImport}718      />719    );720721    await selectImportFile(container);722723    const createButton = findButton(container, "Create vault + import 1");724    expect(createButton.disabled).toBe(true);725    expect(container.textContent).toContain("Restoring now replaces the locked local vault");726727    const masterInput = container.querySelector(728      'input[placeholder="new master password"]'729    ) as HTMLInputElement;730    const confirmInput = container.querySelector(731      'input[placeholder="confirm master password"]'732    ) as HTMLInputElement;733    inputText(masterInput, "fresh-master-password");734    inputText(confirmInput, "fresh-master-password");735    await flushView();736737    expect(createButton.disabled).toBe(false);738    createButton.click();739    await flushView();740741    expect(sendMessageMock).toHaveBeenCalledWith({742      type: "CREATE_VAULT",743      payload: { password: "fresh-master-password" },744    });745    expect(sendMessageMock).toHaveBeenCalledWith({746      type: "BATCH_ADD_ENTRIES",747      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },748    });749    expect(onImport).toHaveBeenCalled();750  });751752  it("Import encrypted backups reparse with the backup password before import", async () => {753    parseImportSourceMock754      .mockResolvedValueOnce(755        sampleImportResult({756          format: "peach-json",757          label: "Peach encrypted backup",758          entries: [],759          totalItems: 0,760          requiresPassword: true,761        })762      )763      .mockResolvedValueOnce(764        sampleImportResult({765          format: "peach-json",766          label: "Peach encrypted backup",767        })768      );769    sendMessageMock.mockImplementation(async (msg: unknown) => {770      const type = (msg as { type?: string }).type;771      if (type === "GET_ENTRIES") return { ok: true, data: [] };772      if (type === "BATCH_ADD_ENTRIES") {773        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };774      }775      return { ok: true };776    });777    const onImport = vi.fn(async () => undefined);778    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);779    await flushView();780    sendMessageMock.mockClear();781782    await selectImportFile(container, '{"type":"peach-export"}');783    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;784    inputText(password, "backup-passphrase");785    pressEnter(password);786    await flushView();787    await flushView();788789    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);790    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");791792    findButton(container, "Import 1").click();793    await flushView();794795    expect(sendMessageMock).toHaveBeenCalledWith({796      type: "BATCH_ADD_ENTRIES",797      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },798    });799    expect(onImport).toHaveBeenCalled();800  });801});