contracts.test.tsx

27.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.mockImplementation(async (msg: unknown) => {324      const m = msg as { type: string };325      if (m.type === "GET_RECOVERY_WORDS") {326        return {327          ok: true,328          data: [329            "abandon", "ability", "able", "about", "above", "absent",330            "absorb", "abstract", "absurd", "abuse", "access", "accident",331          ],332        };333      }334      return { ok: true, data: null };335    });336    const onAutoLockMinutesChange = vi.fn();337    const onThemeChange = vi.fn();338    const container = mount(339      <SettingsView340        entriesCount={1}341        breachMonitoring={true}342        lockOnClose={true}343        autoLockMinutes={5}344        trashRetentionDays={30}345        theme="dark"346        accentColor="peach"347        displayFont="cormorant-italic"348        textSize="medium"349        reduceMotion={false}350        onBreachMonitoringChange={vi.fn()}351        onLockOnCloseChange={vi.fn()}352        onAutoLockMinutesChange={onAutoLockMinutesChange}353        onTrashRetentionChange={vi.fn()}354        onThemeChange={onThemeChange}355        onAccentChange={vi.fn()}356        onDisplayFontChange={vi.fn()}357        onTextSizeChange={vi.fn()}358        onReduceMotionChange={vi.fn()}359        shortcuts={DEFAULT_BINDINGS}360        onShortcutsChange={vi.fn()}361        onBack={vi.fn()}362        onImport={vi.fn()}363        onExport={vi.fn()}364        onBreachCheckDone={vi.fn()}365      />366    );367    await flushView();368    sendMessageMock.mockClear();369370    function headerBack() {371      return container.querySelector(".peach-overlay-header button") as HTMLButtonElement;372    }373374    // ── Auto-lock + theme changes (before password change) ─────────────375    const securityRow = () =>376      Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(377        (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"378      ) as HTMLButtonElement;379380    securityRow().click();381    await flushView();382383    const selects = container.querySelectorAll("select");384    changeSelect(selects[0] as HTMLSelectElement, "15");385    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);386387    headerBack().click();388    await flushView();389390    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    // ── Password change → recovery words display ────────────────────────402    headerBack().click();403    await flushView();404405    securityRow().click();406    await flushView();407408    const changeButton = Array.from(container.querySelectorAll("button")).find(409      (candidate) => candidate.textContent?.trim() === "change"410    ) as HTMLButtonElement;411    changeButton.click();412    await flushView();413414    const inputs = container.querySelectorAll('input[type="password"]');415    inputText(inputs[0] as HTMLInputElement, "current-pass");416    inputText(inputs[1] as HTMLInputElement, "new-password-123");417    inputText(inputs[2] as HTMLInputElement, "new-password-123");418    await flushView();419420    const saveButton = Array.from(container.querySelectorAll("button")).find(421      (candidate) => candidate.textContent?.trim() === "save new password"422    ) as HTMLButtonElement;423    saveButton.click();424    await flushView();425426    expect(sendMessageMock).toHaveBeenCalledWith({427      type: "CHANGE_PASSWORD",428      payload: {429        currentPassword: "current-pass",430        newPassword: "new-password-123",431      },432    });433434    // After a successful password change the recovery word display replaces settings.435    expect(container.textContent).toContain("save your recovery words");436    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });437  });438439  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {440    sendMessageMock.mockResolvedValue({ ok: true });441    const container = mount(442      <EntryView443        entry={sampleEntry()}444        onDelete={vi.fn()}445        onFieldSaved={vi.fn(async () => undefined)}446      />447    );448449    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;450    passwordButton.click();451    await flushView();452453    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {454      type: "COPY",455      payload: { text: "secret123", label: "password", alreadyWritten: false },456    });457    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {458      type: "FIRE_CANARY_PING",459      payload: {460        webhookUrl: "https://example.com/hook",461        entryTitle: "GitHub",462        trigger: "password_copied",463      },464    });465466    sendMessageMock.mockClear();467    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;468    usernameButton.click();469    await flushView();470471    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {472      type: "COPY",473      payload: { text: "june", label: "username", alreadyWritten: false },474    });475    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {476      type: "FIRE_CANARY_PING",477      payload: {478        webhookUrl: "https://example.com/hook",479        entryTitle: "GitHub",480        trigger: "username_copied",481      },482    });483  });484485  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {486    sendMessageMock.mockResolvedValue({ ok: true });487    const onFieldSaved = vi.fn(async () => undefined);488    const container = mount(489      <EntryView490        entry={sampleEntry()}491        onDelete={vi.fn()}492        onFieldSaved={onFieldSaved}493      />494    );495496    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;497    passwordValue.click();498    await flushView();499500    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;501    inputText(passwordInput, "");502    await flushView();503504    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;505    saveButton.click();506    await flushView();507508    expect(sendMessageMock).not.toHaveBeenCalledWith(509      expect.objectContaining({ type: "UPDATE_ENTRY" })510    );511    expect(onFieldSaved).not.toHaveBeenCalled();512513    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;514    totpMain.click();515    await flushView();516517    expect(sendMessageMock).toHaveBeenLastCalledWith({518      type: "COPY",519      payload: { text: "123456", label: "totp", alreadyWritten: false },520    });521  });522523  it("Entry closes an unchanged inline editor when focus leaves it", async () => {524    sendMessageMock.mockResolvedValue({ ok: true });525    const container = mount(526      <EntryView527        entry={sampleEntry()}528        onDelete={vi.fn()}529        onFieldSaved={vi.fn(async () => undefined)}530      />531    );532533    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;534    usernameValue.click();535    await flushView();536537    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;538    usernameInput.blur();539    await flushView();540541    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();542    expect(sendMessageMock).not.toHaveBeenCalledWith(543      expect.objectContaining({ type: "UPDATE_ENTRY" })544    );545  });546547  it("Entry lets TOTP secrets be edited inline", async () => {548    sendMessageMock.mockResolvedValue({ ok: true });549    const onFieldSaved = vi.fn(async () => undefined);550    const container = mount(551      <EntryView552        entry={sampleEntry()}553        onDelete={vi.fn()}554        onFieldSaved={onFieldSaved}555      />556    );557558    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;559    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));560    await flushView();561562    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;563    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");564    await flushView();565566    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;567    saveButton.click();568    await flushView();569570    expect(sendMessageMock).toHaveBeenCalledWith({571      type: "UPDATE_ENTRY",572      payload: {573        id: "entry-1",574        patch: { totp: "NB2W45DFOIZA====" },575      },576    });577    expect(onFieldSaved).toHaveBeenCalled();578  });579580  it("Vault seeds and focuses search when a search seed is provided", async () => {581    const container = mount(582      <VaultView583        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}584        sortOrder="url-first"585        searchSeed="g"586        searchSeedNonce={1}587        onSelectEntry={vi.fn()}588        onSortOrderChange={vi.fn()}589        onAdd={vi.fn()}590        onClose={vi.fn()}591      />592    );593594    await flushView();595596    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;597    expect(search.value).toBe("g");598    expect(document.activeElement).toBe(search);599  });600601  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {602    parseImportSourceMock.mockResolvedValue(603      sampleImportResult({604        entries: [605          {606            type: "login",607            title: "Imported site",608            username: "imported-user",609            password: "imported-pass",610            url: "https://imported.test",611          },612        ],613      })614    );615    sendMessageMock.mockImplementation(async (msg: unknown) => {616      const type = (msg as { type?: string }).type;617      if (type === "GET_ENTRIES") {618        return {619          ok: true,620          data: [621            sampleEntry({622              id: "existing-1",623              title: "Imported site",624              username: "imported-user",625              url: "https://imported.test",626            }),627          ],628        };629      }630      if (type === "BATCH_ADD_ENTRIES") {631        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };632      }633      return { ok: true };634    });635    const onImport = vi.fn(async () => undefined);636    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);637    await flushView();638    sendMessageMock.mockClear();639640    await selectImportFile(container);641    findButton(container, "Import 1").click();642    await flushView();643644    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);645    expect(sendMessageMock).toHaveBeenCalledWith({646      type: "BATCH_ADD_ENTRIES",647      payload: {648        entries: [649          expect.objectContaining({650            title: "Imported site",651            username: "imported-user",652            password: "imported-pass",653            url: "https://imported.test",654          }),655        ],656      },657    });658    expect(onImport).toHaveBeenCalled();659    expect(container.textContent).toContain("1 entry appears to be duplicates");660  });661662  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {663    parseImportSourceMock.mockResolvedValue(sampleImportResult());664    sendMessageMock.mockImplementation(async (msg: unknown) => {665      const type = (msg as { type?: string }).type;666      if (type === "GET_ENTRIES") {667        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };668      }669      if (type === "CLEAR_VAULT_ENTRIES") {670        return { ok: true };671      }672      if (type === "BATCH_ADD_ENTRIES") {673        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };674      }675      return { ok: true };676    });677    const onImport = vi.fn(async () => undefined);678    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);679    await flushView();680    sendMessageMock.mockClear();681682    await selectImportFile(container);683    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();684    await flushView();685686    findButton(container, "Replace vault with 1").click();687    await flushView();688689    expect(sendMessageMock).not.toHaveBeenCalledWith(690      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })691    );692    expect(container.textContent).toContain("Replace forever? This cannot be undone.");693694    findButton(container, "Yes, replace forever").click();695    await flushView();696697    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });698    expect(sendMessageMock).toHaveBeenCalledWith({699      type: "BATCH_ADD_ENTRIES",700      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },701    });702    expect(onImport).toHaveBeenCalled();703  });704705  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {706    parseImportSourceMock.mockResolvedValue(sampleImportResult());707    sendMessageMock.mockImplementation(async (msg: unknown) => {708      const type = (msg as { type?: string }).type;709      if (type === "CREATE_VAULT") return { ok: true };710      if (type === "BATCH_ADD_ENTRIES") {711        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };712      }713      return { ok: true };714    });715    const onImport = vi.fn(async () => undefined);716    const container = mount(717      <ImportView718        mode="onboarding"719        replacesExistingVault={true}720        onBack={vi.fn()}721        onImport={onImport}722      />723    );724725    await selectImportFile(container);726727    const createButton = findButton(container, "Create vault + import 1");728    expect(createButton.disabled).toBe(true);729    expect(container.textContent).toContain("Restoring now replaces the locked local vault");730731    const masterInput = container.querySelector(732      'input[placeholder="new master password"]'733    ) as HTMLInputElement;734    const confirmInput = container.querySelector(735      'input[placeholder="confirm master password"]'736    ) as HTMLInputElement;737    inputText(masterInput, "fresh-master-password");738    inputText(confirmInput, "fresh-master-password");739    await flushView();740741    expect(createButton.disabled).toBe(false);742    createButton.click();743    await flushView();744745    expect(sendMessageMock).toHaveBeenCalledWith({746      type: "CREATE_VAULT",747      payload: { password: "fresh-master-password" },748    });749    expect(sendMessageMock).toHaveBeenCalledWith({750      type: "BATCH_ADD_ENTRIES",751      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },752    });753    expect(onImport).toHaveBeenCalled();754  });755756  it("Import encrypted backups reparse with the backup password before import", async () => {757    parseImportSourceMock758      .mockResolvedValueOnce(759        sampleImportResult({760          format: "peach-json",761          label: "Peach encrypted backup",762          entries: [],763          totalItems: 0,764          requiresPassword: true,765        })766      )767      .mockResolvedValueOnce(768        sampleImportResult({769          format: "peach-json",770          label: "Peach encrypted backup",771        })772      );773    sendMessageMock.mockImplementation(async (msg: unknown) => {774      const type = (msg as { type?: string }).type;775      if (type === "GET_ENTRIES") return { ok: true, data: [] };776      if (type === "BATCH_ADD_ENTRIES") {777        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };778      }779      return { ok: true };780    });781    const onImport = vi.fn(async () => undefined);782    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);783    await flushView();784    sendMessageMock.mockClear();785786    await selectImportFile(container, '{"type":"peach-export"}');787    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;788    inputText(password, "backup-passphrase");789    pressEnter(password);790    await flushView();791    await flushView();792793    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);794    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");795796    findButton(container, "Import 1").click();797    await flushView();798799    expect(sendMessageMock).toHaveBeenCalledWith({800      type: "BATCH_ADD_ENTRIES",801      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },802    });803    expect(onImport).toHaveBeenCalled();804  });805});