contracts.test.tsx

33.1 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 feedback does not transmit when Firefox data consent is denied", async () => {433    sendMessageMock.mockResolvedValue({ ok: true, data: null });434    const fetchMock = vi.fn();435    const requestMock = vi.fn(async () => false);436    vi.stubGlobal("fetch", fetchMock);437    vi.stubGlobal("browser", {438      permissions: {439        getAll: vi.fn(async () => ({ data_collection: [] })),440        request: requestMock,441      },442    });443444    const container = mount(445      <SettingsView446        entriesCount={1}447        breachMonitoring={true}448        lockOnClose={true}449        autoLockMinutes={5}450        trashRetentionDays={30}451        theme="dark"452        accentColor="peach"453        textSize="medium"454        reduceMotion={false}455        initialSection="feedback"456        onBreachMonitoringChange={vi.fn()}457        onLockOnCloseChange={vi.fn()}458        onAutoLockMinutesChange={vi.fn()}459        onTrashRetentionChange={vi.fn()}460        onThemeChange={vi.fn()}461        onAccentChange={vi.fn()}462        onTextSizeChange={vi.fn()}463        onReduceMotionChange={vi.fn()}464        shortcuts={DEFAULT_BINDINGS}465        onShortcutsChange={vi.fn()}466        onBack={vi.fn()}467        onImport={vi.fn()}468        onExport={vi.fn()}469        onBreachCheckDone={vi.fn()}470      />471    );472    await flushView();473474    inputText(475      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,476      "Please improve the feedback flow."477    );478    const sendButton = Array.from(container.querySelectorAll("button")).find(479      (candidate) => candidate.textContent?.trim() === "Send"480    ) as HTMLButtonElement;481    sendButton.click();482483    await vi.waitFor(() => {484      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });485    });486    expect(fetchMock).not.toHaveBeenCalled();487    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();488  });489490  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {491    sendMessageMock.mockImplementation(async (msg: unknown) => {492      const m = msg as { type: string };493      if (m.type === "GET_RECOVERY_WORDS") {494        return {495          ok: true,496          data: [497            "abandon", "ability", "able", "about", "above", "absent",498            "absorb", "abstract", "absurd", "abuse", "access", "accident",499          ],500        };501      }502      return { ok: true, data: null };503    });504    const onAutoLockMinutesChange = vi.fn();505    const onThemeChange = vi.fn();506    const container = mount(507      <SettingsView508        entriesCount={1}509        breachMonitoring={true}510        lockOnClose={true}511        autoLockMinutes={5}512        trashRetentionDays={30}513        theme="dark"514        accentColor="peach"515        textSize="medium"516        reduceMotion={false}517        onBreachMonitoringChange={vi.fn()}518        onLockOnCloseChange={vi.fn()}519        onAutoLockMinutesChange={onAutoLockMinutesChange}520        onTrashRetentionChange={vi.fn()}521        onThemeChange={onThemeChange}522        onAccentChange={vi.fn()}523        onTextSizeChange={vi.fn()}524        onReduceMotionChange={vi.fn()}525        shortcuts={DEFAULT_BINDINGS}526        onShortcutsChange={vi.fn()}527        onBack={vi.fn()}528        onImport={vi.fn()}529        onExport={vi.fn()}530        onBreachCheckDone={vi.fn()}531      />532    );533    await flushView();534    sendMessageMock.mockClear();535536    // ── Auto-lock + theme changes (before password change) ─────────────537    const selects = container.querySelectorAll("select");538    changeSelect(selects[0] as HTMLSelectElement, "15");539    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);540541    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;542    appearanceCard.click();543    await flushView();544    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");545    const themeButton = Array.from(container.querySelectorAll("button")).find(546      (candidate) => candidate.textContent?.trim() === "Switch to light"547    ) as HTMLButtonElement;548    themeButton.click();549    expect(onThemeChange).toHaveBeenCalledWith("light");550551    // ── Password change → recovery words display ────────────────────────552    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;553    protectionCard.click();554    await flushView();555556    const changeButton = Array.from(container.querySelectorAll("button")).find(557      (candidate) => candidate.textContent?.trim() === "change"558    ) as HTMLButtonElement;559    changeButton.click();560    await flushView();561562    const inputs = container.querySelectorAll('input[type="password"]');563    inputText(inputs[0] as HTMLInputElement, "current-pass");564    inputText(inputs[1] as HTMLInputElement, "new-password-123");565    inputText(inputs[2] as HTMLInputElement, "new-password-123");566    await flushView();567568    const saveButton = Array.from(container.querySelectorAll("button")).find(569      (candidate) => candidate.textContent?.trim() === "save new password"570    ) as HTMLButtonElement;571    saveButton.click();572    await flushView();573574    expect(sendMessageMock).toHaveBeenCalledWith({575      type: "CHANGE_PASSWORD",576      payload: {577        currentPassword: "current-pass",578        newPassword: "new-password-123",579      },580    });581582    // After a successful password change the recovery word display replaces settings.583    expect(container.textContent).toContain("save your recovery words");584    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });585  });586587  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {588    sendMessageMock.mockResolvedValue({ ok: true });589    const container = mount(590      <EntryView591        entry={sampleEntry()}592        onDelete={vi.fn()}593        onFieldSaved={vi.fn(async () => undefined)}594      />595    );596597    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;598    passwordButton.click();599    await flushView();600601    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {602      type: "COPY",603      payload: { text: "secret123", label: "password", alreadyWritten: false },604    });605    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {606      type: "FIRE_CANARY_PING",607      payload: {608        webhookUrl: "https://example.com/hook",609        entryTitle: "GitHub",610        trigger: "password_copied",611      },612    });613614    sendMessageMock.mockClear();615    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;616    usernameButton.click();617    await flushView();618619    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {620      type: "COPY",621      payload: { text: "june", label: "username", alreadyWritten: false },622    });623    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {624      type: "FIRE_CANARY_PING",625      payload: {626        webhookUrl: "https://example.com/hook",627        entryTitle: "GitHub",628        trigger: "username_copied",629      },630    });631  });632633  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {634    sendMessageMock.mockResolvedValue({ ok: true });635    const onFieldSaved = vi.fn(async () => undefined);636    const container = mount(637      <EntryView638        entry={sampleEntry()}639        onDelete={vi.fn()}640        onFieldSaved={onFieldSaved}641      />642    );643644    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;645    passwordValue.click();646    await flushView();647648    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;649    inputText(passwordInput, "");650    await flushView();651652    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;653    saveButton.click();654    await flushView();655656    expect(sendMessageMock).not.toHaveBeenCalledWith(657      expect.objectContaining({ type: "UPDATE_ENTRY" })658    );659    expect(onFieldSaved).not.toHaveBeenCalled();660661    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;662    totpMain.click();663664    await vi.waitFor(() => {665      expect(sendMessageMock).toHaveBeenLastCalledWith({666        type: "COPY",667        payload: { text: "123456", label: "totp", alreadyWritten: false },668      });669    }, { timeout: 500 });670  });671672  it("Entry closes an unchanged inline editor when focus leaves it", async () => {673    sendMessageMock.mockResolvedValue({ ok: true });674    const container = mount(675      <EntryView676        entry={sampleEntry()}677        onDelete={vi.fn()}678        onFieldSaved={vi.fn(async () => undefined)}679      />680    );681682    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;683    usernameValue.click();684    await flushView();685686    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;687    usernameInput.blur();688    await flushView();689690    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();691    expect(sendMessageMock).not.toHaveBeenCalledWith(692      expect.objectContaining({ type: "UPDATE_ENTRY" })693    );694  });695696  it("Entry lets TOTP secrets be edited inline", async () => {697    sendMessageMock.mockResolvedValue({ ok: true });698    const onFieldSaved = vi.fn(async () => undefined);699    const container = mount(700      <EntryView701        entry={sampleEntry()}702        onDelete={vi.fn()}703        onFieldSaved={onFieldSaved}704      />705    );706707    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;708    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));709    await flushView();710711    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;712    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");713    await flushView();714715    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;716    saveButton.click();717    await flushView();718719    expect(sendMessageMock).toHaveBeenCalledWith({720      type: "UPDATE_ENTRY",721      payload: {722        id: "entry-1",723        patch: { totp: "NB2W45DFOIZA====" },724      },725    });726    expect(onFieldSaved).toHaveBeenCalled();727  });728729  it("Vault seeds and focuses search when a search seed is provided", async () => {730    const container = mount(731      <VaultView732        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}733        sortOrder="url-first"734        searchSeed="g"735        searchSeedNonce={1}736        onSelectEntry={vi.fn()}737        onSortOrderChange={vi.fn()}738        onAdd={vi.fn()}739        onClose={vi.fn()}740      />741    );742743    await flushView();744745    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;746    expect(search.value).toBe("g");747    expect(document.activeElement).toBe(search);748  });749750  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {751    parseImportSourceMock.mockResolvedValue(752      sampleImportResult({753        entries: [754          {755            type: "login",756            title: "Imported site",757            username: "imported-user",758            password: "imported-pass",759            url: "https://imported.test",760          },761        ],762      })763    );764    sendMessageMock.mockImplementation(async (msg: unknown) => {765      const type = (msg as { type?: string }).type;766      if (type === "GET_ENTRIES") {767        return {768          ok: true,769          data: [770            sampleEntry({771              id: "existing-1",772              title: "Imported site",773              username: "imported-user",774              url: "https://imported.test",775            }),776          ],777        };778      }779      if (type === "BATCH_ADD_ENTRIES") {780        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };781      }782      return { ok: true };783    });784    const onImport = vi.fn(async () => undefined);785    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);786    await flushView();787    sendMessageMock.mockClear();788789    await selectImportFile(container);790    findButton(container, "Import 1").click();791    await flushView();792793    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);794    expect(sendMessageMock).toHaveBeenCalledWith({795      type: "BATCH_ADD_ENTRIES",796      payload: {797        entries: [798          expect.objectContaining({799            title: "Imported site",800            username: "imported-user",801            password: "imported-pass",802            url: "https://imported.test",803          }),804        ],805      },806    });807    expect(onImport).toHaveBeenCalled();808    expect(container.textContent).toContain("1 entry appears to be duplicates");809  });810811  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {812    parseImportSourceMock.mockResolvedValue(sampleImportResult());813    sendMessageMock.mockImplementation(async (msg: unknown) => {814      const type = (msg as { type?: string }).type;815      if (type === "GET_ENTRIES") {816        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };817      }818      if (type === "CLEAR_VAULT_ENTRIES") {819        return { ok: true };820      }821      if (type === "BATCH_ADD_ENTRIES") {822        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };823      }824      return { ok: true };825    });826    const onImport = vi.fn(async () => undefined);827    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);828    await flushView();829    sendMessageMock.mockClear();830831    await selectImportFile(container);832    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();833    await flushView();834835    findButton(container, "Replace vault with 1").click();836    await flushView();837838    expect(sendMessageMock).not.toHaveBeenCalledWith(839      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })840    );841    expect(container.textContent).toContain("Replace forever? This cannot be undone.");842843    findButton(container, "Yes, replace forever").click();844    await flushView();845846    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });847    expect(sendMessageMock).toHaveBeenCalledWith({848      type: "BATCH_ADD_ENTRIES",849      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },850    });851    expect(onImport).toHaveBeenCalled();852  });853854  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {855    parseImportSourceMock.mockResolvedValue(sampleImportResult());856    sendMessageMock.mockImplementation(async (msg: unknown) => {857      const type = (msg as { type?: string }).type;858      if (type === "CREATE_VAULT") return { ok: true };859      if (type === "BATCH_ADD_ENTRIES") {860        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };861      }862      return { ok: true };863    });864    const onImport = vi.fn(async () => undefined);865    const container = mount(866      <ImportView867        mode="onboarding"868        replacesExistingVault={true}869        onBack={vi.fn()}870        onImport={onImport}871      />872    );873874    await selectImportFile(container);875876    const createButton = findButton(container, "Create vault + import 1");877    expect(createButton.disabled).toBe(true);878    expect(container.textContent).toContain("Restoring now replaces the locked local vault");879880    const masterInput = container.querySelector(881      'input[placeholder="new master password"]'882    ) as HTMLInputElement;883    const confirmInput = container.querySelector(884      'input[placeholder="confirm master password"]'885    ) as HTMLInputElement;886    inputText(masterInput, "fresh-master-password");887    inputText(confirmInput, "fresh-master-password");888    await flushView();889890    expect(createButton.disabled).toBe(false);891    createButton.click();892    await flushView();893894    expect(sendMessageMock).toHaveBeenCalledWith({895      type: "CREATE_VAULT",896      payload: { password: "fresh-master-password" },897    });898    expect(sendMessageMock).toHaveBeenCalledWith({899      type: "BATCH_ADD_ENTRIES",900      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },901    });902    expect(onImport).toHaveBeenCalled();903  });904905  it("Import encrypted backups reparse with the backup password before import", async () => {906    parseImportSourceMock907      .mockResolvedValueOnce(908        sampleImportResult({909          format: "peach-json",910          label: "Peach encrypted backup",911          entries: [],912          totalItems: 0,913          requiresPassword: true,914        })915      )916      .mockResolvedValueOnce(917        sampleImportResult({918          format: "peach-json",919          label: "Peach encrypted backup",920        })921      );922    sendMessageMock.mockImplementation(async (msg: unknown) => {923      const type = (msg as { type?: string }).type;924      if (type === "GET_ENTRIES") return { ok: true, data: [] };925      if (type === "BATCH_ADD_ENTRIES") {926        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };927      }928      return { ok: true };929    });930    const onImport = vi.fn(async () => undefined);931    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);932    await flushView();933    sendMessageMock.mockClear();934935    await selectImportFile(container, '{"type":"peach-export"}');936    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;937    inputText(password, "backup-passphrase");938    pressEnter(password);939    await flushView();940    await flushView();941942    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);943    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");944945    findButton(container, "Import 1").click();946    await flushView();947948    expect(sendMessageMock).toHaveBeenCalledWith({949      type: "BATCH_ADD_ENTRIES",950      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },951    });952    expect(onImport).toHaveBeenCalled();953  });954});