contracts.test.tsx

31.2 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("Lock onboarding restore options include Peach Codex", async () => {261    const onOpenCodexRestore = vi.fn();262    const container = mount(263      <LockView264        hasVault={false}265        ready={true}266        onUnlock={vi.fn()}267        onOpenCodexRestore={onOpenCodexRestore}268      />269    );270271    const restoreButton = Array.from(container.querySelectorAll("button")).find(272      (candidate) => candidate.textContent?.includes("restore from backup")273    ) as HTMLButtonElement;274    restoreButton.click();275    await flushView();276277    expect(container.textContent).toContain("PeachScript");278    expect(container.textContent).toContain("Codex");279    expect(container.textContent).toContain("CSV");280281    const codexTab = Array.from(container.querySelectorAll("button")).find(282      (candidate) => candidate.textContent?.trim() === "Codex"283    ) as HTMLButtonElement;284    codexTab.click();285    await flushView();286287    expect(container.textContent).toContain("Restore from Peach Codex");288    expect(container.textContent).toContain("Scan printed Codex pages");289290    const openCodexButton = Array.from(container.querySelectorAll("button")).find(291      (candidate) => candidate.textContent?.trim() === "open Codex restore →"292    ) as HTMLButtonElement;293    openCodexButton.click();294295    expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);296  });297298  it("Settings requires current, new, and confirm passwords before enabling save", async () => {299    sendMessageMock.mockResolvedValue({ ok: true, data: null });300    const container = mount(301      <SettingsView302        entriesCount={1}303        breachMonitoring={true}304        lockOnClose={true}305        autoLockMinutes={5}306        trashRetentionDays={30}307        theme="dark"308        accentColor="peach"309        textSize="medium"310        reduceMotion={false}311        onBreachMonitoringChange={vi.fn()}312        onLockOnCloseChange={vi.fn()}313        onAutoLockMinutesChange={vi.fn()}314        onTrashRetentionChange={vi.fn()}315        onThemeChange={vi.fn()}316        onAccentChange={vi.fn()}317        onTextSizeChange={vi.fn()}318        onReduceMotionChange={vi.fn()}319        shortcuts={DEFAULT_BINDINGS}320        onShortcutsChange={vi.fn()}321        onBack={vi.fn()}322        onImport={vi.fn()}323        onExport={vi.fn()}324        onBreachCheckDone={vi.fn()}325      />326    );327    await flushView();328329    expect(container.querySelectorAll(".stk-card").length).toBe(7);330    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Protection");331332    const changeButton = Array.from(container.querySelectorAll("button")).find(333      (candidate) => candidate.textContent?.trim() === "change"334    ) as HTMLButtonElement;335    changeButton.click();336    await flushView();337338    const saveButton = Array.from(container.querySelectorAll("button")).find(339      (candidate) => candidate.textContent?.trim() === "save new password"340    ) as HTMLButtonElement;341    expect(saveButton.disabled).toBe(true);342343    const inputs = container.querySelectorAll('input[type="password"]');344    inputText(inputs[0] as HTMLInputElement, "current-pass");345    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");346    await flushView();347    expect(saveButton.disabled).toBe(true);348349    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");350    await flushView();351    expect(saveButton.disabled).toBe(false);352  });353354  it("Settings feedback highlights the selected category and hides the form after submit", async () => {355    sendMessageMock.mockResolvedValue({ ok: true, data: null });356    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({357      ok: true,358      json: async () => ({ success: true }),359    }));360    vi.stubGlobal("fetch", fetchMock);361362    const container = mount(363      <SettingsView364        entriesCount={1}365        breachMonitoring={true}366        lockOnClose={true}367        autoLockMinutes={5}368        trashRetentionDays={30}369        theme="dark"370        accentColor="peach"371        textSize="medium"372        reduceMotion={false}373        initialSection="feedback"374        onBreachMonitoringChange={vi.fn()}375        onLockOnCloseChange={vi.fn()}376        onAutoLockMinutesChange={vi.fn()}377        onTrashRetentionChange={vi.fn()}378        onThemeChange={vi.fn()}379        onAccentChange={vi.fn()}380        onTextSizeChange={vi.fn()}381        onReduceMotionChange={vi.fn()}382        shortcuts={DEFAULT_BINDINGS}383        onShortcutsChange={vi.fn()}384        onBack={vi.fn()}385        onImport={vi.fn()}386        onExport={vi.fn()}387        onBreachCheckDone={vi.fn()}388      />389    );390    await flushView();391392    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;393    expect(feedbackCard.classList.contains("is-active")).toBe(true);394395    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];396    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;397    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;398    expect(bugButton.getAttribute("aria-pressed")).toBe("true");399    expect(featureButton.getAttribute("aria-pressed")).toBe("false");400401    featureButton.click();402    await flushView();403404    expect(bugButton.getAttribute("aria-pressed")).toBe("false");405    expect(featureButton.getAttribute("aria-pressed")).toBe("true");406    expect(featureButton.classList.contains("is-active")).toBe(true);407408    inputText(409      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,410      '<script>alert("xss")</script> Please make this easier to see & safer.'411    );412    const sendButton = Array.from(container.querySelectorAll("button")).find(413      (candidate) => candidate.textContent?.trim() === "Send"414    ) as HTMLButtonElement;415    sendButton.click();416417    await vi.waitFor(() => {418      expect(container.textContent).toContain("Thank you");419    });420421    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));422    expect(requestBody.subject).toContain("Feature request");423    expect(requestBody.message).toBe(424      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."425    );426    expect(requestBody.message).not.toContain("<script>");427    expect(container.textContent).toContain("Someone will process it soon");428    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();429    expect(container.querySelector(".peach-feedback-submit")).toBeNull();430  });431432  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {433    sendMessageMock.mockImplementation(async (msg: unknown) => {434      const m = msg as { type: string };435      if (m.type === "GET_RECOVERY_WORDS") {436        return {437          ok: true,438          data: [439            "abandon", "ability", "able", "about", "above", "absent",440            "absorb", "abstract", "absurd", "abuse", "access", "accident",441          ],442        };443      }444      return { ok: true, data: null };445    });446    const onAutoLockMinutesChange = vi.fn();447    const onThemeChange = vi.fn();448    const container = mount(449      <SettingsView450        entriesCount={1}451        breachMonitoring={true}452        lockOnClose={true}453        autoLockMinutes={5}454        trashRetentionDays={30}455        theme="dark"456        accentColor="peach"457        textSize="medium"458        reduceMotion={false}459        onBreachMonitoringChange={vi.fn()}460        onLockOnCloseChange={vi.fn()}461        onAutoLockMinutesChange={onAutoLockMinutesChange}462        onTrashRetentionChange={vi.fn()}463        onThemeChange={onThemeChange}464        onAccentChange={vi.fn()}465        onTextSizeChange={vi.fn()}466        onReduceMotionChange={vi.fn()}467        shortcuts={DEFAULT_BINDINGS}468        onShortcutsChange={vi.fn()}469        onBack={vi.fn()}470        onImport={vi.fn()}471        onExport={vi.fn()}472        onBreachCheckDone={vi.fn()}473      />474    );475    await flushView();476    sendMessageMock.mockClear();477478    // ── Auto-lock + theme changes (before password change) ─────────────479    const selects = container.querySelectorAll("select");480    changeSelect(selects[0] as HTMLSelectElement, "15");481    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);482483    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;484    appearanceCard.click();485    await flushView();486    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");487    const themeButton = Array.from(container.querySelectorAll("button")).find(488      (candidate) => candidate.textContent?.trim() === "Switch to light"489    ) as HTMLButtonElement;490    themeButton.click();491    expect(onThemeChange).toHaveBeenCalledWith("light");492493    // ── Password change → recovery words display ────────────────────────494    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;495    protectionCard.click();496    await flushView();497498    const changeButton = Array.from(container.querySelectorAll("button")).find(499      (candidate) => candidate.textContent?.trim() === "change"500    ) as HTMLButtonElement;501    changeButton.click();502    await flushView();503504    const inputs = container.querySelectorAll('input[type="password"]');505    inputText(inputs[0] as HTMLInputElement, "current-pass");506    inputText(inputs[1] as HTMLInputElement, "new-password-123");507    inputText(inputs[2] as HTMLInputElement, "new-password-123");508    await flushView();509510    const saveButton = Array.from(container.querySelectorAll("button")).find(511      (candidate) => candidate.textContent?.trim() === "save new password"512    ) as HTMLButtonElement;513    saveButton.click();514    await flushView();515516    expect(sendMessageMock).toHaveBeenCalledWith({517      type: "CHANGE_PASSWORD",518      payload: {519        currentPassword: "current-pass",520        newPassword: "new-password-123",521      },522    });523524    // After a successful password change the recovery word display replaces settings.525    expect(container.textContent).toContain("save your recovery words");526    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });527  });528529  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {530    sendMessageMock.mockResolvedValue({ ok: true });531    const container = mount(532      <EntryView533        entry={sampleEntry()}534        onDelete={vi.fn()}535        onFieldSaved={vi.fn(async () => undefined)}536      />537    );538539    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;540    passwordButton.click();541    await flushView();542543    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {544      type: "COPY",545      payload: { text: "secret123", label: "password", alreadyWritten: false },546    });547    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {548      type: "FIRE_CANARY_PING",549      payload: {550        webhookUrl: "https://example.com/hook",551        entryTitle: "GitHub",552        trigger: "password_copied",553      },554    });555556    sendMessageMock.mockClear();557    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;558    usernameButton.click();559    await flushView();560561    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {562      type: "COPY",563      payload: { text: "june", label: "username", alreadyWritten: false },564    });565    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {566      type: "FIRE_CANARY_PING",567      payload: {568        webhookUrl: "https://example.com/hook",569        entryTitle: "GitHub",570        trigger: "username_copied",571      },572    });573  });574575  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {576    sendMessageMock.mockResolvedValue({ ok: true });577    const onFieldSaved = vi.fn(async () => undefined);578    const container = mount(579      <EntryView580        entry={sampleEntry()}581        onDelete={vi.fn()}582        onFieldSaved={onFieldSaved}583      />584    );585586    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;587    passwordValue.click();588    await flushView();589590    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;591    inputText(passwordInput, "");592    await flushView();593594    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;595    saveButton.click();596    await flushView();597598    expect(sendMessageMock).not.toHaveBeenCalledWith(599      expect.objectContaining({ type: "UPDATE_ENTRY" })600    );601    expect(onFieldSaved).not.toHaveBeenCalled();602603    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;604    totpMain.click();605606    await vi.waitFor(() => {607      expect(sendMessageMock).toHaveBeenLastCalledWith({608        type: "COPY",609        payload: { text: "123456", label: "totp", alreadyWritten: false },610      });611    }, { timeout: 500 });612  });613614  it("Entry closes an unchanged inline editor when focus leaves it", async () => {615    sendMessageMock.mockResolvedValue({ ok: true });616    const container = mount(617      <EntryView618        entry={sampleEntry()}619        onDelete={vi.fn()}620        onFieldSaved={vi.fn(async () => undefined)}621      />622    );623624    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;625    usernameValue.click();626    await flushView();627628    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;629    usernameInput.blur();630    await flushView();631632    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();633    expect(sendMessageMock).not.toHaveBeenCalledWith(634      expect.objectContaining({ type: "UPDATE_ENTRY" })635    );636  });637638  it("Entry lets TOTP secrets be edited inline", async () => {639    sendMessageMock.mockResolvedValue({ ok: true });640    const onFieldSaved = vi.fn(async () => undefined);641    const container = mount(642      <EntryView643        entry={sampleEntry()}644        onDelete={vi.fn()}645        onFieldSaved={onFieldSaved}646      />647    );648649    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;650    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));651    await flushView();652653    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;654    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");655    await flushView();656657    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;658    saveButton.click();659    await flushView();660661    expect(sendMessageMock).toHaveBeenCalledWith({662      type: "UPDATE_ENTRY",663      payload: {664        id: "entry-1",665        patch: { totp: "NB2W45DFOIZA====" },666      },667    });668    expect(onFieldSaved).toHaveBeenCalled();669  });670671  it("Vault seeds and focuses search when a search seed is provided", async () => {672    const container = mount(673      <VaultView674        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}675        sortOrder="url-first"676        searchSeed="g"677        searchSeedNonce={1}678        onSelectEntry={vi.fn()}679        onSortOrderChange={vi.fn()}680        onAdd={vi.fn()}681        onClose={vi.fn()}682      />683    );684685    await flushView();686687    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;688    expect(search.value).toBe("g");689    expect(document.activeElement).toBe(search);690  });691692  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {693    parseImportSourceMock.mockResolvedValue(694      sampleImportResult({695        entries: [696          {697            type: "login",698            title: "Imported site",699            username: "imported-user",700            password: "imported-pass",701            url: "https://imported.test",702          },703        ],704      })705    );706    sendMessageMock.mockImplementation(async (msg: unknown) => {707      const type = (msg as { type?: string }).type;708      if (type === "GET_ENTRIES") {709        return {710          ok: true,711          data: [712            sampleEntry({713              id: "existing-1",714              title: "Imported site",715              username: "imported-user",716              url: "https://imported.test",717            }),718          ],719        };720      }721      if (type === "BATCH_ADD_ENTRIES") {722        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };723      }724      return { ok: true };725    });726    const onImport = vi.fn(async () => undefined);727    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);728    await flushView();729    sendMessageMock.mockClear();730731    await selectImportFile(container);732    findButton(container, "Import 1").click();733    await flushView();734735    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);736    expect(sendMessageMock).toHaveBeenCalledWith({737      type: "BATCH_ADD_ENTRIES",738      payload: {739        entries: [740          expect.objectContaining({741            title: "Imported site",742            username: "imported-user",743            password: "imported-pass",744            url: "https://imported.test",745          }),746        ],747      },748    });749    expect(onImport).toHaveBeenCalled();750    expect(container.textContent).toContain("1 entry appears to be duplicates");751  });752753  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {754    parseImportSourceMock.mockResolvedValue(sampleImportResult());755    sendMessageMock.mockImplementation(async (msg: unknown) => {756      const type = (msg as { type?: string }).type;757      if (type === "GET_ENTRIES") {758        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };759      }760      if (type === "CLEAR_VAULT_ENTRIES") {761        return { ok: true };762      }763      if (type === "BATCH_ADD_ENTRIES") {764        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };765      }766      return { ok: true };767    });768    const onImport = vi.fn(async () => undefined);769    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);770    await flushView();771    sendMessageMock.mockClear();772773    await selectImportFile(container);774    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();775    await flushView();776777    findButton(container, "Replace vault with 1").click();778    await flushView();779780    expect(sendMessageMock).not.toHaveBeenCalledWith(781      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })782    );783    expect(container.textContent).toContain("Replace forever? This cannot be undone.");784785    findButton(container, "Yes, replace forever").click();786    await flushView();787788    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });789    expect(sendMessageMock).toHaveBeenCalledWith({790      type: "BATCH_ADD_ENTRIES",791      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },792    });793    expect(onImport).toHaveBeenCalled();794  });795796  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {797    parseImportSourceMock.mockResolvedValue(sampleImportResult());798    sendMessageMock.mockImplementation(async (msg: unknown) => {799      const type = (msg as { type?: string }).type;800      if (type === "CREATE_VAULT") return { ok: true };801      if (type === "BATCH_ADD_ENTRIES") {802        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };803      }804      return { ok: true };805    });806    const onImport = vi.fn(async () => undefined);807    const container = mount(808      <ImportView809        mode="onboarding"810        replacesExistingVault={true}811        onBack={vi.fn()}812        onImport={onImport}813      />814    );815816    await selectImportFile(container);817818    const createButton = findButton(container, "Create vault + import 1");819    expect(createButton.disabled).toBe(true);820    expect(container.textContent).toContain("Restoring now replaces the locked local vault");821822    const masterInput = container.querySelector(823      'input[placeholder="new master password"]'824    ) as HTMLInputElement;825    const confirmInput = container.querySelector(826      'input[placeholder="confirm master password"]'827    ) as HTMLInputElement;828    inputText(masterInput, "fresh-master-password");829    inputText(confirmInput, "fresh-master-password");830    await flushView();831832    expect(createButton.disabled).toBe(false);833    createButton.click();834    await flushView();835836    expect(sendMessageMock).toHaveBeenCalledWith({837      type: "CREATE_VAULT",838      payload: { password: "fresh-master-password" },839    });840    expect(sendMessageMock).toHaveBeenCalledWith({841      type: "BATCH_ADD_ENTRIES",842      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },843    });844    expect(onImport).toHaveBeenCalled();845  });846847  it("Import encrypted backups reparse with the backup password before import", async () => {848    parseImportSourceMock849      .mockResolvedValueOnce(850        sampleImportResult({851          format: "peach-json",852          label: "Peach encrypted backup",853          entries: [],854          totalItems: 0,855          requiresPassword: true,856        })857      )858      .mockResolvedValueOnce(859        sampleImportResult({860          format: "peach-json",861          label: "Peach encrypted backup",862        })863      );864    sendMessageMock.mockImplementation(async (msg: unknown) => {865      const type = (msg as { type?: string }).type;866      if (type === "GET_ENTRIES") return { ok: true, data: [] };867      if (type === "BATCH_ADD_ENTRIES") {868        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };869      }870      return { ok: true };871    });872    const onImport = vi.fn(async () => undefined);873    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);874    await flushView();875    sendMessageMock.mockClear();876877    await selectImportFile(container, '{"type":"peach-export"}');878    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;879    inputText(password, "backup-passphrase");880    pressEnter(password);881    await flushView();882    await flushView();883884    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);885    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");886887    findButton(container, "Import 1").click();888    await flushView();889890    expect(sendMessageMock).toHaveBeenCalledWith({891      type: "BATCH_ADD_ENTRIES",892      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },893    });894    expect(onImport).toHaveBeenCalled();895  });896});