contracts.test.tsx

27.6 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 exportEntriesToPeachJsonMock = vi.fn();21const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();22const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2324vi.mock("../App", () => ({25  sendMessage: (msg: unknown) => sendMessageMock(msg),26  triggerRing: () => triggerRingMock(),27  triggerPipPulse: () => triggerPipPulseMock(),28}));2930vi.mock("../../shared/backup", () => ({31  exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),32}));3334vi.mock("../../shared/import", () => ({35  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),36}));3738vi.mock("../../shared/favicon", () => ({39  faviconUrl: () => null,40}));4142vi.mock("../../crypto/totp", () => ({43  generateTOTP: () => ({ code: "123 456", remaining: 24 }),44  parseOtpauthURI: (value: string) =>45    value.startsWith("otpauth://")46      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }47      : null,48}));4950vi.mock("../components/GeneratorDrawer", () => ({51  GeneratorDrawer: () => null,52}));5354vi.mock("./CJKImport", () => ({55  CJKImportView: () => null,56}));5758vi.mock("./CJKExport", () => ({59  CJKExportView: () => null,60}));6162vi.mock("zxcvbn", () => ({63  default: (value: string) => zxcvbnMock(value),64}));6566describe("popup contract tests", () => {67  const disposers: Array<() => void> = [];6869  beforeEach(() => {70    document.body.innerHTML = "";71    sendMessageMock.mockReset();72    triggerRingMock.mockReset();73    triggerPipPulseMock.mockReset();74    exportEntriesToPeachJsonMock.mockReset();75    parseImportSourceMock.mockReset();76    zxcvbnMock.mockReset();77    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));78    parseImportSourceMock.mockResolvedValue({79      format: "unknown",80      label: "Unknown format",81      entries: [],82      totalItems: 0,83      skippedItems: 0,84      requiresPassword: false,85    });8687    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {88      return window.setTimeout(() => cb(0), 0) as unknown as number;89    });90    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));91    vi.stubGlobal("URL", {92      createObjectURL: vi.fn(() => "blob:peach-test"),93      revokeObjectURL: vi.fn(),94    });95    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9697    (globalThis as { chrome?: typeof chrome }).chrome = {98      runtime: {99        getManifest: () => ({ version: "1.4.47" }),100        onMessage: { addListener: vi.fn() },101      },102    } as unknown as typeof chrome;103  });104105  afterEach(() => {106    while (disposers.length > 0) {107      disposers.pop()?.();108    }109    vi.unstubAllGlobals();110  });111112  function mount(view: JSX.Element) {113    const mounted = renderView(view);114    disposers.push(mounted.dispose);115    return mounted.container;116  }117118  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {119    return {120      id: "entry-1",121      type: "login",122      title: "GitHub",123      username: "june",124      password: "secret123",125      url: "https://github.com",126      totp: "JBSWY3DPEHPK3PXP",127      canary: {128        webhookUrl: "https://example.com/hook",129      },130      createdAt: 1,131      modifiedAt: 1,132      ...overrides,133    };134  }135136  function sampleImportResult(137    overrides: Partial<ImportParseResult> = {}138  ): ImportParseResult {139    return {140      format: "csv",141      label: "CSV import",142      entries: [143        {144          type: "login",145          title: "Imported site",146          username: "imported-user",147          password: "imported-pass",148          url: "https://imported.test",149        },150      ],151      totalItems: 1,152      skippedItems: 0,153      requiresPassword: false,154      ...overrides,155    };156  }157158  async function selectImportFile(159    container: HTMLElement,160    contents = '{"export":"test"}'161  ) {162    const input = container.querySelector('input[type="file"]') as HTMLInputElement;163    const file = new File([contents], "import.json", { type: "application/json" });164    Object.defineProperty(input, "files", {165      configurable: true,166      value: [file],167    });168    input.dispatchEvent(new Event("change", { bubbles: true }));169    await flushView();170    await flushView();171  }172173  function findButton(container: HTMLElement, text: string): HTMLButtonElement {174    return Array.from(container.querySelectorAll("button")).find(175      (candidate) => candidate.textContent?.trim() === text176    ) as HTMLButtonElement;177  }178179  it("Lock sends UNLOCK with the entered passphrase", async () => {180    sendMessageMock.mockResolvedValue({ ok: true });181    const onUnlock = vi.fn(async () => undefined);182    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);183184    const password = container.querySelector("#unlock-password") as HTMLInputElement;185    inputText(password, "correct horse battery staple");186    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();187    await flushView();188189    expect(sendMessageMock).toHaveBeenCalledWith({190      type: "UNLOCK",191      payload: { password: "correct horse battery staple" },192    });193    expect(onUnlock).toHaveBeenCalled();194  });195196  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {197    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);198    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;199    const password = container.querySelector("#master-password") as HTMLInputElement;200    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;201202    inputText(password, "short");203    inputText(confirm, "short");204    await flushView();205    expect(createButton.disabled).toBe(true);206207    zxcvbnMock.mockImplementation(() => ({ score: 1 }));208    inputText(password, "weakweakweak");209    inputText(confirm, "weakweakweak");210    await flushView();211    expect(createButton.disabled).toBe(true);212  });213214  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {215    sendMessageMock.mockResolvedValue({ ok: true });216    zxcvbnMock.mockImplementation(() => ({ score: 3 }));217    const onUnlock = vi.fn(async () => undefined);218    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);219220    const password = container.querySelector("#master-password") as HTMLInputElement;221    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;222    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;223224    inputText(password, "correct horse battery staple");225    inputText(confirm, "correct horse battery staple");226    await flushView();227    expect(createButton.disabled).toBe(false);228229    pressEnter(confirm);230    await flushView();231232    expect(sendMessageMock).toHaveBeenCalledWith({233      type: "CREATE_VAULT",234      payload: { password: "correct horse battery staple" },235    });236    expect(onUnlock).toHaveBeenCalled();237  });238239  it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {240    const container = mount(<ExportView onBack={vi.fn()} />);241242    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;243    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;244    const button = Array.from(container.querySelectorAll("button")).find(245      (candidate) => candidate.textContent?.trim() === "Download backup file"246    ) as HTMLButtonElement;247248    inputText(password, "too-short");249    inputText(confirm, "too-short");250    await flushView();251252    expect(button.disabled).toBe(true);253    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();254  });255256  it("Export keeps encrypted export disabled when passwords do not match", async () => {257    const container = mount(<ExportView onBack={vi.fn()} />);258259    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;260    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;261    const button = Array.from(container.querySelectorAll("button")).find(262      (candidate) => candidate.textContent?.trim() === "Download backup file"263    ) as HTMLButtonElement;264265    inputText(password, "correct horse battery staple");266    inputText(confirm, "different horse battery staple");267    await flushView();268269    expect(button.disabled).toBe(true);270    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();271  });272273  it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {274    sendMessageMock.mockResolvedValue({275      ok: true,276      data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],277    });278    exportEntriesToPeachJsonMock.mockResolvedValue({279      version: 2,280      type: "peach-export",281    });282    const container = mount(<ExportView onBack={vi.fn()} />);283284    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;285    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;286    const button = Array.from(container.querySelectorAll("button")).find(287      (candidate) => candidate.textContent?.trim() === "Download backup file"288    ) as HTMLButtonElement;289290    inputText(password, "correct horse battery staple");291    inputText(confirm, "correct horse battery staple");292    await flushView();293    button.click();294    await flushView();295296    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });297    expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(298      [299        expect.objectContaining({ id: "entry-1" }),300        expect.objectContaining({ id: "deleted", deletedAt: 2 }),301      ],302      "correct horse battery staple"303    );304  });305306  it("Settings requires current, new, and confirm passwords before enabling save", async () => {307    sendMessageMock.mockResolvedValue({ ok: true, data: null });308    const container = mount(309      <SettingsView310        entriesCount={1}311        breachMonitoring={true}312        lockOnClose={true}313        autoLockMinutes={5}314        trashRetentionDays={30}315        theme="dark"316        accentColor="peach"317        onBreachMonitoringChange={vi.fn()}318        onLockOnCloseChange={vi.fn()}319        onAutoLockMinutesChange={vi.fn()}320        onTrashRetentionChange={vi.fn()}321        onThemeChange={vi.fn()}322        onAccentChange={vi.fn()}323        shortcuts={DEFAULT_BINDINGS}324        onShortcutsChange={vi.fn()}325        onBack={vi.fn()}326        onImport={vi.fn()}327        onExport={vi.fn()}328        onBreachCheckDone={vi.fn()}329      />330    );331    await flushView();332333    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(334      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"335    ) as HTMLButtonElement;336    securityRow.click();337    await flushView();338339    const changeButton = Array.from(container.querySelectorAll("button")).find(340      (candidate) => candidate.textContent?.trim() === "change"341    ) as HTMLButtonElement;342    changeButton.click();343    await flushView();344345    const saveButton = Array.from(container.querySelectorAll("button")).find(346      (candidate) => candidate.textContent?.trim() === "save new password"347    ) as HTMLButtonElement;348    expect(saveButton.disabled).toBe(true);349350    const inputs = container.querySelectorAll('input[type="password"]');351    inputText(inputs[0] as HTMLInputElement, "current-pass");352    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");353    await flushView();354    expect(saveButton.disabled).toBe(true);355356    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");357    await flushView();358    expect(saveButton.disabled).toBe(false);359  });360361  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {362    sendMessageMock.mockResolvedValue({ ok: true, data: null });363    const onAutoLockMinutesChange = vi.fn();364    const onThemeChange = vi.fn();365    const container = mount(366      <SettingsView367        entriesCount={1}368        breachMonitoring={true}369        lockOnClose={true}370        autoLockMinutes={5}371        trashRetentionDays={30}372        theme="dark"373        accentColor="peach"374        onBreachMonitoringChange={vi.fn()}375        onLockOnCloseChange={vi.fn()}376        onAutoLockMinutesChange={onAutoLockMinutesChange}377        onTrashRetentionChange={vi.fn()}378        onThemeChange={onThemeChange}379        onAccentChange={vi.fn()}380        shortcuts={DEFAULT_BINDINGS}381        onShortcutsChange={vi.fn()}382        onBack={vi.fn()}383        onImport={vi.fn()}384        onExport={vi.fn()}385        onBreachCheckDone={vi.fn()}386      />387    );388    await flushView();389    sendMessageMock.mockClear();390391    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;392393    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(394      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"395    ) as HTMLButtonElement;396    securityRow.click();397    await flushView();398399    const selects = container.querySelectorAll("select");400    changeSelect(selects[0] as HTMLSelectElement, "15");401    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);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    headerBack.click();422    await flushView();423    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(424      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"425    ) as HTMLButtonElement;426    appearanceRow.click();427    await flushView();428    const themeButton = Array.from(container.querySelectorAll("button")).find(429      (candidate) => candidate.textContent?.trim() === "Switch to light"430    ) as HTMLButtonElement;431    themeButton.click();432    expect(onThemeChange).toHaveBeenCalledWith("light");433434    expect(sendMessageMock).toHaveBeenCalledWith({435      type: "CHANGE_PASSWORD",436      payload: {437        currentPassword: "current-pass",438        newPassword: "new-password-123",439      },440    });441  });442443  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {444    sendMessageMock.mockResolvedValue({ ok: true });445    const container = mount(446      <EntryView447        entry={sampleEntry()}448        onDelete={vi.fn()}449        onFieldSaved={vi.fn(async () => undefined)}450      />451    );452453    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;454    passwordButton.click();455    await flushView();456457    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {458      type: "COPY",459      payload: { text: "secret123", label: "password", alreadyWritten: false },460    });461    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {462      type: "FIRE_CANARY_PING",463      payload: {464        webhookUrl: "https://example.com/hook",465        entryTitle: "GitHub",466        trigger: "password_copied",467      },468    });469470    sendMessageMock.mockClear();471    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;472    usernameButton.click();473    await flushView();474475    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {476      type: "COPY",477      payload: { text: "june", label: "username", alreadyWritten: false },478    });479    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {480      type: "FIRE_CANARY_PING",481      payload: {482        webhookUrl: "https://example.com/hook",483        entryTitle: "GitHub",484        trigger: "username_copied",485      },486    });487  });488489  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {490    sendMessageMock.mockResolvedValue({ ok: true });491    const onFieldSaved = vi.fn(async () => undefined);492    const container = mount(493      <EntryView494        entry={sampleEntry()}495        onDelete={vi.fn()}496        onFieldSaved={onFieldSaved}497      />498    );499500    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;501    passwordValue.click();502    await flushView();503504    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;505    inputText(passwordInput, "");506    await flushView();507508    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;509    saveButton.click();510    await flushView();511512    expect(sendMessageMock).not.toHaveBeenCalledWith(513      expect.objectContaining({ type: "UPDATE_ENTRY" })514    );515    expect(onFieldSaved).not.toHaveBeenCalled();516517    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;518    totpMain.click();519    await flushView();520521    expect(sendMessageMock).toHaveBeenLastCalledWith({522      type: "COPY",523      payload: { text: "123456", label: "totp", alreadyWritten: false },524    });525  });526527  it("Entry closes an unchanged inline editor when focus leaves it", async () => {528    sendMessageMock.mockResolvedValue({ ok: true });529    const container = mount(530      <EntryView531        entry={sampleEntry()}532        onDelete={vi.fn()}533        onFieldSaved={vi.fn(async () => undefined)}534      />535    );536537    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;538    usernameValue.click();539    await flushView();540541    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;542    usernameInput.blur();543    await flushView();544545    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();546    expect(sendMessageMock).not.toHaveBeenCalledWith(547      expect.objectContaining({ type: "UPDATE_ENTRY" })548    );549  });550551  it("Entry lets TOTP secrets be edited inline", async () => {552    sendMessageMock.mockResolvedValue({ ok: true });553    const onFieldSaved = vi.fn(async () => undefined);554    const container = mount(555      <EntryView556        entry={sampleEntry()}557        onDelete={vi.fn()}558        onFieldSaved={onFieldSaved}559      />560    );561562    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;563    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));564    await flushView();565566    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;567    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");568    await flushView();569570    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;571    saveButton.click();572    await flushView();573574    expect(sendMessageMock).toHaveBeenCalledWith({575      type: "UPDATE_ENTRY",576      payload: {577        id: "entry-1",578        patch: { totp: "NB2W45DFOIZA====" },579      },580    });581    expect(onFieldSaved).toHaveBeenCalled();582  });583584  it("Vault seeds and focuses search when a search seed is provided", async () => {585    const container = mount(586      <VaultView587        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}588        sortOrder="url-first"589        searchSeed="g"590        searchSeedNonce={1}591        onSelectEntry={vi.fn()}592        onSortOrderChange={vi.fn()}593        onAdd={vi.fn()}594        onClose={vi.fn()}595      />596    );597598    await flushView();599600    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;601    expect(search.value).toBe("g");602    expect(document.activeElement).toBe(search);603  });604605  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {606    parseImportSourceMock.mockResolvedValue(607      sampleImportResult({608        entries: [609          {610            type: "login",611            title: "Imported site",612            username: "imported-user",613            password: "imported-pass",614            url: "https://imported.test",615          },616        ],617      })618    );619    sendMessageMock.mockImplementation(async (msg: unknown) => {620      const type = (msg as { type?: string }).type;621      if (type === "GET_ENTRIES") {622        return {623          ok: true,624          data: [625            sampleEntry({626              id: "existing-1",627              title: "Imported site",628              username: "imported-user",629              url: "https://imported.test",630            }),631          ],632        };633      }634      if (type === "BATCH_ADD_ENTRIES") {635        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };636      }637      return { ok: true };638    });639    const onImport = vi.fn(async () => undefined);640    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);641    await flushView();642    sendMessageMock.mockClear();643644    await selectImportFile(container);645    findButton(container, "Import 1").click();646    await flushView();647648    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);649    expect(sendMessageMock).toHaveBeenCalledWith({650      type: "BATCH_ADD_ENTRIES",651      payload: {652        entries: [653          expect.objectContaining({654            title: "Imported site",655            username: "imported-user",656            password: "imported-pass",657            url: "https://imported.test",658          }),659        ],660      },661    });662    expect(onImport).toHaveBeenCalled();663    expect(container.textContent).toContain("1 entry appears to be duplicates");664  });665666  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {667    parseImportSourceMock.mockResolvedValue(sampleImportResult());668    sendMessageMock.mockImplementation(async (msg: unknown) => {669      const type = (msg as { type?: string }).type;670      if (type === "GET_ENTRIES") {671        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };672      }673      if (type === "CLEAR_VAULT_ENTRIES") {674        return { ok: true };675      }676      if (type === "BATCH_ADD_ENTRIES") {677        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };678      }679      return { ok: true };680    });681    const onImport = vi.fn(async () => undefined);682    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);683    await flushView();684    sendMessageMock.mockClear();685686    await selectImportFile(container);687    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();688    await flushView();689690    findButton(container, "Replace vault with 1").click();691    await flushView();692693    expect(sendMessageMock).not.toHaveBeenCalledWith(694      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })695    );696    expect(container.textContent).toContain("Replace forever? This cannot be undone.");697698    findButton(container, "Yes, replace forever").click();699    await flushView();700701    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });702    expect(sendMessageMock).toHaveBeenCalledWith({703      type: "BATCH_ADD_ENTRIES",704      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },705    });706    expect(onImport).toHaveBeenCalled();707  });708709  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {710    parseImportSourceMock.mockResolvedValue(sampleImportResult());711    sendMessageMock.mockImplementation(async (msg: unknown) => {712      const type = (msg as { type?: string }).type;713      if (type === "CREATE_VAULT") return { ok: true };714      if (type === "BATCH_ADD_ENTRIES") {715        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };716      }717      return { ok: true };718    });719    const onImport = vi.fn(async () => undefined);720    const container = mount(721      <ImportView722        mode="onboarding"723        replacesExistingVault={true}724        onBack={vi.fn()}725        onImport={onImport}726      />727    );728729    await selectImportFile(container);730731    const createButton = findButton(container, "Create vault + import 1");732    expect(createButton.disabled).toBe(true);733    expect(container.textContent).toContain("Restoring now replaces the locked local vault");734735    const inputs = container.querySelectorAll('input[type="password"]');736    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");737    inputText(inputs[1] as HTMLInputElement, "fresh-master-password");738    await flushView();739740    expect(createButton.disabled).toBe(false);741    createButton.click();742    await flushView();743744    expect(sendMessageMock).toHaveBeenCalledWith({745      type: "CREATE_VAULT",746      payload: { password: "fresh-master-password" },747    });748    expect(sendMessageMock).toHaveBeenCalledWith({749      type: "BATCH_ADD_ENTRIES",750      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },751    });752    expect(onImport).toHaveBeenCalled();753  });754755  it("Import encrypted backups reparse with the backup password before import", async () => {756    parseImportSourceMock757      .mockResolvedValueOnce(758        sampleImportResult({759          format: "peach-json",760          label: "Peach encrypted backup",761          entries: [],762          totalItems: 0,763          requiresPassword: true,764        })765      )766      .mockResolvedValueOnce(767        sampleImportResult({768          format: "peach-json",769          label: "Peach encrypted backup",770        })771      );772    sendMessageMock.mockImplementation(async (msg: unknown) => {773      const type = (msg as { type?: string }).type;774      if (type === "GET_ENTRIES") return { ok: true, data: [] };775      if (type === "BATCH_ADD_ENTRIES") {776        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };777      }778      return { ok: true };779    });780    const onImport = vi.fn(async () => undefined);781    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);782    await flushView();783    sendMessageMock.mockClear();784785    await selectImportFile(container, '{"type":"peach-export"}');786    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;787    inputText(password, "backup-passphrase");788    pressEnter(password);789    await flushView();790    await flushView();791792    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);793    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");794795    findButton(container, "Import 1").click();796    await flushView();797798    expect(sendMessageMock).toHaveBeenCalledWith({799      type: "BATCH_ADD_ENTRIES",800      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },801    });802    expect(onImport).toHaveBeenCalled();803  });804});