contracts.test.tsx

33.3 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    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]331    // "open settings to card I (Peach Pro) instead of card II (Protection)".332    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");333334    const changeButton = Array.from(container.querySelectorAll("button")).find(335      (candidate) => candidate.textContent?.trim() === "change"336    ) as HTMLButtonElement;337    changeButton.click();338    await flushView();339340    const saveButton = Array.from(container.querySelectorAll("button")).find(341      (candidate) => candidate.textContent?.trim() === "save new password"342    ) as HTMLButtonElement;343    expect(saveButton.disabled).toBe(true);344345    const inputs = container.querySelectorAll('input[type="password"]');346    inputText(inputs[0] as HTMLInputElement, "current-pass");347    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");348    await flushView();349    expect(saveButton.disabled).toBe(true);350351    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");352    await flushView();353    expect(saveButton.disabled).toBe(false);354  });355356  it("Settings feedback highlights the selected category and hides the form after submit", async () => {357    sendMessageMock.mockResolvedValue({ ok: true, data: null });358    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({359      ok: true,360      json: async () => ({ success: true }),361    }));362    vi.stubGlobal("fetch", fetchMock);363364    const container = mount(365      <SettingsView366        entriesCount={1}367        breachMonitoring={true}368        lockOnClose={true}369        autoLockMinutes={5}370        trashRetentionDays={30}371        theme="dark"372        accentColor="peach"373        textSize="medium"374        reduceMotion={false}375        initialSection="feedback"376        onBreachMonitoringChange={vi.fn()}377        onLockOnCloseChange={vi.fn()}378        onAutoLockMinutesChange={vi.fn()}379        onTrashRetentionChange={vi.fn()}380        onThemeChange={vi.fn()}381        onAccentChange={vi.fn()}382        onTextSizeChange={vi.fn()}383        onReduceMotionChange={vi.fn()}384        shortcuts={DEFAULT_BINDINGS}385        onShortcutsChange={vi.fn()}386        onBack={vi.fn()}387        onImport={vi.fn()}388        onExport={vi.fn()}389        onBreachCheckDone={vi.fn()}390      />391    );392    await flushView();393394    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;395    expect(feedbackCard.classList.contains("is-active")).toBe(true);396397    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];398    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;399    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;400    expect(bugButton.getAttribute("aria-pressed")).toBe("true");401    expect(featureButton.getAttribute("aria-pressed")).toBe("false");402403    featureButton.click();404    await flushView();405406    expect(bugButton.getAttribute("aria-pressed")).toBe("false");407    expect(featureButton.getAttribute("aria-pressed")).toBe("true");408    expect(featureButton.classList.contains("is-active")).toBe(true);409410    inputText(411      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,412      '<script>alert("xss")</script> Please make this easier to see & safer.'413    );414    const sendButton = Array.from(container.querySelectorAll("button")).find(415      (candidate) => candidate.textContent?.trim() === "Send"416    ) as HTMLButtonElement;417    sendButton.click();418419    await vi.waitFor(() => {420      expect(container.textContent).toContain("Thank you");421    });422423    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));424    expect(requestBody.subject).toContain("Feature request");425    expect(requestBody.message).toBe(426      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."427    );428    expect(requestBody.message).not.toContain("<script>");429    expect(container.textContent).toContain("Someone will process it soon");430    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();431    expect(container.querySelector(".peach-feedback-submit")).toBeNull();432  });433434  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {435    sendMessageMock.mockResolvedValue({ ok: true, data: null });436    const fetchMock = vi.fn();437    const requestMock = vi.fn(async () => false);438    vi.stubGlobal("fetch", fetchMock);439    vi.stubGlobal("browser", {440      permissions: {441        getAll: vi.fn(async () => ({ data_collection: [] })),442        request: requestMock,443      },444    });445446    const container = mount(447      <SettingsView448        entriesCount={1}449        breachMonitoring={true}450        lockOnClose={true}451        autoLockMinutes={5}452        trashRetentionDays={30}453        theme="dark"454        accentColor="peach"455        textSize="medium"456        reduceMotion={false}457        initialSection="feedback"458        onBreachMonitoringChange={vi.fn()}459        onLockOnCloseChange={vi.fn()}460        onAutoLockMinutesChange={vi.fn()}461        onTrashRetentionChange={vi.fn()}462        onThemeChange={vi.fn()}463        onAccentChange={vi.fn()}464        onTextSizeChange={vi.fn()}465        onReduceMotionChange={vi.fn()}466        shortcuts={DEFAULT_BINDINGS}467        onShortcutsChange={vi.fn()}468        onBack={vi.fn()}469        onImport={vi.fn()}470        onExport={vi.fn()}471        onBreachCheckDone={vi.fn()}472      />473    );474    await flushView();475476    inputText(477      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,478      "Please improve the feedback flow."479    );480    const sendButton = Array.from(container.querySelectorAll("button")).find(481      (candidate) => candidate.textContent?.trim() === "Send"482    ) as HTMLButtonElement;483    sendButton.click();484485    await vi.waitFor(() => {486      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });487    });488    expect(fetchMock).not.toHaveBeenCalled();489    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();490  });491492  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {493    sendMessageMock.mockImplementation(async (msg: unknown) => {494      const m = msg as { type: string };495      if (m.type === "GET_RECOVERY_WORDS") {496        return {497          ok: true,498          data: [499            "abandon", "ability", "able", "about", "above", "absent",500            "absorb", "abstract", "absurd", "abuse", "access", "accident",501          ],502        };503      }504      return { ok: true, data: null };505    });506    const onAutoLockMinutesChange = vi.fn();507    const onThemeChange = vi.fn();508    const container = mount(509      <SettingsView510        entriesCount={1}511        breachMonitoring={true}512        lockOnClose={true}513        autoLockMinutes={5}514        trashRetentionDays={30}515        theme="dark"516        accentColor="peach"517        textSize="medium"518        reduceMotion={false}519        onBreachMonitoringChange={vi.fn()}520        onLockOnCloseChange={vi.fn()}521        onAutoLockMinutesChange={onAutoLockMinutesChange}522        onTrashRetentionChange={vi.fn()}523        onThemeChange={onThemeChange}524        onAccentChange={vi.fn()}525        onTextSizeChange={vi.fn()}526        onReduceMotionChange={vi.fn()}527        shortcuts={DEFAULT_BINDINGS}528        onShortcutsChange={vi.fn()}529        onBack={vi.fn()}530        onImport={vi.fn()}531        onExport={vi.fn()}532        onBreachCheckDone={vi.fn()}533      />534    );535    await flushView();536    sendMessageMock.mockClear();537538    // ── Auto-lock + theme changes (before password change) ─────────────539    const selects = container.querySelectorAll("select");540    changeSelect(selects[0] as HTMLSelectElement, "15");541    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);542543    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;544    appearanceCard.click();545    await flushView();546    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");547    const themeButton = Array.from(container.querySelectorAll("button")).find(548      (candidate) => candidate.textContent?.trim() === "Switch to light"549    ) as HTMLButtonElement;550    themeButton.click();551    expect(onThemeChange).toHaveBeenCalledWith("light");552553    // ── Password change → recovery words display ────────────────────────554    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;555    protectionCard.click();556    await flushView();557558    const changeButton = Array.from(container.querySelectorAll("button")).find(559      (candidate) => candidate.textContent?.trim() === "change"560    ) as HTMLButtonElement;561    changeButton.click();562    await flushView();563564    const inputs = container.querySelectorAll('input[type="password"]');565    inputText(inputs[0] as HTMLInputElement, "current-pass");566    inputText(inputs[1] as HTMLInputElement, "new-password-123");567    inputText(inputs[2] as HTMLInputElement, "new-password-123");568    await flushView();569570    const saveButton = Array.from(container.querySelectorAll("button")).find(571      (candidate) => candidate.textContent?.trim() === "save new password"572    ) as HTMLButtonElement;573    saveButton.click();574    await flushView();575576    expect(sendMessageMock).toHaveBeenCalledWith({577      type: "CHANGE_PASSWORD",578      payload: {579        currentPassword: "current-pass",580        newPassword: "new-password-123",581      },582    });583584    // After a successful password change the recovery word display replaces settings.585    expect(container.textContent).toContain("save your recovery words");586    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });587  });588589  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {590    sendMessageMock.mockResolvedValue({ ok: true });591    const container = mount(592      <EntryView593        entry={sampleEntry()}594        onDelete={vi.fn()}595        onFieldSaved={vi.fn(async () => undefined)}596      />597    );598599    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;600    passwordButton.click();601    await flushView();602603    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {604      type: "COPY",605      payload: { text: "secret123", label: "password", alreadyWritten: false },606    });607    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {608      type: "FIRE_CANARY_PING",609      payload: {610        webhookUrl: "https://example.com/hook",611        entryTitle: "GitHub",612        trigger: "password_copied",613      },614    });615616    sendMessageMock.mockClear();617    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;618    usernameButton.click();619    await flushView();620621    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {622      type: "COPY",623      payload: { text: "june", label: "username", alreadyWritten: false },624    });625    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {626      type: "FIRE_CANARY_PING",627      payload: {628        webhookUrl: "https://example.com/hook",629        entryTitle: "GitHub",630        trigger: "username_copied",631      },632    });633  });634635  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {636    sendMessageMock.mockResolvedValue({ ok: true });637    const onFieldSaved = vi.fn(async () => undefined);638    const container = mount(639      <EntryView640        entry={sampleEntry()}641        onDelete={vi.fn()}642        onFieldSaved={onFieldSaved}643      />644    );645646    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;647    passwordValue.click();648    await flushView();649650    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;651    inputText(passwordInput, "");652    await flushView();653654    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;655    saveButton.click();656    await flushView();657658    expect(sendMessageMock).not.toHaveBeenCalledWith(659      expect.objectContaining({ type: "UPDATE_ENTRY" })660    );661    expect(onFieldSaved).not.toHaveBeenCalled();662663    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;664    totpMain.click();665666    await vi.waitFor(() => {667      expect(sendMessageMock).toHaveBeenLastCalledWith({668        type: "COPY",669        payload: { text: "123456", label: "totp", alreadyWritten: false },670      });671    }, { timeout: 500 });672  });673674  it("Entry closes an unchanged inline editor when focus leaves it", async () => {675    sendMessageMock.mockResolvedValue({ ok: true });676    const container = mount(677      <EntryView678        entry={sampleEntry()}679        onDelete={vi.fn()}680        onFieldSaved={vi.fn(async () => undefined)}681      />682    );683684    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;685    usernameValue.click();686    await flushView();687688    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;689    usernameInput.blur();690    await flushView();691692    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();693    expect(sendMessageMock).not.toHaveBeenCalledWith(694      expect.objectContaining({ type: "UPDATE_ENTRY" })695    );696  });697698  it("Entry lets TOTP secrets be edited inline", async () => {699    sendMessageMock.mockResolvedValue({ ok: true });700    const onFieldSaved = vi.fn(async () => undefined);701    const container = mount(702      <EntryView703        entry={sampleEntry()}704        onDelete={vi.fn()}705        onFieldSaved={onFieldSaved}706      />707    );708709    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;710    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));711    await flushView();712713    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;714    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");715    await flushView();716717    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;718    saveButton.click();719    await flushView();720721    expect(sendMessageMock).toHaveBeenCalledWith({722      type: "UPDATE_ENTRY",723      payload: {724        id: "entry-1",725        patch: { totp: "NB2W45DFOIZA====" },726      },727    });728    expect(onFieldSaved).toHaveBeenCalled();729  });730731  it("Vault seeds and focuses search when a search seed is provided", async () => {732    const container = mount(733      <VaultView734        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}735        sortOrder="url-first"736        searchSeed="g"737        searchSeedNonce={1}738        onSelectEntry={vi.fn()}739        onSortOrderChange={vi.fn()}740        onAdd={vi.fn()}741        onClose={vi.fn()}742      />743    );744745    await flushView();746747    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;748    expect(search.value).toBe("g");749    expect(document.activeElement).toBe(search);750  });751752  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {753    parseImportSourceMock.mockResolvedValue(754      sampleImportResult({755        entries: [756          {757            type: "login",758            title: "Imported site",759            username: "imported-user",760            password: "imported-pass",761            url: "https://imported.test",762          },763        ],764      })765    );766    sendMessageMock.mockImplementation(async (msg: unknown) => {767      const type = (msg as { type?: string }).type;768      if (type === "GET_ENTRIES") {769        return {770          ok: true,771          data: [772            sampleEntry({773              id: "existing-1",774              title: "Imported site",775              username: "imported-user",776              url: "https://imported.test",777            }),778          ],779        };780      }781      if (type === "BATCH_ADD_ENTRIES") {782        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };783      }784      return { ok: true };785    });786    const onImport = vi.fn(async () => undefined);787    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);788    await flushView();789    sendMessageMock.mockClear();790791    await selectImportFile(container);792    findButton(container, "Import 1").click();793    await flushView();794795    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);796    expect(sendMessageMock).toHaveBeenCalledWith({797      type: "BATCH_ADD_ENTRIES",798      payload: {799        entries: [800          expect.objectContaining({801            title: "Imported site",802            username: "imported-user",803            password: "imported-pass",804            url: "https://imported.test",805          }),806        ],807      },808    });809    expect(onImport).toHaveBeenCalled();810    expect(container.textContent).toContain("1 entry appears to be duplicates");811  });812813  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {814    parseImportSourceMock.mockResolvedValue(sampleImportResult());815    sendMessageMock.mockImplementation(async (msg: unknown) => {816      const type = (msg as { type?: string }).type;817      if (type === "GET_ENTRIES") {818        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };819      }820      if (type === "CLEAR_VAULT_ENTRIES") {821        return { ok: true };822      }823      if (type === "BATCH_ADD_ENTRIES") {824        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };825      }826      return { ok: true };827    });828    const onImport = vi.fn(async () => undefined);829    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);830    await flushView();831    sendMessageMock.mockClear();832833    await selectImportFile(container);834    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();835    await flushView();836837    findButton(container, "Replace vault with 1").click();838    await flushView();839840    expect(sendMessageMock).not.toHaveBeenCalledWith(841      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })842    );843    expect(container.textContent).toContain("Replace forever? This cannot be undone.");844845    findButton(container, "Yes, replace forever").click();846    await flushView();847848    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });849    expect(sendMessageMock).toHaveBeenCalledWith({850      type: "BATCH_ADD_ENTRIES",851      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },852    });853    expect(onImport).toHaveBeenCalled();854  });855856  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {857    parseImportSourceMock.mockResolvedValue(sampleImportResult());858    sendMessageMock.mockImplementation(async (msg: unknown) => {859      const type = (msg as { type?: string }).type;860      if (type === "CREATE_VAULT") return { ok: true };861      if (type === "BATCH_ADD_ENTRIES") {862        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };863      }864      return { ok: true };865    });866    const onImport = vi.fn(async () => undefined);867    const container = mount(868      <ImportView869        mode="onboarding"870        replacesExistingVault={true}871        onBack={vi.fn()}872        onImport={onImport}873      />874    );875876    await selectImportFile(container);877878    const createButton = findButton(container, "Create vault + import 1");879    expect(createButton.disabled).toBe(true);880    expect(container.textContent).toContain("Restoring now replaces the locked local vault");881882    const masterInput = container.querySelector(883      'input[placeholder="new master password"]'884    ) as HTMLInputElement;885    const confirmInput = container.querySelector(886      'input[placeholder="confirm master password"]'887    ) as HTMLInputElement;888    inputText(masterInput, "fresh-master-password");889    inputText(confirmInput, "fresh-master-password");890    await flushView();891892    expect(createButton.disabled).toBe(false);893    createButton.click();894    await flushView();895896    expect(sendMessageMock).toHaveBeenCalledWith({897      type: "CREATE_VAULT",898      payload: { password: "fresh-master-password" },899    });900    expect(sendMessageMock).toHaveBeenCalledWith({901      type: "BATCH_ADD_ENTRIES",902      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },903    });904    expect(onImport).toHaveBeenCalled();905  });906907  it("Import encrypted backups reparse with the backup password before import", async () => {908    parseImportSourceMock909      .mockResolvedValueOnce(910        sampleImportResult({911          format: "peach-json",912          label: "Peach encrypted backup",913          entries: [],914          totalItems: 0,915          requiresPassword: true,916        })917      )918      .mockResolvedValueOnce(919        sampleImportResult({920          format: "peach-json",921          label: "Peach encrypted backup",922        })923      );924    sendMessageMock.mockImplementation(async (msg: unknown) => {925      const type = (msg as { type?: string }).type;926      if (type === "GET_ENTRIES") return { ok: true, data: [] };927      if (type === "BATCH_ADD_ENTRIES") {928        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };929      }930      return { ok: true };931    });932    const onImport = vi.fn(async () => undefined);933    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);934    await flushView();935    sendMessageMock.mockClear();936937    await selectImportFile(container, '{"type":"peach-export"}');938    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;939    inputText(password, "backup-passphrase");940    pressEnter(password);941    await flushView();942    await flushView();943944    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);945    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");946947    findButton(container, "Import 1").click();948    await flushView();949950    expect(sendMessageMock).toHaveBeenCalledWith({951      type: "BATCH_ADD_ENTRIES",952      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },953    });954    expect(onImport).toHaveBeenCalled();955  });956});