contracts.test.tsx

27.7 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      undefined304    );305  });306307  it("Settings requires current, new, and confirm passwords before enabling save", async () => {308    sendMessageMock.mockResolvedValue({ ok: true, data: null });309    const container = mount(310      <SettingsView311        entriesCount={1}312        breachMonitoring={true}313        lockOnClose={true}314        autoLockMinutes={5}315        trashRetentionDays={30}316        theme="dark"317        accentColor="peach"318        onBreachMonitoringChange={vi.fn()}319        onLockOnCloseChange={vi.fn()}320        onAutoLockMinutesChange={vi.fn()}321        onTrashRetentionChange={vi.fn()}322        onThemeChange={vi.fn()}323        onAccentChange={vi.fn()}324        shortcuts={DEFAULT_BINDINGS}325        onShortcutsChange={vi.fn()}326        onBack={vi.fn()}327        onImport={vi.fn()}328        onExport={vi.fn()}329        onBreachCheckDone={vi.fn()}330      />331    );332    await flushView();333334    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(335      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"336    ) as HTMLButtonElement;337    securityRow.click();338    await flushView();339340    const changeButton = Array.from(container.querySelectorAll("button")).find(341      (candidate) => candidate.textContent?.trim() === "change"342    ) as HTMLButtonElement;343    changeButton.click();344    await flushView();345346    const saveButton = Array.from(container.querySelectorAll("button")).find(347      (candidate) => candidate.textContent?.trim() === "save new password"348    ) as HTMLButtonElement;349    expect(saveButton.disabled).toBe(true);350351    const inputs = container.querySelectorAll('input[type="password"]');352    inputText(inputs[0] as HTMLInputElement, "current-pass");353    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");354    await flushView();355    expect(saveButton.disabled).toBe(true);356357    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");358    await flushView();359    expect(saveButton.disabled).toBe(false);360  });361362  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {363    sendMessageMock.mockResolvedValue({ ok: true, data: null });364    const onAutoLockMinutesChange = vi.fn();365    const onThemeChange = vi.fn();366    const container = mount(367      <SettingsView368        entriesCount={1}369        breachMonitoring={true}370        lockOnClose={true}371        autoLockMinutes={5}372        trashRetentionDays={30}373        theme="dark"374        accentColor="peach"375        onBreachMonitoringChange={vi.fn()}376        onLockOnCloseChange={vi.fn()}377        onAutoLockMinutesChange={onAutoLockMinutesChange}378        onTrashRetentionChange={vi.fn()}379        onThemeChange={onThemeChange}380        onAccentChange={vi.fn()}381        shortcuts={DEFAULT_BINDINGS}382        onShortcutsChange={vi.fn()}383        onBack={vi.fn()}384        onImport={vi.fn()}385        onExport={vi.fn()}386        onBreachCheckDone={vi.fn()}387      />388    );389    await flushView();390    sendMessageMock.mockClear();391392    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;393394    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(395      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"396    ) as HTMLButtonElement;397    securityRow.click();398    await flushView();399400    const selects = container.querySelectorAll("select");401    changeSelect(selects[0] as HTMLSelectElement, "15");402    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);403404    const changeButton = Array.from(container.querySelectorAll("button")).find(405      (candidate) => candidate.textContent?.trim() === "change"406    ) as HTMLButtonElement;407    changeButton.click();408    await flushView();409410    const inputs = container.querySelectorAll('input[type="password"]');411    inputText(inputs[0] as HTMLInputElement, "current-pass");412    inputText(inputs[1] as HTMLInputElement, "new-password-123");413    inputText(inputs[2] as HTMLInputElement, "new-password-123");414    await flushView();415416    const saveButton = Array.from(container.querySelectorAll("button")).find(417      (candidate) => candidate.textContent?.trim() === "save new password"418    ) as HTMLButtonElement;419    saveButton.click();420    await flushView();421422    headerBack.click();423    await flushView();424    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(425      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"426    ) as HTMLButtonElement;427    appearanceRow.click();428    await flushView();429    const themeButton = Array.from(container.querySelectorAll("button")).find(430      (candidate) => candidate.textContent?.trim() === "Switch to light"431    ) as HTMLButtonElement;432    themeButton.click();433    expect(onThemeChange).toHaveBeenCalledWith("light");434435    expect(sendMessageMock).toHaveBeenCalledWith({436      type: "CHANGE_PASSWORD",437      payload: {438        currentPassword: "current-pass",439        newPassword: "new-password-123",440      },441    });442  });443444  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {445    sendMessageMock.mockResolvedValue({ ok: true });446    const container = mount(447      <EntryView448        entry={sampleEntry()}449        onDelete={vi.fn()}450        onFieldSaved={vi.fn(async () => undefined)}451      />452    );453454    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;455    passwordButton.click();456    await flushView();457458    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {459      type: "COPY",460      payload: { text: "secret123", label: "password", alreadyWritten: false },461    });462    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {463      type: "FIRE_CANARY_PING",464      payload: {465        webhookUrl: "https://example.com/hook",466        entryTitle: "GitHub",467        trigger: "password_copied",468      },469    });470471    sendMessageMock.mockClear();472    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;473    usernameButton.click();474    await flushView();475476    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {477      type: "COPY",478      payload: { text: "june", label: "username", alreadyWritten: false },479    });480    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {481      type: "FIRE_CANARY_PING",482      payload: {483        webhookUrl: "https://example.com/hook",484        entryTitle: "GitHub",485        trigger: "username_copied",486      },487    });488  });489490  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {491    sendMessageMock.mockResolvedValue({ ok: true });492    const onFieldSaved = vi.fn(async () => undefined);493    const container = mount(494      <EntryView495        entry={sampleEntry()}496        onDelete={vi.fn()}497        onFieldSaved={onFieldSaved}498      />499    );500501    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;502    passwordValue.click();503    await flushView();504505    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;506    inputText(passwordInput, "");507    await flushView();508509    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;510    saveButton.click();511    await flushView();512513    expect(sendMessageMock).not.toHaveBeenCalledWith(514      expect.objectContaining({ type: "UPDATE_ENTRY" })515    );516    expect(onFieldSaved).not.toHaveBeenCalled();517518    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;519    totpMain.click();520    await flushView();521522    expect(sendMessageMock).toHaveBeenLastCalledWith({523      type: "COPY",524      payload: { text: "123456", label: "totp", alreadyWritten: false },525    });526  });527528  it("Entry closes an unchanged inline editor when focus leaves it", async () => {529    sendMessageMock.mockResolvedValue({ ok: true });530    const container = mount(531      <EntryView532        entry={sampleEntry()}533        onDelete={vi.fn()}534        onFieldSaved={vi.fn(async () => undefined)}535      />536    );537538    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;539    usernameValue.click();540    await flushView();541542    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;543    usernameInput.blur();544    await flushView();545546    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();547    expect(sendMessageMock).not.toHaveBeenCalledWith(548      expect.objectContaining({ type: "UPDATE_ENTRY" })549    );550  });551552  it("Entry lets TOTP secrets be edited inline", async () => {553    sendMessageMock.mockResolvedValue({ ok: true });554    const onFieldSaved = vi.fn(async () => undefined);555    const container = mount(556      <EntryView557        entry={sampleEntry()}558        onDelete={vi.fn()}559        onFieldSaved={onFieldSaved}560      />561    );562563    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;564    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));565    await flushView();566567    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;568    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");569    await flushView();570571    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;572    saveButton.click();573    await flushView();574575    expect(sendMessageMock).toHaveBeenCalledWith({576      type: "UPDATE_ENTRY",577      payload: {578        id: "entry-1",579        patch: { totp: "NB2W45DFOIZA====" },580      },581    });582    expect(onFieldSaved).toHaveBeenCalled();583  });584585  it("Vault seeds and focuses search when a search seed is provided", async () => {586    const container = mount(587      <VaultView588        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}589        sortOrder="url-first"590        searchSeed="g"591        searchSeedNonce={1}592        onSelectEntry={vi.fn()}593        onSortOrderChange={vi.fn()}594        onAdd={vi.fn()}595        onClose={vi.fn()}596      />597    );598599    await flushView();600601    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;602    expect(search.value).toBe("g");603    expect(document.activeElement).toBe(search);604  });605606  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {607    parseImportSourceMock.mockResolvedValue(608      sampleImportResult({609        entries: [610          {611            type: "login",612            title: "Imported site",613            username: "imported-user",614            password: "imported-pass",615            url: "https://imported.test",616          },617        ],618      })619    );620    sendMessageMock.mockImplementation(async (msg: unknown) => {621      const type = (msg as { type?: string }).type;622      if (type === "GET_ENTRIES") {623        return {624          ok: true,625          data: [626            sampleEntry({627              id: "existing-1",628              title: "Imported site",629              username: "imported-user",630              url: "https://imported.test",631            }),632          ],633        };634      }635      if (type === "BATCH_ADD_ENTRIES") {636        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };637      }638      return { ok: true };639    });640    const onImport = vi.fn(async () => undefined);641    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);642    await flushView();643    sendMessageMock.mockClear();644645    await selectImportFile(container);646    findButton(container, "Import 1").click();647    await flushView();648649    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);650    expect(sendMessageMock).toHaveBeenCalledWith({651      type: "BATCH_ADD_ENTRIES",652      payload: {653        entries: [654          expect.objectContaining({655            title: "Imported site",656            username: "imported-user",657            password: "imported-pass",658            url: "https://imported.test",659          }),660        ],661      },662    });663    expect(onImport).toHaveBeenCalled();664    expect(container.textContent).toContain("1 entry appears to be duplicates");665  });666667  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {668    parseImportSourceMock.mockResolvedValue(sampleImportResult());669    sendMessageMock.mockImplementation(async (msg: unknown) => {670      const type = (msg as { type?: string }).type;671      if (type === "GET_ENTRIES") {672        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };673      }674      if (type === "CLEAR_VAULT_ENTRIES") {675        return { ok: true };676      }677      if (type === "BATCH_ADD_ENTRIES") {678        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };679      }680      return { ok: true };681    });682    const onImport = vi.fn(async () => undefined);683    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);684    await flushView();685    sendMessageMock.mockClear();686687    await selectImportFile(container);688    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();689    await flushView();690691    findButton(container, "Replace vault with 1").click();692    await flushView();693694    expect(sendMessageMock).not.toHaveBeenCalledWith(695      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })696    );697    expect(container.textContent).toContain("Replace forever? This cannot be undone.");698699    findButton(container, "Yes, replace forever").click();700    await flushView();701702    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });703    expect(sendMessageMock).toHaveBeenCalledWith({704      type: "BATCH_ADD_ENTRIES",705      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },706    });707    expect(onImport).toHaveBeenCalled();708  });709710  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {711    parseImportSourceMock.mockResolvedValue(sampleImportResult());712    sendMessageMock.mockImplementation(async (msg: unknown) => {713      const type = (msg as { type?: string }).type;714      if (type === "CREATE_VAULT") return { ok: true };715      if (type === "BATCH_ADD_ENTRIES") {716        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };717      }718      return { ok: true };719    });720    const onImport = vi.fn(async () => undefined);721    const container = mount(722      <ImportView723        mode="onboarding"724        replacesExistingVault={true}725        onBack={vi.fn()}726        onImport={onImport}727      />728    );729730    await selectImportFile(container);731732    const createButton = findButton(container, "Create vault + import 1");733    expect(createButton.disabled).toBe(true);734    expect(container.textContent).toContain("Restoring now replaces the locked local vault");735736    const inputs = container.querySelectorAll('input[type="password"]');737    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");738    inputText(inputs[1] as HTMLInputElement, "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});