contracts.test.tsx

36.7 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { DEFAULT_BINDINGS } from "../../shared/shortcuts";12import { EntryView } from "./Entry";13import { ImportView } from "./Import";14import { VaultView } from "./Vault";15import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1617const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();18const triggerRingMock = vi.fn();19const triggerPipPulseMock = vi.fn();20const 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  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {145    return {146      entriesCount: 1,147      breachMonitoring: true,148      lockOnClose: true,149      autoLockMinutes: 5,150      trashRetentionDays: 30,151      theme: "dark" as const,152      accentColor: "peach" as const,153      textSize: "medium" as const,154      reduceMotion: false,155      onBreachMonitoringChange: vi.fn(),156      onLockOnCloseChange: vi.fn(),157      onAutoLockMinutesChange: vi.fn(),158      onTrashRetentionChange: vi.fn(),159      onThemeChange: vi.fn(),160      onAccentChange: vi.fn(),161      onTextSizeChange: vi.fn(),162      onReduceMotionChange: vi.fn(),163      shortcuts: DEFAULT_BINDINGS,164      onShortcutsChange: vi.fn(),165      onBack: vi.fn(),166      onImport: vi.fn(),167      onExport: vi.fn(),168      onBreachCheckDone: vi.fn(),169      ...overrides,170    };171  }172173  async function selectImportFile(174    container: HTMLElement,175    contents = '{"export":"test"}'176  ) {177    // The Import view now opens onto a menu — click into the file subview178    // before locating the file input. Idempotent: a second click won't re-fire179    // because the menu unmounts once the subview renders.180    const fileCard = findButton(container, "Choose file →");181    if (fileCard) {182      fileCard.click();183      await flushView();184    }185    const input = container.querySelector('input[type="file"]') as HTMLInputElement;186    const file = new File([contents], "import.json", { type: "application/json" });187    Object.defineProperty(input, "files", {188      configurable: true,189      value: [file],190    });191    input.dispatchEvent(new Event("change", { bubbles: true }));192    await flushView();193    await flushView();194  }195196  function findButton(container: HTMLElement, text: string): HTMLButtonElement {197    return Array.from(container.querySelectorAll("button")).find(198      (candidate) => candidate.textContent?.trim() === text199    ) as HTMLButtonElement;200  }201202  it("Lock sends UNLOCK with the entered passphrase", async () => {203    sendMessageMock.mockResolvedValue({ ok: true });204    const onUnlock = vi.fn(async () => undefined);205    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);206207    const password = container.querySelector("#unlock-password") as HTMLInputElement;208    inputText(password, "correct horse battery staple");209    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();210    await flushView();211212    expect(sendMessageMock).toHaveBeenCalledWith({213      type: "UNLOCK",214      payload: { password: "correct horse battery staple" },215    });216    expect(onUnlock).toHaveBeenCalled();217  });218219  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {220    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);221    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;222    const password = container.querySelector("#master-password") as HTMLInputElement;223    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;224225    inputText(password, "short");226    inputText(confirm, "short");227    await flushView();228    expect(createButton.disabled).toBe(true);229230    zxcvbnMock.mockImplementation(() => ({ score: 1 }));231    inputText(password, "weakweakweak");232    inputText(confirm, "weakweakweak");233    await flushView();234    expect(createButton.disabled).toBe(true);235  });236237  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {238    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock239    sendMessageMock.mockImplementation(240      async (msg: unknown) => {241        const m = msg as { type: string; payload?: { password?: string } };242        if (m.type === "CREATE_VAULT") return { ok: true };243        if (m.type === "GET_RECOVERY_WORDS") {244          // Return 12 fake words — must pass findInvalidWords245          return {246            ok: true,247            data: [248              "abandon", "ability", "able", "about", "above", "absent",249              "absorb", "abstract", "absurd", "abuse", "access", "accident",250            ],251          };252        }253        return { ok: true };254      }255    );256    zxcvbnMock.mockImplementation(() => ({ score: 3 }));257    const onUnlock = vi.fn(async () => undefined);258    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);259260    const password = container.querySelector("#master-password") as HTMLInputElement;261    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;262    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;263264    inputText(password, "correct horse battery staple");265    inputText(confirm, "correct horse battery staple");266    await flushView();267    expect(createButton.disabled).toBe(false);268269    pressEnter(confirm);270    await flushView();271272    expect(sendMessageMock).toHaveBeenCalledWith({273      type: "CREATE_VAULT",274      payload: { password: "correct horse battery staple" },275    });276    // After CREATE_VAULT success, the recovery word display is shown.277    // onUnlock is NOT called yet — user must first confirm their recovery words.278    expect(onUnlock).not.toHaveBeenCalled();279  });280281  it("Export menu surfaces both backup formats", async () => {282    const container = mount(<ExportView onBack={vi.fn()} />);283    await flushView();284285    expect(container.textContent).toContain("PeachScript");286    expect(container.textContent).toContain("CSV");287  });288289  it("Lock onboarding restore options include Peach Codex", async () => {290    const onOpenCodexRestore = vi.fn();291    const container = mount(292      <LockView293        hasVault={false}294        ready={true}295        onUnlock={vi.fn()}296        onOpenCodexRestore={onOpenCodexRestore}297      />298    );299300    const restoreButton = Array.from(container.querySelectorAll("button")).find(301      (candidate) => candidate.textContent?.includes("restore from backup")302    ) as HTMLButtonElement;303    restoreButton.click();304    await flushView();305306    expect(container.textContent).toContain("PeachScript");307    expect(container.textContent).toContain("Codex");308    expect(container.textContent).toContain("CSV");309310    const codexTab = Array.from(container.querySelectorAll("button")).find(311      (candidate) => candidate.textContent?.trim() === "Codex"312    ) as HTMLButtonElement;313    codexTab.click();314    await flushView();315316    expect(container.textContent).toContain("Restore from Peach Codex");317    expect(container.textContent).toContain("Scan printed Codex pages");318319    const openCodexButton = Array.from(container.querySelectorAll("button")).find(320      (candidate) => candidate.textContent?.trim() === "open Codex restore →"321    ) as HTMLButtonElement;322    openCodexButton.click();323324    expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);325  });326327  it("Settings requires current, new, and confirm passwords before enabling save", async () => {328    sendMessageMock.mockResolvedValue({ ok: true, data: null });329    const container = mount(330      <SettingsView331        entriesCount={1}332        breachMonitoring={true}333        lockOnClose={true}334        autoLockMinutes={5}335        trashRetentionDays={30}336        theme="dark"337        accentColor="peach"338        textSize="medium"339        reduceMotion={false}340        onBreachMonitoringChange={vi.fn()}341        onLockOnCloseChange={vi.fn()}342        onAutoLockMinutesChange={vi.fn()}343        onTrashRetentionChange={vi.fn()}344        onThemeChange={vi.fn()}345        onAccentChange={vi.fn()}346        onTextSizeChange={vi.fn()}347        onReduceMotionChange={vi.fn()}348        shortcuts={DEFAULT_BINDINGS}349        onShortcutsChange={vi.fn()}350        onBack={vi.fn()}351        onImport={vi.fn()}352        onExport={vi.fn()}353        onBreachCheckDone={vi.fn()}354      />355    );356    await flushView();357358    expect(container.querySelectorAll(".stk-card").length).toBe(7);359    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]360    // "open settings to card I (Peach Pro) instead of card II (Protection)".361    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");362363    const changeButton = Array.from(container.querySelectorAll("button")).find(364      (candidate) => candidate.textContent?.trim() === "change"365    ) as HTMLButtonElement;366    changeButton.click();367    await flushView();368369    const saveButton = Array.from(container.querySelectorAll("button")).find(370      (candidate) => candidate.textContent?.trim() === "save new password"371    ) as HTMLButtonElement;372    expect(saveButton.disabled).toBe(true);373374    const inputs = container.querySelectorAll('input[type="password"]');375    inputText(inputs[0] as HTMLInputElement, "current-pass");376    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");377    await flushView();378    expect(saveButton.disabled).toBe(true);379380    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");381    await flushView();382    expect(saveButton.disabled).toBe(false);383  });384385  it("Settings feedback highlights the selected category and hides the form after submit", async () => {386    sendMessageMock.mockResolvedValue({ ok: true, data: null });387    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({388      ok: true,389      json: async () => ({ success: true }),390    }));391    vi.stubGlobal("fetch", fetchMock);392393    const container = mount(394      <SettingsView395        entriesCount={1}396        breachMonitoring={true}397        lockOnClose={true}398        autoLockMinutes={5}399        trashRetentionDays={30}400        theme="dark"401        accentColor="peach"402        textSize="medium"403        reduceMotion={false}404        initialSection="feedback"405        onBreachMonitoringChange={vi.fn()}406        onLockOnCloseChange={vi.fn()}407        onAutoLockMinutesChange={vi.fn()}408        onTrashRetentionChange={vi.fn()}409        onThemeChange={vi.fn()}410        onAccentChange={vi.fn()}411        onTextSizeChange={vi.fn()}412        onReduceMotionChange={vi.fn()}413        shortcuts={DEFAULT_BINDINGS}414        onShortcutsChange={vi.fn()}415        onBack={vi.fn()}416        onImport={vi.fn()}417        onExport={vi.fn()}418        onBreachCheckDone={vi.fn()}419      />420    );421    await flushView();422423    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;424    expect(feedbackCard.classList.contains("is-active")).toBe(true);425426    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];427    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;428    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;429    expect(bugButton.getAttribute("aria-pressed")).toBe("true");430    expect(featureButton.getAttribute("aria-pressed")).toBe("false");431432    featureButton.click();433    await flushView();434435    expect(bugButton.getAttribute("aria-pressed")).toBe("false");436    expect(featureButton.getAttribute("aria-pressed")).toBe("true");437    expect(featureButton.classList.contains("is-active")).toBe(true);438439    inputText(440      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,441      '<script>alert("xss")</script> Please make this easier to see & safer.'442    );443    const sendButton = Array.from(container.querySelectorAll("button")).find(444      (candidate) => candidate.textContent?.trim() === "Send"445    ) as HTMLButtonElement;446    sendButton.click();447448    await vi.waitFor(() => {449      expect(container.textContent).toContain("Thank you");450    });451452    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));453    expect(requestBody.subject).toContain("Feature request");454    expect(requestBody.message).toBe(455      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."456    );457    expect(requestBody.message).not.toContain("<script>");458    expect(container.textContent).toContain("Someone will process it soon");459    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();460    expect(container.querySelector(".peach-feedback-submit")).toBeNull();461  });462463  it("Settings keeps license activation on the full-screen celebration path", async () => {464    const onLicenseChange = vi.fn(async () => undefined);465    sendMessageMock.mockImplementation(async (msg: unknown) => {466      const type = (msg as { type?: string }).type;467      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };468      if (type === "ACTIVATE_LICENSE") {469        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };470      }471      if (type === "GET_ENTRIES") return { ok: true, data: [] };472      return { ok: true, data: null };473    });474475    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);476    await flushView();477478    inputText(479      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,480      "PEACH-TEST-KEY",481    );482    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;483    apply.click();484485    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));486    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);487    expect(container.textContent).not.toContain("thank you ✦");488  });489490  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {491    sendMessageMock.mockImplementation(async (msg: unknown) => {492      const type = (msg as { type?: string }).type;493      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };494      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };495      if (type === "GET_ENTRIES") return { ok: true, data: [] };496      return { ok: true, data: null };497    });498499    const onLicenseChangeFromStar = vi.fn();500    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);501    await flushView();502    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();503    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);504    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();505506    const onLicenseChangeFromPeach = vi.fn();507    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);508    await flushView();509    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();510    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);511    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();512  });513514  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {515    sendMessageMock.mockResolvedValue({ ok: true, data: null });516    const fetchMock = vi.fn();517    const requestMock = vi.fn(async () => false);518    vi.stubGlobal("fetch", fetchMock);519    vi.stubGlobal("browser", {520      permissions: {521        getAll: vi.fn(async () => ({ data_collection: [] })),522        request: requestMock,523      },524    });525526    const container = mount(527      <SettingsView528        entriesCount={1}529        breachMonitoring={true}530        lockOnClose={true}531        autoLockMinutes={5}532        trashRetentionDays={30}533        theme="dark"534        accentColor="peach"535        textSize="medium"536        reduceMotion={false}537        initialSection="feedback"538        onBreachMonitoringChange={vi.fn()}539        onLockOnCloseChange={vi.fn()}540        onAutoLockMinutesChange={vi.fn()}541        onTrashRetentionChange={vi.fn()}542        onThemeChange={vi.fn()}543        onAccentChange={vi.fn()}544        onTextSizeChange={vi.fn()}545        onReduceMotionChange={vi.fn()}546        shortcuts={DEFAULT_BINDINGS}547        onShortcutsChange={vi.fn()}548        onBack={vi.fn()}549        onImport={vi.fn()}550        onExport={vi.fn()}551        onBreachCheckDone={vi.fn()}552      />553    );554    await flushView();555556    inputText(557      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,558      "Please improve the feedback flow."559    );560    const sendButton = Array.from(container.querySelectorAll("button")).find(561      (candidate) => candidate.textContent?.trim() === "Send"562    ) as HTMLButtonElement;563    sendButton.click();564565    await vi.waitFor(() => {566      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });567    });568    expect(fetchMock).not.toHaveBeenCalled();569    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();570  });571572  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {573    sendMessageMock.mockImplementation(async (msg: unknown) => {574      const m = msg as { type: string };575      if (m.type === "GET_RECOVERY_WORDS") {576        return {577          ok: true,578          data: [579            "abandon", "ability", "able", "about", "above", "absent",580            "absorb", "abstract", "absurd", "abuse", "access", "accident",581          ],582        };583      }584      return { ok: true, data: null };585    });586    const onAutoLockMinutesChange = vi.fn();587    const onThemeChange = vi.fn();588    const container = mount(589      <SettingsView590        entriesCount={1}591        breachMonitoring={true}592        lockOnClose={true}593        autoLockMinutes={5}594        trashRetentionDays={30}595        theme="dark"596        accentColor="peach"597        textSize="medium"598        reduceMotion={false}599        onBreachMonitoringChange={vi.fn()}600        onLockOnCloseChange={vi.fn()}601        onAutoLockMinutesChange={onAutoLockMinutesChange}602        onTrashRetentionChange={vi.fn()}603        onThemeChange={onThemeChange}604        onAccentChange={vi.fn()}605        onTextSizeChange={vi.fn()}606        onReduceMotionChange={vi.fn()}607        shortcuts={DEFAULT_BINDINGS}608        onShortcutsChange={vi.fn()}609        onBack={vi.fn()}610        onImport={vi.fn()}611        onExport={vi.fn()}612        onBreachCheckDone={vi.fn()}613      />614    );615    await flushView();616    sendMessageMock.mockClear();617618    // ── Auto-lock + theme changes (before password change) ─────────────619    const selects = container.querySelectorAll("select");620    changeSelect(selects[0] as HTMLSelectElement, "15");621    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);622623    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;624    appearanceCard.click();625    await flushView();626    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");627    const themeButton = Array.from(container.querySelectorAll("button")).find(628      (candidate) => candidate.textContent?.trim() === "Switch to light"629    ) as HTMLButtonElement;630    themeButton.click();631    expect(onThemeChange).toHaveBeenCalledWith("light");632633    // ── Password change → recovery words display ────────────────────────634    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;635    protectionCard.click();636    await flushView();637638    const changeButton = Array.from(container.querySelectorAll("button")).find(639      (candidate) => candidate.textContent?.trim() === "change"640    ) as HTMLButtonElement;641    changeButton.click();642    await flushView();643644    const inputs = container.querySelectorAll('input[type="password"]');645    inputText(inputs[0] as HTMLInputElement, "current-pass");646    inputText(inputs[1] as HTMLInputElement, "new-password-123");647    inputText(inputs[2] as HTMLInputElement, "new-password-123");648    await flushView();649650    const saveButton = Array.from(container.querySelectorAll("button")).find(651      (candidate) => candidate.textContent?.trim() === "save new password"652    ) as HTMLButtonElement;653    saveButton.click();654    await flushView();655656    expect(sendMessageMock).toHaveBeenCalledWith({657      type: "CHANGE_PASSWORD",658      payload: {659        currentPassword: "current-pass",660        newPassword: "new-password-123",661      },662    });663664    // After a successful password change the recovery word display replaces settings.665    expect(container.textContent).toContain("save your recovery words");666    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });667  });668669  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {670    sendMessageMock.mockResolvedValue({ ok: true });671    const container = mount(672      <EntryView673        entry={sampleEntry()}674        onDelete={vi.fn()}675        onFieldSaved={vi.fn(async () => undefined)}676      />677    );678679    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;680    passwordButton.click();681    await flushView();682683    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {684      type: "COPY",685      payload: { text: "secret123", label: "password", alreadyWritten: false },686    });687    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {688      type: "FIRE_CANARY_PING",689      payload: {690        webhookUrl: "https://example.com/hook",691        entryTitle: "GitHub",692        trigger: "password_copied",693      },694    });695696    sendMessageMock.mockClear();697    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;698    usernameButton.click();699    await flushView();700701    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {702      type: "COPY",703      payload: { text: "june", label: "username", alreadyWritten: false },704    });705    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {706      type: "FIRE_CANARY_PING",707      payload: {708        webhookUrl: "https://example.com/hook",709        entryTitle: "GitHub",710        trigger: "username_copied",711      },712    });713  });714715  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {716    sendMessageMock.mockResolvedValue({ ok: true });717    const onFieldSaved = vi.fn(async () => undefined);718    const container = mount(719      <EntryView720        entry={sampleEntry()}721        onDelete={vi.fn()}722        onFieldSaved={onFieldSaved}723      />724    );725726    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;727    passwordValue.click();728    await flushView();729730    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;731    inputText(passwordInput, "");732    await flushView();733734    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;735    saveButton.click();736    await flushView();737738    expect(sendMessageMock).not.toHaveBeenCalledWith(739      expect.objectContaining({ type: "UPDATE_ENTRY" })740    );741    expect(onFieldSaved).not.toHaveBeenCalled();742743    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;744    totpMain.click();745746    await vi.waitFor(() => {747      expect(sendMessageMock).toHaveBeenLastCalledWith({748        type: "COPY",749        payload: { text: "123456", label: "totp", alreadyWritten: false },750      });751    }, { timeout: 500 });752  });753754  it("Entry closes an unchanged inline editor when focus leaves it", async () => {755    sendMessageMock.mockResolvedValue({ ok: true });756    const container = mount(757      <EntryView758        entry={sampleEntry()}759        onDelete={vi.fn()}760        onFieldSaved={vi.fn(async () => undefined)}761      />762    );763764    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;765    usernameValue.click();766    await flushView();767768    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;769    usernameInput.blur();770    await flushView();771772    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();773    expect(sendMessageMock).not.toHaveBeenCalledWith(774      expect.objectContaining({ type: "UPDATE_ENTRY" })775    );776  });777778  it("Entry lets TOTP secrets be edited inline", async () => {779    sendMessageMock.mockResolvedValue({ ok: true });780    const onFieldSaved = vi.fn(async () => undefined);781    const container = mount(782      <EntryView783        entry={sampleEntry()}784        onDelete={vi.fn()}785        onFieldSaved={onFieldSaved}786      />787    );788789    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;790    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));791    await flushView();792793    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;794    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");795    await flushView();796797    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;798    saveButton.click();799    await flushView();800801    expect(sendMessageMock).toHaveBeenCalledWith({802      type: "UPDATE_ENTRY",803      payload: {804        id: "entry-1",805        patch: { totp: "NB2W45DFOIZA====" },806      },807    });808    expect(onFieldSaved).toHaveBeenCalled();809  });810811  it("Vault seeds and focuses search when a search seed is provided", async () => {812    const container = mount(813      <VaultView814        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}815        sortOrder="url-first"816        searchSeed="g"817        searchSeedNonce={1}818        onSelectEntry={vi.fn()}819        onSortOrderChange={vi.fn()}820        onAdd={vi.fn()}821        onClose={vi.fn()}822      />823    );824825    await flushView();826827    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;828    expect(search.value).toBe("g");829    expect(document.activeElement).toBe(search);830  });831832  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {833    parseImportSourceMock.mockResolvedValue(834      sampleImportResult({835        entries: [836          {837            type: "login",838            title: "Imported site",839            username: "imported-user",840            password: "imported-pass",841            url: "https://imported.test",842          },843        ],844      })845    );846    sendMessageMock.mockImplementation(async (msg: unknown) => {847      const type = (msg as { type?: string }).type;848      if (type === "GET_ENTRIES") {849        return {850          ok: true,851          data: [852            sampleEntry({853              id: "existing-1",854              title: "Imported site",855              username: "imported-user",856              url: "https://imported.test",857            }),858          ],859        };860      }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(<ImportView onBack={vi.fn()} onImport={onImport} />);868    await flushView();869    sendMessageMock.mockClear();870871    await selectImportFile(container);872    findButton(container, "Import 1").click();873    await flushView();874875    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);876    expect(sendMessageMock).toHaveBeenCalledWith({877      type: "BATCH_ADD_ENTRIES",878      payload: {879        entries: [880          expect.objectContaining({881            title: "Imported site",882            username: "imported-user",883            password: "imported-pass",884            url: "https://imported.test",885          }),886        ],887      },888    });889    expect(onImport).toHaveBeenCalled();890    expect(container.textContent).toContain("1 entry appears to be duplicates");891  });892893  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {894    parseImportSourceMock.mockResolvedValue(sampleImportResult());895    sendMessageMock.mockImplementation(async (msg: unknown) => {896      const type = (msg as { type?: string }).type;897      if (type === "GET_ENTRIES") {898        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };899      }900      if (type === "CLEAR_VAULT_ENTRIES") {901        return { ok: true };902      }903      if (type === "BATCH_ADD_ENTRIES") {904        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };905      }906      return { ok: true };907    });908    const onImport = vi.fn(async () => undefined);909    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);910    await flushView();911    sendMessageMock.mockClear();912913    await selectImportFile(container);914    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();915    await flushView();916917    findButton(container, "Replace vault with 1").click();918    await flushView();919920    expect(sendMessageMock).not.toHaveBeenCalledWith(921      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })922    );923    expect(container.textContent).toContain("Replace forever? This cannot be undone.");924925    findButton(container, "Yes, replace forever").click();926    await flushView();927928    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });929    expect(sendMessageMock).toHaveBeenCalledWith({930      type: "BATCH_ADD_ENTRIES",931      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },932    });933    expect(onImport).toHaveBeenCalled();934  });935936  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {937    parseImportSourceMock.mockResolvedValue(sampleImportResult());938    sendMessageMock.mockImplementation(async (msg: unknown) => {939      const type = (msg as { type?: string }).type;940      if (type === "CREATE_VAULT") return { ok: true };941      if (type === "BATCH_ADD_ENTRIES") {942        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };943      }944      return { ok: true };945    });946    const onImport = vi.fn(async () => undefined);947    const container = mount(948      <ImportView949        mode="onboarding"950        replacesExistingVault={true}951        onBack={vi.fn()}952        onImport={onImport}953      />954    );955956    await selectImportFile(container);957958    const createButton = findButton(container, "Create vault + import 1");959    expect(createButton.disabled).toBe(true);960    expect(container.textContent).toContain("Restoring now replaces the locked local vault");961962    const masterInput = container.querySelector(963      'input[placeholder="new master password"]'964    ) as HTMLInputElement;965    const confirmInput = container.querySelector(966      'input[placeholder="confirm master password"]'967    ) as HTMLInputElement;968    inputText(masterInput, "fresh-master-password");969    inputText(confirmInput, "fresh-master-password");970    await flushView();971972    expect(createButton.disabled).toBe(false);973    createButton.click();974    await flushView();975976    expect(sendMessageMock).toHaveBeenCalledWith({977      type: "CREATE_VAULT",978      payload: { password: "fresh-master-password" },979    });980    expect(sendMessageMock).toHaveBeenCalledWith({981      type: "BATCH_ADD_ENTRIES",982      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },983    });984    expect(onImport).toHaveBeenCalled();985  });986987  it("Import encrypted backups reparse with the backup password before import", async () => {988    parseImportSourceMock989      .mockResolvedValueOnce(990        sampleImportResult({991          format: "peach-json",992          label: "Peach encrypted backup",993          entries: [],994          totalItems: 0,995          requiresPassword: true,996        })997      )998      .mockResolvedValueOnce(999        sampleImportResult({1000          format: "peach-json",1001          label: "Peach encrypted backup",1002        })1003      );1004    sendMessageMock.mockImplementation(async (msg: unknown) => {1005      const type = (msg as { type?: string }).type;1006      if (type === "GET_ENTRIES") return { ok: true, data: [] };1007      if (type === "BATCH_ADD_ENTRIES") {1008        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1009      }1010      return { ok: true };1011    });1012    const onImport = vi.fn(async () => undefined);1013    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1014    await flushView();1015    sendMessageMock.mockClear();10161017    await selectImportFile(container, '{"type":"peach-export"}');1018    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1019    inputText(password, "backup-passphrase");1020    pressEnter(password);1021    await flushView();1022    await flushView();10231024    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1025    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");10261027    findButton(container, "Import 1").click();1028    await flushView();10291030    expect(sendMessageMock).toHaveBeenCalledWith({1031      type: "BATCH_ADD_ENTRIES",1032      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1033    });1034    expect(onImport).toHaveBeenCalled();1035  });1036});