contracts.test.tsx

38.2 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { DEFAULT_BINDINGS } from "../../shared/shortcuts";12import { EntryView } from "./Entry";13import { ImportView } from "./Import";14import { VaultView } from "./Vault";15import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1617const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();18const triggerRingMock = vi.fn();19const triggerPipPulseMock = vi.fn();20const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();21const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2223vi.mock("../App", () => ({24  sendMessage: (msg: unknown) => sendMessageMock(msg),25  triggerRing: () => triggerRingMock(),26  triggerPipPulse: () => triggerPipPulseMock(),27}));2829vi.mock("../../shared/import", () => ({30  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),31}));3233vi.mock("../../shared/favicon", async (importOriginal) => {34  const actual = await importOriginal<typeof import("../../shared/favicon")>();35  return { ...actual, faviconUrl: () => null };36});3738vi.mock("../../crypto/totp", () => ({39  generateTOTP: () => ({ code: "123 456", remaining: 24 }),40  parseOtpauthURI: (value: string) =>41    value.startsWith("otpauth://")42      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }43      : null,44}));4546vi.mock("../components/GeneratorDrawer", () => ({47  GeneratorDrawer: () => null,48}));4950vi.mock("zxcvbn", () => ({51  default: (value: string) => zxcvbnMock(value),52}));5354describe("popup contract tests", () => {55  const disposers: Array<() => void> = [];5657  beforeEach(() => {58    document.body.innerHTML = "";59    sendMessageMock.mockReset();60    triggerRingMock.mockReset();61    triggerPipPulseMock.mockReset();62    parseImportSourceMock.mockReset();63    zxcvbnMock.mockReset();64    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));65    parseImportSourceMock.mockResolvedValue({66      format: "unknown",67      label: "Unknown format",68      entries: [],69      totalItems: 0,70      skippedItems: 0,71      requiresPassword: false,72    });7374    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {75      return window.setTimeout(() => cb(0), 0) as unknown as number;76    });77    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));78    vi.stubGlobal("URL", {79      createObjectURL: vi.fn(() => "blob:peach-test"),80      revokeObjectURL: vi.fn(),81    });82    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8384    (globalThis as { chrome?: typeof chrome }).chrome = {85      runtime: {86        getManifest: () => ({ version: "1.4.47" }),87        onMessage: { addListener: vi.fn() },88      },89    } as unknown as typeof chrome;90  });9192  afterEach(() => {93    while (disposers.length > 0) {94      disposers.pop()?.();95    }96    vi.unstubAllGlobals();97  });9899  function mount(view: JSX.Element) {100    const mounted = renderView(view);101    disposers.push(mounted.dispose);102    return mounted.container;103  }104105  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {106    return {107      id: "entry-1",108      type: "login",109      title: "GitHub",110      username: "june",111      password: "secret123",112      url: "https://github.com",113      totp: "JBSWY3DPEHPK3PXP",114      canary: {115        webhookUrl: "https://example.com/hook",116      },117      createdAt: 1,118      modifiedAt: 1,119      ...overrides,120    };121  }122123  function sampleImportResult(124    overrides: Partial<ImportParseResult> = {}125  ): ImportParseResult {126    return {127      format: "csv",128      label: "CSV import",129      entries: [130        {131          type: "login",132          title: "Imported site",133          username: "imported-user",134          password: "imported-pass",135          url: "https://imported.test",136        },137      ],138      totalItems: 1,139      skippedItems: 0,140      requiresPassword: false,141      ...overrides,142    };143  }144145  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {146    return {147      entriesCount: 1,148      breachMonitoring: true,149      lockOnClose: true,150      autoLockMinutes: 5,151      trashRetentionDays: 30,152      theme: "dark" as const,153      accentColor: "peach" as const,154      textSize: "medium" as const,155      reduceMotion: false,156      onBreachMonitoringChange: vi.fn(),157      onLockOnCloseChange: vi.fn(),158      onAutoLockMinutesChange: vi.fn(),159      onTrashRetentionChange: vi.fn(),160      onThemeChange: vi.fn(),161      onAccentChange: vi.fn(),162      onTextSizeChange: vi.fn(),163      onReduceMotionChange: vi.fn(),164      shortcuts: DEFAULT_BINDINGS,165      onShortcutsChange: vi.fn(),166      onBack: vi.fn(),167      onImport: vi.fn(),168      onExport: vi.fn(),169      onBreachCheckDone: vi.fn(),170      ...overrides,171    };172  }173174  async function selectImportFile(175    container: HTMLElement,176    contents = '{"export":"test"}'177  ) {178    // The Import view now opens onto a menu — click into the file subview179    // before locating the file input. Idempotent: a second click won't re-fire180    // because the menu unmounts once the subview renders.181    const fileCard = findButton(container, "Choose file →");182    if (fileCard) {183      fileCard.click();184      await flushView();185    }186    const input = container.querySelector('input[type="file"]') as HTMLInputElement;187    const file = new File([contents], "import.json", { type: "application/json" });188    Object.defineProperty(input, "files", {189      configurable: true,190      value: [file],191    });192    input.dispatchEvent(new Event("change", { bubbles: true }));193    await flushView();194    await flushView();195  }196197  function findButton(container: HTMLElement, text: string): HTMLButtonElement {198    return Array.from(container.querySelectorAll("button")).find(199      (candidate) => candidate.textContent?.trim() === text200    ) as HTMLButtonElement;201  }202203  it("Lock sends UNLOCK with the entered passphrase", async () => {204    sendMessageMock.mockResolvedValue({ ok: true });205    const onUnlock = vi.fn(async () => undefined);206    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);207208    const password = container.querySelector("#unlock-password") as HTMLInputElement;209    inputText(password, "correct horse battery staple");210    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();211    await flushView();212213    expect(sendMessageMock).toHaveBeenCalledWith({214      type: "UNLOCK",215      payload: { password: "correct horse battery staple" },216    });217    expect(onUnlock).toHaveBeenCalled();218  });219220  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {221    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);222    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;223    const password = container.querySelector("#master-password") as HTMLInputElement;224    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;225226    inputText(password, "short");227    inputText(confirm, "short");228    await flushView();229    expect(createButton.disabled).toBe(true);230231    zxcvbnMock.mockImplementation(() => ({ score: 1 }));232    inputText(password, "weakweakweak");233    inputText(confirm, "weakweakweak");234    await flushView();235    expect(createButton.disabled).toBe(true);236  });237238  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {239    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock240    sendMessageMock.mockImplementation(241      async (msg: unknown) => {242        const m = msg as { type: string; payload?: { password?: string } };243        if (m.type === "CREATE_VAULT") return { ok: true };244        if (m.type === "GET_RECOVERY_WORDS") {245          // Return 12 fake words — must pass findInvalidWords246          return {247            ok: true,248            data: [249              "abandon", "ability", "able", "about", "above", "absent",250              "absorb", "abstract", "absurd", "abuse", "access", "accident",251            ],252          };253        }254        return { ok: true };255      }256    );257    zxcvbnMock.mockImplementation(() => ({ score: 3 }));258    const onUnlock = vi.fn(async () => undefined);259    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);260261    const password = container.querySelector("#master-password") as HTMLInputElement;262    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;263    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;264265    inputText(password, "correct horse battery staple");266    inputText(confirm, "correct horse battery staple");267    await flushView();268    expect(createButton.disabled).toBe(false);269270    pressEnter(confirm);271    await flushView();272273    expect(sendMessageMock).toHaveBeenCalledWith({274      type: "CREATE_VAULT",275      payload: { password: "correct horse battery staple" },276    });277    // After CREATE_VAULT success, the recovery word display is shown.278    // onUnlock is NOT called yet — user must first confirm their recovery words.279    expect(onUnlock).not.toHaveBeenCalled();280  });281282  it("Export menu surfaces both backup formats", async () => {283    const container = mount(<ExportView onBack={vi.fn()} />);284    await flushView();285286    expect(container.textContent).toContain("PeachScript");287    expect(container.textContent).toContain("CSV");288  });289290  it("Lock onboarding restore options include Peach Codex", async () => {291    const onOpenCodexRestore = vi.fn();292    const container = mount(293      <LockView294        hasVault={false}295        ready={true}296        onUnlock={vi.fn()}297        onOpenCodexRestore={onOpenCodexRestore}298      />299    );300301    const restoreButton = Array.from(container.querySelectorAll("button")).find(302      (candidate) => candidate.textContent?.includes("restore from backup")303    ) as HTMLButtonElement;304    restoreButton.click();305    await flushView();306307    expect(container.textContent).toContain("PeachScript");308    expect(container.textContent).toContain("Codex");309    expect(container.textContent).toContain("CSV");310311    const codexTab = Array.from(container.querySelectorAll("button")).find(312      (candidate) => candidate.textContent?.trim() === "Codex"313    ) as HTMLButtonElement;314    codexTab.click();315    await flushView();316317    expect(container.textContent).toContain("Restore from Peach Codex");318    expect(container.textContent).toContain("Scan printed Codex pages");319320    const openCodexButton = Array.from(container.querySelectorAll("button")).find(321      (candidate) => candidate.textContent?.trim() === "open Codex restore →"322    ) as HTMLButtonElement;323    openCodexButton.click();324325    expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);326  });327328  it("Settings requires current, new, and confirm passwords before enabling save", async () => {329    sendMessageMock.mockResolvedValue({ ok: true, data: null });330    const container = mount(331      <SettingsView332        entriesCount={1}333        breachMonitoring={true}334        lockOnClose={true}335        autoLockMinutes={5}336        trashRetentionDays={30}337        theme="dark"338        accentColor="peach"339        textSize="medium"340        reduceMotion={false}341        onBreachMonitoringChange={vi.fn()}342        onLockOnCloseChange={vi.fn()}343        onAutoLockMinutesChange={vi.fn()}344        onTrashRetentionChange={vi.fn()}345        onThemeChange={vi.fn()}346        onAccentChange={vi.fn()}347        onTextSizeChange={vi.fn()}348        onReduceMotionChange={vi.fn()}349        shortcuts={DEFAULT_BINDINGS}350        onShortcutsChange={vi.fn()}351        onBack={vi.fn()}352        onImport={vi.fn()}353        onExport={vi.fn()}354        onBreachCheckDone={vi.fn()}355      />356    );357    await flushView();358359    expect(container.querySelectorAll(".stk-card").length).toBe(7);360    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]361    // "open settings to card I (Peach Pro) instead of card II (Protection)".362    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");363364    const changeButton = Array.from(container.querySelectorAll("button")).find(365      (candidate) => candidate.textContent?.trim() === "change"366    ) as HTMLButtonElement;367    changeButton.click();368    await flushView();369370    const saveButton = Array.from(container.querySelectorAll("button")).find(371      (candidate) => candidate.textContent?.trim() === "save new password"372    ) as HTMLButtonElement;373    expect(saveButton.disabled).toBe(true);374375    const inputs = container.querySelectorAll('input[type="password"]');376    inputText(inputs[0] as HTMLInputElement, "current-pass");377    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");378    await flushView();379    expect(saveButton.disabled).toBe(true);380381    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");382    await flushView();383    expect(saveButton.disabled).toBe(false);384  });385386  it("Settings feedback highlights the selected category and hides the form after submit", async () => {387    sendMessageMock.mockResolvedValue({ ok: true, data: null });388    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({389      ok: true,390      json: async () => ({ success: true }),391    }));392    vi.stubGlobal("fetch", fetchMock);393394    const container = mount(395      <SettingsView396        entriesCount={1}397        breachMonitoring={true}398        lockOnClose={true}399        autoLockMinutes={5}400        trashRetentionDays={30}401        theme="dark"402        accentColor="peach"403        textSize="medium"404        reduceMotion={false}405        initialSection="feedback"406        onBreachMonitoringChange={vi.fn()}407        onLockOnCloseChange={vi.fn()}408        onAutoLockMinutesChange={vi.fn()}409        onTrashRetentionChange={vi.fn()}410        onThemeChange={vi.fn()}411        onAccentChange={vi.fn()}412        onTextSizeChange={vi.fn()}413        onReduceMotionChange={vi.fn()}414        shortcuts={DEFAULT_BINDINGS}415        onShortcutsChange={vi.fn()}416        onBack={vi.fn()}417        onImport={vi.fn()}418        onExport={vi.fn()}419        onBreachCheckDone={vi.fn()}420      />421    );422    await flushView();423424    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;425    expect(feedbackCard.classList.contains("is-active")).toBe(true);426427    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];428    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;429    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;430    expect(bugButton.getAttribute("aria-pressed")).toBe("true");431    expect(featureButton.getAttribute("aria-pressed")).toBe("false");432433    featureButton.click();434    await flushView();435436    expect(bugButton.getAttribute("aria-pressed")).toBe("false");437    expect(featureButton.getAttribute("aria-pressed")).toBe("true");438    expect(featureButton.classList.contains("is-active")).toBe(true);439440    inputText(441      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,442      '<script>alert("xss")</script> Please make this easier to see & safer.'443    );444    const sendButton = Array.from(container.querySelectorAll("button")).find(445      (candidate) => candidate.textContent?.trim() === "Send"446    ) as HTMLButtonElement;447    sendButton.click();448449    await vi.waitFor(() => {450      expect(container.textContent).toContain("Thank you");451    });452453    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));454    expect(requestBody.subject).toContain("Feature request");455    expect(requestBody.message).toBe(456      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."457    );458    expect(requestBody.message).not.toContain("<script>");459    expect(container.textContent).toContain("Someone will process it soon");460    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();461    expect(container.querySelector(".peach-feedback-submit")).toBeNull();462  });463464  it("Settings keeps license activation on the full-screen celebration path", async () => {465    const onLicenseChange = vi.fn(async () => undefined);466    sendMessageMock.mockImplementation(async (msg: unknown) => {467      const type = (msg as { type?: string }).type;468      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };469      if (type === "ACTIVATE_LICENSE") {470        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };471      }472      if (type === "GET_ENTRIES") return { ok: true, data: [] };473      return { ok: true, data: null };474    });475476    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);477    await flushView();478479    inputText(480      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,481      "PEACH-TEST-KEY",482    );483    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;484    apply.click();485486    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));487    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);488    expect(container.textContent).not.toContain("thank you ✦");489  });490491  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {492    sendMessageMock.mockImplementation(async (msg: unknown) => {493      const type = (msg as { type?: string }).type;494      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };495      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };496      if (type === "GET_ENTRIES") return { ok: true, data: [] };497      return { ok: true, data: null };498    });499500    const onLicenseChangeFromStar = vi.fn();501    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);502    await flushView();503    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();504    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);505    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();506507    const onLicenseChangeFromPeach = vi.fn();508    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);509    await flushView();510    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();511    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);512    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();513  });514515  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {516    sendMessageMock.mockResolvedValue({ ok: true, data: null });517    const fetchMock = vi.fn();518    const requestMock = vi.fn(async () => false);519    vi.stubGlobal("fetch", fetchMock);520    vi.stubGlobal("browser", {521      permissions: {522        getAll: vi.fn(async () => ({ data_collection: [] })),523        request: requestMock,524      },525    });526527    const container = mount(528      <SettingsView529        entriesCount={1}530        breachMonitoring={true}531        lockOnClose={true}532        autoLockMinutes={5}533        trashRetentionDays={30}534        theme="dark"535        accentColor="peach"536        textSize="medium"537        reduceMotion={false}538        initialSection="feedback"539        onBreachMonitoringChange={vi.fn()}540        onLockOnCloseChange={vi.fn()}541        onAutoLockMinutesChange={vi.fn()}542        onTrashRetentionChange={vi.fn()}543        onThemeChange={vi.fn()}544        onAccentChange={vi.fn()}545        onTextSizeChange={vi.fn()}546        onReduceMotionChange={vi.fn()}547        shortcuts={DEFAULT_BINDINGS}548        onShortcutsChange={vi.fn()}549        onBack={vi.fn()}550        onImport={vi.fn()}551        onExport={vi.fn()}552        onBreachCheckDone={vi.fn()}553      />554    );555    await flushView();556557    inputText(558      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,559      "Please improve the feedback flow."560    );561    const sendButton = Array.from(container.querySelectorAll("button")).find(562      (candidate) => candidate.textContent?.trim() === "Send"563    ) as HTMLButtonElement;564    sendButton.click();565566    await vi.waitFor(() => {567      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });568    });569    expect(fetchMock).not.toHaveBeenCalled();570    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();571  });572573  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {574    sendMessageMock.mockImplementation(async (msg: unknown) => {575      const m = msg as { type: string };576      if (m.type === "GET_RECOVERY_WORDS") {577        return {578          ok: true,579          data: [580            "abandon", "ability", "able", "about", "above", "absent",581            "absorb", "abstract", "absurd", "abuse", "access", "accident",582          ],583        };584      }585      return { ok: true, data: null };586    });587    const onAutoLockMinutesChange = vi.fn();588    const onThemeChange = vi.fn();589    const container = mount(590      <SettingsView591        entriesCount={1}592        breachMonitoring={true}593        lockOnClose={true}594        autoLockMinutes={5}595        trashRetentionDays={30}596        theme="dark"597        accentColor="peach"598        textSize="medium"599        reduceMotion={false}600        onBreachMonitoringChange={vi.fn()}601        onLockOnCloseChange={vi.fn()}602        onAutoLockMinutesChange={onAutoLockMinutesChange}603        onTrashRetentionChange={vi.fn()}604        onThemeChange={onThemeChange}605        onAccentChange={vi.fn()}606        onTextSizeChange={vi.fn()}607        onReduceMotionChange={vi.fn()}608        shortcuts={DEFAULT_BINDINGS}609        onShortcutsChange={vi.fn()}610        onBack={vi.fn()}611        onImport={vi.fn()}612        onExport={vi.fn()}613        onBreachCheckDone={vi.fn()}614      />615    );616    await flushView();617    sendMessageMock.mockClear();618619    // ── Auto-lock + theme changes (before password change) ─────────────620    const selects = container.querySelectorAll("select");621    changeSelect(selects[0] as HTMLSelectElement, "15");622    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);623624    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;625    appearanceCard.click();626    await flushView();627    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");628    const themeButton = Array.from(container.querySelectorAll("button")).find(629      (candidate) => candidate.textContent?.trim() === "Switch to light"630    ) as HTMLButtonElement;631    themeButton.click();632    expect(onThemeChange).toHaveBeenCalledWith("light");633634    // ── Password change → recovery words display ────────────────────────635    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;636    protectionCard.click();637    await flushView();638639    const changeButton = Array.from(container.querySelectorAll("button")).find(640      (candidate) => candidate.textContent?.trim() === "change"641    ) as HTMLButtonElement;642    changeButton.click();643    await flushView();644645    const inputs = container.querySelectorAll('input[type="password"]');646    inputText(inputs[0] as HTMLInputElement, "current-pass");647    inputText(inputs[1] as HTMLInputElement, "new-password-123");648    inputText(inputs[2] as HTMLInputElement, "new-password-123");649    await flushView();650651    const saveButton = Array.from(container.querySelectorAll("button")).find(652      (candidate) => candidate.textContent?.trim() === "save new password"653    ) as HTMLButtonElement;654    saveButton.click();655    await flushView();656657    expect(sendMessageMock).toHaveBeenCalledWith({658      type: "CHANGE_PASSWORD",659      payload: {660        currentPassword: "current-pass",661        newPassword: "new-password-123",662      },663    });664665    // After a successful password change the recovery word display replaces settings.666    expect(container.textContent).toContain("save your recovery words");667    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });668  });669670  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {671    sendMessageMock.mockResolvedValue({ ok: true });672    const container = mount(673      <EntryView674        entry={sampleEntry()}675        onDelete={vi.fn()}676        onFieldSaved={vi.fn(async () => undefined)}677      />678    );679680    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;681    passwordButton.click();682    await flushView();683684    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {685      type: "COPY",686      payload: { text: "secret123", label: "password", alreadyWritten: false },687    });688    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {689      type: "FIRE_CANARY_PING",690      payload: {691        webhookUrl: "https://example.com/hook",692        entryTitle: "GitHub",693        trigger: "password_copied",694      },695    });696697    sendMessageMock.mockClear();698    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;699    usernameButton.click();700    await flushView();701702    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {703      type: "COPY",704      payload: { text: "june", label: "username", alreadyWritten: false },705    });706    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {707      type: "FIRE_CANARY_PING",708      payload: {709        webhookUrl: "https://example.com/hook",710        entryTitle: "GitHub",711        trigger: "username_copied",712      },713    });714  });715716  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {717    sendMessageMock.mockResolvedValue({ ok: true });718    const onFieldSaved = vi.fn(async () => undefined);719    const container = mount(720      <EntryView721        entry={sampleEntry()}722        onDelete={vi.fn()}723        onFieldSaved={onFieldSaved}724      />725    );726727    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;728    passwordValue.click();729    await flushView();730731    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;732    inputText(passwordInput, "");733    await flushView();734735    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;736    saveButton.click();737    await flushView();738739    expect(sendMessageMock).not.toHaveBeenCalledWith(740      expect.objectContaining({ type: "UPDATE_ENTRY" })741    );742    expect(onFieldSaved).not.toHaveBeenCalled();743744    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;745    totpMain.click();746747    await vi.waitFor(() => {748      expect(sendMessageMock).toHaveBeenLastCalledWith({749        type: "COPY",750        payload: { text: "123456", label: "totp", alreadyWritten: false },751      });752    }, { timeout: 500 });753  });754755  it("Entry closes an unchanged inline editor when focus leaves it", async () => {756    sendMessageMock.mockResolvedValue({ ok: true });757    const container = mount(758      <EntryView759        entry={sampleEntry()}760        onDelete={vi.fn()}761        onFieldSaved={vi.fn(async () => undefined)}762      />763    );764765    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;766    usernameValue.click();767    await flushView();768769    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;770    usernameInput.blur();771    await flushView();772773    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();774    expect(sendMessageMock).not.toHaveBeenCalledWith(775      expect.objectContaining({ type: "UPDATE_ENTRY" })776    );777  });778779  it("Entry lets TOTP secrets be edited inline", async () => {780    sendMessageMock.mockResolvedValue({ ok: true });781    const onFieldSaved = vi.fn(async () => undefined);782    const container = mount(783      <EntryView784        entry={sampleEntry()}785        onDelete={vi.fn()}786        onFieldSaved={onFieldSaved}787      />788    );789790    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;791    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));792    await flushView();793794    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;795    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");796    await flushView();797798    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;799    saveButton.click();800    await flushView();801802    expect(sendMessageMock).toHaveBeenCalledWith({803      type: "UPDATE_ENTRY",804      payload: {805        id: "entry-1",806        patch: { totp: "NB2W45DFOIZA====" },807      },808    });809    expect(onFieldSaved).toHaveBeenCalled();810  });811812  it("Vault seeds and focuses search when a search seed is provided", async () => {813    const container = mount(814      <VaultView815        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}816        sortOrder="url-first"817        searchSeed="g"818        searchSeedNonce={1}819        onSelectEntry={vi.fn()}820        onSortOrderChange={vi.fn()}821        onAdd={vi.fn()}822        onClose={vi.fn()}823      />824    );825826    await flushView();827828    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;829    expect(search.value).toBe("g");830    expect(document.activeElement).toBe(search);831  });832833  it("Vault search scope toggle flips between emails and usernames", async () => {834    const container = mount(835      <VaultView836        entries={[837          sampleEntry({838            id: "entry-2",839            title: "Mailbox",840            username: "june-user",841            email: "[email protected]",842            url: "https://mail.example.com",843          }),844        ]}845        sortOrder="recent"846        onSelectEntry={vi.fn()}847        onSortOrderChange={vi.fn()}848        onAdd={vi.fn()}849        onClose={vi.fn()}850      />851    );852853    await flushView();854855    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;856    inputText(search, "june-user");857    await flushView();858859    expect(container.querySelectorAll(".peach-entry-row")).toHaveLength(1);860861    const scopeButtons = Array.from(container.querySelectorAll(".peach-sort-scope-btn")) as HTMLButtonElement[];862    const emails = scopeButtons.find((button) => button.textContent?.trim() === "emails");863    const usernames = scopeButtons.find((button) => button.textContent?.trim() === "usernames");864    expect(emails).toBeTruthy();865    expect(usernames).toBeTruthy();866867    emails?.click();868    await flushView();869    expect(container.querySelectorAll(".peach-entry-row")).toHaveLength(0);870871    usernames?.click();872    await flushView();873    expect(container.querySelectorAll(".peach-entry-row")).toHaveLength(1);874  });875876  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {877    parseImportSourceMock.mockResolvedValue(878      sampleImportResult({879        entries: [880          {881            type: "login",882            title: "Imported site",883            username: "imported-user",884            password: "imported-pass",885            url: "https://imported.test",886          },887        ],888      })889    );890    sendMessageMock.mockImplementation(async (msg: unknown) => {891      const type = (msg as { type?: string }).type;892      if (type === "GET_ENTRIES") {893        return {894          ok: true,895          data: [896            sampleEntry({897              id: "existing-1",898              title: "Imported site",899              username: "imported-user",900              url: "https://imported.test",901            }),902          ],903        };904      }905      if (type === "BATCH_ADD_ENTRIES") {906        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };907      }908      return { ok: true };909    });910    const onImport = vi.fn(async () => undefined);911    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);912    await flushView();913    sendMessageMock.mockClear();914915    await selectImportFile(container);916    findButton(container, "Import 1").click();917    await flushView();918919    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);920    expect(sendMessageMock).toHaveBeenCalledWith({921      type: "BATCH_ADD_ENTRIES",922      payload: {923        entries: [924          expect.objectContaining({925            title: "Imported site",926            username: "imported-user",927            password: "imported-pass",928            url: "https://imported.test",929          }),930        ],931      },932    });933    expect(onImport).toHaveBeenCalled();934    expect(container.textContent).toContain("1 entry appears to be duplicates");935  });936937  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {938    parseImportSourceMock.mockResolvedValue(sampleImportResult());939    sendMessageMock.mockImplementation(async (msg: unknown) => {940      const type = (msg as { type?: string }).type;941      if (type === "GET_ENTRIES") {942        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };943      }944      if (type === "CLEAR_VAULT_ENTRIES") {945        return { ok: true };946      }947      if (type === "BATCH_ADD_ENTRIES") {948        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };949      }950      return { ok: true };951    });952    const onImport = vi.fn(async () => undefined);953    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);954    await flushView();955    sendMessageMock.mockClear();956957    await selectImportFile(container);958    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();959    await flushView();960961    findButton(container, "Replace vault with 1").click();962    await flushView();963964    expect(sendMessageMock).not.toHaveBeenCalledWith(965      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })966    );967    expect(container.textContent).toContain("Replace forever? This cannot be undone.");968969    findButton(container, "Yes, replace forever").click();970    await flushView();971972    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });973    expect(sendMessageMock).toHaveBeenCalledWith({974      type: "BATCH_ADD_ENTRIES",975      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },976    });977    expect(onImport).toHaveBeenCalled();978  });979980  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {981    parseImportSourceMock.mockResolvedValue(sampleImportResult());982    sendMessageMock.mockImplementation(async (msg: unknown) => {983      const type = (msg as { type?: string }).type;984      if (type === "CREATE_VAULT") return { ok: true };985      if (type === "BATCH_ADD_ENTRIES") {986        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };987      }988      return { ok: true };989    });990    const onImport = vi.fn(async () => undefined);991    const container = mount(992      <ImportView993        mode="onboarding"994        replacesExistingVault={true}995        onBack={vi.fn()}996        onImport={onImport}997      />998    );9991000    await selectImportFile(container);10011002    const createButton = findButton(container, "Create vault + import 1");1003    expect(createButton.disabled).toBe(true);1004    expect(container.textContent).toContain("Restoring now replaces the locked local vault");10051006    const masterInput = container.querySelector(1007      'input[placeholder="new master password"]'1008    ) as HTMLInputElement;1009    const confirmInput = container.querySelector(1010      'input[placeholder="confirm master password"]'1011    ) as HTMLInputElement;1012    inputText(masterInput, "fresh-master-password");1013    inputText(confirmInput, "fresh-master-password");1014    await flushView();10151016    expect(createButton.disabled).toBe(false);1017    createButton.click();1018    await flushView();10191020    expect(sendMessageMock).toHaveBeenCalledWith({1021      type: "CREATE_VAULT",1022      payload: { password: "fresh-master-password" },1023    });1024    expect(sendMessageMock).toHaveBeenCalledWith({1025      type: "BATCH_ADD_ENTRIES",1026      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1027    });1028    expect(onImport).toHaveBeenCalled();1029  });10301031  it("Import encrypted backups reparse with the backup password before import", async () => {1032    parseImportSourceMock1033      .mockResolvedValueOnce(1034        sampleImportResult({1035          format: "peach-json",1036          label: "Peach encrypted backup",1037          entries: [],1038          totalItems: 0,1039          requiresPassword: true,1040        })1041      )1042      .mockResolvedValueOnce(1043        sampleImportResult({1044          format: "peach-json",1045          label: "Peach encrypted backup",1046        })1047      );1048    sendMessageMock.mockImplementation(async (msg: unknown) => {1049      const type = (msg as { type?: string }).type;1050      if (type === "GET_ENTRIES") return { ok: true, data: [] };1051      if (type === "BATCH_ADD_ENTRIES") {1052        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1053      }1054      return { ok: true };1055    });1056    const onImport = vi.fn(async () => undefined);1057    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1058    await flushView();1059    sendMessageMock.mockClear();10601061    await selectImportFile(container, '{"type":"peach-export"}');1062    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1063    inputText(password, "backup-passphrase");1064    pressEnter(password);1065    await flushView();1066    await flushView();10671068    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1069    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");10701071    findButton(container, "Import 1").click();1072    await flushView();10731074    expect(sendMessageMock).toHaveBeenCalledWith({1075      type: "BATCH_ADD_ENTRIES",1076      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1077    });1078    expect(onImport).toHaveBeenCalled();1079  });1080});