contracts.test.tsx

29.9 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { DEFAULT_BINDINGS } from "../../shared/shortcuts";12import { EntryView } from "./Entry";13import { ImportView } from "./Import";14import { VaultView } from "./Vault";15import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1617const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();18const triggerRingMock = vi.fn();19const triggerPipPulseMock = vi.fn();20const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();21const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2223vi.mock("../App", () => ({24  sendMessage: (msg: unknown) => sendMessageMock(msg),25  triggerRing: () => triggerRingMock(),26  triggerPipPulse: () => triggerPipPulseMock(),27}));2829vi.mock("../../shared/import", () => ({30  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),31}));3233vi.mock("../../shared/favicon", () => ({34  faviconUrl: () => null,35}));3637vi.mock("../../crypto/totp", () => ({38  generateTOTP: () => ({ code: "123 456", remaining: 24 }),39  parseOtpauthURI: (value: string) =>40    value.startsWith("otpauth://")41      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }42      : null,43}));4445vi.mock("../components/GeneratorDrawer", () => ({46  GeneratorDrawer: () => null,47}));4849vi.mock("zxcvbn", () => ({50  default: (value: string) => zxcvbnMock(value),51}));5253describe("popup contract tests", () => {54  const disposers: Array<() => void> = [];5556  beforeEach(() => {57    document.body.innerHTML = "";58    sendMessageMock.mockReset();59    triggerRingMock.mockReset();60    triggerPipPulseMock.mockReset();61    parseImportSourceMock.mockReset();62    zxcvbnMock.mockReset();63    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));64    parseImportSourceMock.mockResolvedValue({65      format: "unknown",66      label: "Unknown format",67      entries: [],68      totalItems: 0,69      skippedItems: 0,70      requiresPassword: false,71    });7273    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {74      return window.setTimeout(() => cb(0), 0) as unknown as number;75    });76    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));77    vi.stubGlobal("URL", {78      createObjectURL: vi.fn(() => "blob:peach-test"),79      revokeObjectURL: vi.fn(),80    });81    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8283    (globalThis as { chrome?: typeof chrome }).chrome = {84      runtime: {85        getManifest: () => ({ version: "1.4.47" }),86        onMessage: { addListener: vi.fn() },87      },88    } as unknown as typeof chrome;89  });9091  afterEach(() => {92    while (disposers.length > 0) {93      disposers.pop()?.();94    }95    vi.unstubAllGlobals();96  });9798  function mount(view: JSX.Element) {99    const mounted = renderView(view);100    disposers.push(mounted.dispose);101    return mounted.container;102  }103104  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {105    return {106      id: "entry-1",107      type: "login",108      title: "GitHub",109      username: "june",110      password: "secret123",111      url: "https://github.com",112      totp: "JBSWY3DPEHPK3PXP",113      canary: {114        webhookUrl: "https://example.com/hook",115      },116      createdAt: 1,117      modifiedAt: 1,118      ...overrides,119    };120  }121122  function sampleImportResult(123    overrides: Partial<ImportParseResult> = {}124  ): ImportParseResult {125    return {126      format: "csv",127      label: "CSV import",128      entries: [129        {130          type: "login",131          title: "Imported site",132          username: "imported-user",133          password: "imported-pass",134          url: "https://imported.test",135        },136      ],137      totalItems: 1,138      skippedItems: 0,139      requiresPassword: false,140      ...overrides,141    };142  }143144  async function selectImportFile(145    container: HTMLElement,146    contents = '{"export":"test"}'147  ) {148    // The Import view now opens onto a menu — click into the file subview149    // before locating the file input. Idempotent: a second click won't re-fire150    // because the menu unmounts once the subview renders.151    const fileCard = findButton(container, "Choose file →");152    if (fileCard) {153      fileCard.click();154      await flushView();155    }156    const input = container.querySelector('input[type="file"]') as HTMLInputElement;157    const file = new File([contents], "import.json", { type: "application/json" });158    Object.defineProperty(input, "files", {159      configurable: true,160      value: [file],161    });162    input.dispatchEvent(new Event("change", { bubbles: true }));163    await flushView();164    await flushView();165  }166167  function findButton(container: HTMLElement, text: string): HTMLButtonElement {168    return Array.from(container.querySelectorAll("button")).find(169      (candidate) => candidate.textContent?.trim() === text170    ) as HTMLButtonElement;171  }172173  it("Lock sends UNLOCK with the entered passphrase", async () => {174    sendMessageMock.mockResolvedValue({ ok: true });175    const onUnlock = vi.fn(async () => undefined);176    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);177178    const password = container.querySelector("#unlock-password") as HTMLInputElement;179    inputText(password, "correct horse battery staple");180    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();181    await flushView();182183    expect(sendMessageMock).toHaveBeenCalledWith({184      type: "UNLOCK",185      payload: { password: "correct horse battery staple" },186    });187    expect(onUnlock).toHaveBeenCalled();188  });189190  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {191    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);192    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;193    const password = container.querySelector("#master-password") as HTMLInputElement;194    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;195196    inputText(password, "short");197    inputText(confirm, "short");198    await flushView();199    expect(createButton.disabled).toBe(true);200201    zxcvbnMock.mockImplementation(() => ({ score: 1 }));202    inputText(password, "weakweakweak");203    inputText(confirm, "weakweakweak");204    await flushView();205    expect(createButton.disabled).toBe(true);206  });207208  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {209    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock210    sendMessageMock.mockImplementation(211      async (msg: unknown) => {212        const m = msg as { type: string; payload?: { password?: string } };213        if (m.type === "CREATE_VAULT") return { ok: true };214        if (m.type === "GET_RECOVERY_WORDS") {215          // Return 12 fake words — must pass findInvalidWords216          return {217            ok: true,218            data: [219              "abandon", "ability", "able", "about", "above", "absent",220              "absorb", "abstract", "absurd", "abuse", "access", "accident",221            ],222          };223        }224        return { ok: true };225      }226    );227    zxcvbnMock.mockImplementation(() => ({ score: 3 }));228    const onUnlock = vi.fn(async () => undefined);229    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);230231    const password = container.querySelector("#master-password") as HTMLInputElement;232    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;233    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;234235    inputText(password, "correct horse battery staple");236    inputText(confirm, "correct horse battery staple");237    await flushView();238    expect(createButton.disabled).toBe(false);239240    pressEnter(confirm);241    await flushView();242243    expect(sendMessageMock).toHaveBeenCalledWith({244      type: "CREATE_VAULT",245      payload: { password: "correct horse battery staple" },246    });247    // After CREATE_VAULT success, the recovery word display is shown.248    // onUnlock is NOT called yet — user must first confirm their recovery words.249    expect(onUnlock).not.toHaveBeenCalled();250  });251252  it("Export menu surfaces both backup formats", async () => {253    const container = mount(<ExportView onBack={vi.fn()} />);254    await flushView();255256    expect(container.textContent).toContain("PeachScript");257    expect(container.textContent).toContain("CSV");258  });259260  it("Settings requires current, new, and confirm passwords before enabling save", async () => {261    sendMessageMock.mockResolvedValue({ ok: true, data: null });262    const container = mount(263      <SettingsView264        entriesCount={1}265        breachMonitoring={true}266        lockOnClose={true}267        autoLockMinutes={5}268        trashRetentionDays={30}269        theme="dark"270        accentColor="peach"271        textSize="medium"272        reduceMotion={false}273        onBreachMonitoringChange={vi.fn()}274        onLockOnCloseChange={vi.fn()}275        onAutoLockMinutesChange={vi.fn()}276        onTrashRetentionChange={vi.fn()}277        onThemeChange={vi.fn()}278        onAccentChange={vi.fn()}279        onTextSizeChange={vi.fn()}280        onReduceMotionChange={vi.fn()}281        shortcuts={DEFAULT_BINDINGS}282        onShortcutsChange={vi.fn()}283        onBack={vi.fn()}284        onImport={vi.fn()}285        onExport={vi.fn()}286        onBreachCheckDone={vi.fn()}287      />288    );289    await flushView();290291    expect(container.querySelectorAll(".stk-card").length).toBe(7);292    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Protection");293294    const changeButton = Array.from(container.querySelectorAll("button")).find(295      (candidate) => candidate.textContent?.trim() === "change"296    ) as HTMLButtonElement;297    changeButton.click();298    await flushView();299300    const saveButton = Array.from(container.querySelectorAll("button")).find(301      (candidate) => candidate.textContent?.trim() === "save new password"302    ) as HTMLButtonElement;303    expect(saveButton.disabled).toBe(true);304305    const inputs = container.querySelectorAll('input[type="password"]');306    inputText(inputs[0] as HTMLInputElement, "current-pass");307    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");308    await flushView();309    expect(saveButton.disabled).toBe(true);310311    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");312    await flushView();313    expect(saveButton.disabled).toBe(false);314  });315316  it("Settings feedback highlights the selected category and hides the form after submit", async () => {317    sendMessageMock.mockResolvedValue({ ok: true, data: null });318    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({319      ok: true,320      json: async () => ({ success: true }),321    }));322    vi.stubGlobal("fetch", fetchMock);323324    const container = mount(325      <SettingsView326        entriesCount={1}327        breachMonitoring={true}328        lockOnClose={true}329        autoLockMinutes={5}330        trashRetentionDays={30}331        theme="dark"332        accentColor="peach"333        textSize="medium"334        reduceMotion={false}335        initialSection="feedback"336        onBreachMonitoringChange={vi.fn()}337        onLockOnCloseChange={vi.fn()}338        onAutoLockMinutesChange={vi.fn()}339        onTrashRetentionChange={vi.fn()}340        onThemeChange={vi.fn()}341        onAccentChange={vi.fn()}342        onTextSizeChange={vi.fn()}343        onReduceMotionChange={vi.fn()}344        shortcuts={DEFAULT_BINDINGS}345        onShortcutsChange={vi.fn()}346        onBack={vi.fn()}347        onImport={vi.fn()}348        onExport={vi.fn()}349        onBreachCheckDone={vi.fn()}350      />351    );352    await flushView();353354    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;355    expect(feedbackCard.classList.contains("is-active")).toBe(true);356357    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];358    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;359    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;360    expect(bugButton.getAttribute("aria-pressed")).toBe("true");361    expect(featureButton.getAttribute("aria-pressed")).toBe("false");362363    featureButton.click();364    await flushView();365366    expect(bugButton.getAttribute("aria-pressed")).toBe("false");367    expect(featureButton.getAttribute("aria-pressed")).toBe("true");368    expect(featureButton.classList.contains("is-active")).toBe(true);369370    inputText(371      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,372      '<script>alert("xss")</script> Please make this easier to see & safer.'373    );374    const sendButton = Array.from(container.querySelectorAll("button")).find(375      (candidate) => candidate.textContent?.trim() === "Send"376    ) as HTMLButtonElement;377    sendButton.click();378379    await vi.waitFor(() => {380      expect(container.textContent).toContain("Thank you");381    });382383    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));384    expect(requestBody.subject).toContain("Feature request");385    expect(requestBody.message).toBe(386      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."387    );388    expect(requestBody.message).not.toContain("<script>");389    expect(container.textContent).toContain("Someone will process it soon");390    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();391    expect(container.querySelector(".peach-feedback-submit")).toBeNull();392  });393394  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {395    sendMessageMock.mockImplementation(async (msg: unknown) => {396      const m = msg as { type: string };397      if (m.type === "GET_RECOVERY_WORDS") {398        return {399          ok: true,400          data: [401            "abandon", "ability", "able", "about", "above", "absent",402            "absorb", "abstract", "absurd", "abuse", "access", "accident",403          ],404        };405      }406      return { ok: true, data: null };407    });408    const onAutoLockMinutesChange = vi.fn();409    const onThemeChange = vi.fn();410    const container = mount(411      <SettingsView412        entriesCount={1}413        breachMonitoring={true}414        lockOnClose={true}415        autoLockMinutes={5}416        trashRetentionDays={30}417        theme="dark"418        accentColor="peach"419        textSize="medium"420        reduceMotion={false}421        onBreachMonitoringChange={vi.fn()}422        onLockOnCloseChange={vi.fn()}423        onAutoLockMinutesChange={onAutoLockMinutesChange}424        onTrashRetentionChange={vi.fn()}425        onThemeChange={onThemeChange}426        onAccentChange={vi.fn()}427        onTextSizeChange={vi.fn()}428        onReduceMotionChange={vi.fn()}429        shortcuts={DEFAULT_BINDINGS}430        onShortcutsChange={vi.fn()}431        onBack={vi.fn()}432        onImport={vi.fn()}433        onExport={vi.fn()}434        onBreachCheckDone={vi.fn()}435      />436    );437    await flushView();438    sendMessageMock.mockClear();439440    // ── Auto-lock + theme changes (before password change) ─────────────441    const selects = container.querySelectorAll("select");442    changeSelect(selects[0] as HTMLSelectElement, "15");443    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);444445    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;446    appearanceCard.click();447    await flushView();448    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");449    const themeButton = Array.from(container.querySelectorAll("button")).find(450      (candidate) => candidate.textContent?.trim() === "Switch to light"451    ) as HTMLButtonElement;452    themeButton.click();453    expect(onThemeChange).toHaveBeenCalledWith("light");454455    // ── Password change → recovery words display ────────────────────────456    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;457    protectionCard.click();458    await flushView();459460    const changeButton = Array.from(container.querySelectorAll("button")).find(461      (candidate) => candidate.textContent?.trim() === "change"462    ) as HTMLButtonElement;463    changeButton.click();464    await flushView();465466    const inputs = container.querySelectorAll('input[type="password"]');467    inputText(inputs[0] as HTMLInputElement, "current-pass");468    inputText(inputs[1] as HTMLInputElement, "new-password-123");469    inputText(inputs[2] as HTMLInputElement, "new-password-123");470    await flushView();471472    const saveButton = Array.from(container.querySelectorAll("button")).find(473      (candidate) => candidate.textContent?.trim() === "save new password"474    ) as HTMLButtonElement;475    saveButton.click();476    await flushView();477478    expect(sendMessageMock).toHaveBeenCalledWith({479      type: "CHANGE_PASSWORD",480      payload: {481        currentPassword: "current-pass",482        newPassword: "new-password-123",483      },484    });485486    // After a successful password change the recovery word display replaces settings.487    expect(container.textContent).toContain("save your recovery words");488    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });489  });490491  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {492    sendMessageMock.mockResolvedValue({ ok: true });493    const container = mount(494      <EntryView495        entry={sampleEntry()}496        onDelete={vi.fn()}497        onFieldSaved={vi.fn(async () => undefined)}498      />499    );500501    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;502    passwordButton.click();503    await flushView();504505    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {506      type: "COPY",507      payload: { text: "secret123", label: "password", alreadyWritten: false },508    });509    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {510      type: "FIRE_CANARY_PING",511      payload: {512        webhookUrl: "https://example.com/hook",513        entryTitle: "GitHub",514        trigger: "password_copied",515      },516    });517518    sendMessageMock.mockClear();519    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;520    usernameButton.click();521    await flushView();522523    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {524      type: "COPY",525      payload: { text: "june", label: "username", alreadyWritten: false },526    });527    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {528      type: "FIRE_CANARY_PING",529      payload: {530        webhookUrl: "https://example.com/hook",531        entryTitle: "GitHub",532        trigger: "username_copied",533      },534    });535  });536537  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {538    sendMessageMock.mockResolvedValue({ ok: true });539    const onFieldSaved = vi.fn(async () => undefined);540    const container = mount(541      <EntryView542        entry={sampleEntry()}543        onDelete={vi.fn()}544        onFieldSaved={onFieldSaved}545      />546    );547548    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;549    passwordValue.click();550    await flushView();551552    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;553    inputText(passwordInput, "");554    await flushView();555556    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;557    saveButton.click();558    await flushView();559560    expect(sendMessageMock).not.toHaveBeenCalledWith(561      expect.objectContaining({ type: "UPDATE_ENTRY" })562    );563    expect(onFieldSaved).not.toHaveBeenCalled();564565    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;566    totpMain.click();567568    await vi.waitFor(() => {569      expect(sendMessageMock).toHaveBeenLastCalledWith({570        type: "COPY",571        payload: { text: "123456", label: "totp", alreadyWritten: false },572      });573    }, { timeout: 500 });574  });575576  it("Entry closes an unchanged inline editor when focus leaves it", async () => {577    sendMessageMock.mockResolvedValue({ ok: true });578    const container = mount(579      <EntryView580        entry={sampleEntry()}581        onDelete={vi.fn()}582        onFieldSaved={vi.fn(async () => undefined)}583      />584    );585586    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;587    usernameValue.click();588    await flushView();589590    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;591    usernameInput.blur();592    await flushView();593594    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();595    expect(sendMessageMock).not.toHaveBeenCalledWith(596      expect.objectContaining({ type: "UPDATE_ENTRY" })597    );598  });599600  it("Entry lets TOTP secrets be edited inline", async () => {601    sendMessageMock.mockResolvedValue({ ok: true });602    const onFieldSaved = vi.fn(async () => undefined);603    const container = mount(604      <EntryView605        entry={sampleEntry()}606        onDelete={vi.fn()}607        onFieldSaved={onFieldSaved}608      />609    );610611    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;612    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));613    await flushView();614615    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;616    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");617    await flushView();618619    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;620    saveButton.click();621    await flushView();622623    expect(sendMessageMock).toHaveBeenCalledWith({624      type: "UPDATE_ENTRY",625      payload: {626        id: "entry-1",627        patch: { totp: "NB2W45DFOIZA====" },628      },629    });630    expect(onFieldSaved).toHaveBeenCalled();631  });632633  it("Vault seeds and focuses search when a search seed is provided", async () => {634    const container = mount(635      <VaultView636        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}637        sortOrder="url-first"638        searchSeed="g"639        searchSeedNonce={1}640        onSelectEntry={vi.fn()}641        onSortOrderChange={vi.fn()}642        onAdd={vi.fn()}643        onClose={vi.fn()}644      />645    );646647    await flushView();648649    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;650    expect(search.value).toBe("g");651    expect(document.activeElement).toBe(search);652  });653654  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {655    parseImportSourceMock.mockResolvedValue(656      sampleImportResult({657        entries: [658          {659            type: "login",660            title: "Imported site",661            username: "imported-user",662            password: "imported-pass",663            url: "https://imported.test",664          },665        ],666      })667    );668    sendMessageMock.mockImplementation(async (msg: unknown) => {669      const type = (msg as { type?: string }).type;670      if (type === "GET_ENTRIES") {671        return {672          ok: true,673          data: [674            sampleEntry({675              id: "existing-1",676              title: "Imported site",677              username: "imported-user",678              url: "https://imported.test",679            }),680          ],681        };682      }683      if (type === "BATCH_ADD_ENTRIES") {684        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };685      }686      return { ok: true };687    });688    const onImport = vi.fn(async () => undefined);689    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);690    await flushView();691    sendMessageMock.mockClear();692693    await selectImportFile(container);694    findButton(container, "Import 1").click();695    await flushView();696697    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);698    expect(sendMessageMock).toHaveBeenCalledWith({699      type: "BATCH_ADD_ENTRIES",700      payload: {701        entries: [702          expect.objectContaining({703            title: "Imported site",704            username: "imported-user",705            password: "imported-pass",706            url: "https://imported.test",707          }),708        ],709      },710    });711    expect(onImport).toHaveBeenCalled();712    expect(container.textContent).toContain("1 entry appears to be duplicates");713  });714715  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {716    parseImportSourceMock.mockResolvedValue(sampleImportResult());717    sendMessageMock.mockImplementation(async (msg: unknown) => {718      const type = (msg as { type?: string }).type;719      if (type === "GET_ENTRIES") {720        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };721      }722      if (type === "CLEAR_VAULT_ENTRIES") {723        return { ok: true };724      }725      if (type === "BATCH_ADD_ENTRIES") {726        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };727      }728      return { ok: true };729    });730    const onImport = vi.fn(async () => undefined);731    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);732    await flushView();733    sendMessageMock.mockClear();734735    await selectImportFile(container);736    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();737    await flushView();738739    findButton(container, "Replace vault with 1").click();740    await flushView();741742    expect(sendMessageMock).not.toHaveBeenCalledWith(743      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })744    );745    expect(container.textContent).toContain("Replace forever? This cannot be undone.");746747    findButton(container, "Yes, replace forever").click();748    await flushView();749750    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });751    expect(sendMessageMock).toHaveBeenCalledWith({752      type: "BATCH_ADD_ENTRIES",753      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },754    });755    expect(onImport).toHaveBeenCalled();756  });757758  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {759    parseImportSourceMock.mockResolvedValue(sampleImportResult());760    sendMessageMock.mockImplementation(async (msg: unknown) => {761      const type = (msg as { type?: string }).type;762      if (type === "CREATE_VAULT") return { ok: true };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(770      <ImportView771        mode="onboarding"772        replacesExistingVault={true}773        onBack={vi.fn()}774        onImport={onImport}775      />776    );777778    await selectImportFile(container);779780    const createButton = findButton(container, "Create vault + import 1");781    expect(createButton.disabled).toBe(true);782    expect(container.textContent).toContain("Restoring now replaces the locked local vault");783784    const masterInput = container.querySelector(785      'input[placeholder="new master password"]'786    ) as HTMLInputElement;787    const confirmInput = container.querySelector(788      'input[placeholder="confirm master password"]'789    ) as HTMLInputElement;790    inputText(masterInput, "fresh-master-password");791    inputText(confirmInput, "fresh-master-password");792    await flushView();793794    expect(createButton.disabled).toBe(false);795    createButton.click();796    await flushView();797798    expect(sendMessageMock).toHaveBeenCalledWith({799      type: "CREATE_VAULT",800      payload: { password: "fresh-master-password" },801    });802    expect(sendMessageMock).toHaveBeenCalledWith({803      type: "BATCH_ADD_ENTRIES",804      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },805    });806    expect(onImport).toHaveBeenCalled();807  });808809  it("Import encrypted backups reparse with the backup password before import", async () => {810    parseImportSourceMock811      .mockResolvedValueOnce(812        sampleImportResult({813          format: "peach-json",814          label: "Peach encrypted backup",815          entries: [],816          totalItems: 0,817          requiresPassword: true,818        })819      )820      .mockResolvedValueOnce(821        sampleImportResult({822          format: "peach-json",823          label: "Peach encrypted backup",824        })825      );826    sendMessageMock.mockImplementation(async (msg: unknown) => {827      const type = (msg as { type?: string }).type;828      if (type === "GET_ENTRIES") return { ok: true, data: [] };829      if (type === "BATCH_ADD_ENTRIES") {830        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };831      }832      return { ok: true };833    });834    const onImport = vi.fn(async () => undefined);835    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);836    await flushView();837    sendMessageMock.mockClear();838839    await selectImportFile(container, '{"type":"peach-export"}');840    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;841    inputText(password, "backup-passphrase");842    pressEnter(password);843    await flushView();844    await flushView();845846    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);847    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");848849    findButton(container, "Import 1").click();850    await flushView();851852    expect(sendMessageMock).toHaveBeenCalledWith({853      type: "BATCH_ADD_ENTRIES",854      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },855    });856    expect(onImport).toHaveBeenCalled();857  });858});