contracts.test.tsx

46.0 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 { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { RecoveryWordEntryView } from "./RecoveryWordEntry";16import { VaultView } from "./Vault";17import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1819const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();20const triggerRingMock = vi.fn();21const triggerPipPulseMock = vi.fn();22const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();23const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2425vi.mock("../App", () => ({26  sendMessage: (msg: unknown) => sendMessageMock(msg),27  triggerRing: () => triggerRingMock(),28  triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/import", () => ({32  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),33}));3435vi.mock("../../shared/favicon", async (importOriginal) => {36  const actual = await importOriginal<typeof import("../../shared/favicon")>();37  return { ...actual, faviconUrl: () => null };38});3940vi.mock("../../crypto/totp", () => ({41  generateTOTP: () => ({ code: "123 456", remaining: 24 }),42  parseOtpauthURI: (value: string) =>43    value.startsWith("otpauth://")44      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }45      : null,46}));4748vi.mock("../components/GeneratorDrawer", () => ({49  GeneratorDrawer: () => null,50}));5152vi.mock("@zxcvbn-ts/core", () => ({53  zxcvbn: (value: string) => zxcvbnMock(value),54  zxcvbnOptions: { setOptions: vi.fn() },55}));56vi.mock("@zxcvbn-ts/language-common", () => ({57  adjacencyGraphs: {},58  dictionary: {},59}));60vi.mock("@zxcvbn-ts/language-en", () => ({61  translations: {},62  dictionary: {},63}));6465describe("popup contract tests", () => {66  const disposers: Array<() => void> = [];6768  beforeEach(() => {69    document.body.innerHTML = "";70    sendMessageMock.mockReset();71    triggerRingMock.mockReset();72    triggerPipPulseMock.mockReset();73    parseImportSourceMock.mockReset();74    zxcvbnMock.mockReset();75    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));76    parseImportSourceMock.mockResolvedValue({77      format: "unknown",78      label: "Unknown format",79      entries: [],80      totalItems: 0,81      skippedItems: 0,82      requiresPassword: false,83    });8485    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {86      return window.setTimeout(() => cb(0), 0) as unknown as number;87    });88    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));89    vi.stubGlobal("URL", {90      createObjectURL: vi.fn(() => "blob:peach-test"),91      revokeObjectURL: vi.fn(),92    });93    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9495    (globalThis as { chrome?: typeof chrome }).chrome = {96      runtime: {97        getManifest: () => ({ version: "1.4.47" }),98        onMessage: { addListener: vi.fn() },99      },100    } as unknown as typeof chrome;101  });102103  afterEach(() => {104    while (disposers.length > 0) {105      disposers.pop()?.();106    }107    vi.restoreAllMocks();108    vi.unstubAllGlobals();109  });110111  function mount(view: JSX.Element | (() => JSX.Element)) {112    const mounted = renderView(view);113    disposers.push(mounted.dispose);114    return mounted.container;115  }116117  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {118    return {119      id: "entry-1",120      type: "login",121      title: "GitHub",122      username: "june",123      password: "secret123",124      url: "https://github.com",125      totp: "JBSWY3DPEHPK3PXP",126      canary: {127        webhookUrl: "https://example.com/hook",128      },129      createdAt: 1,130      modifiedAt: 1,131      ...overrides,132    };133  }134135  function sampleImportResult(136    overrides: Partial<ImportParseResult> = {}137  ): ImportParseResult {138    return {139      format: "csv",140      label: "CSV import",141      entries: [142        {143          type: "login",144          title: "Imported site",145          username: "imported-user",146          password: "imported-pass",147          url: "https://imported.test",148        },149      ],150      totalItems: 1,151      skippedItems: 0,152      requiresPassword: false,153      ...overrides,154    };155  }156157  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {158    return {159      entriesCount: 1,160      breachMonitoring: true,161      lockOnClose: true,162      autoLockMinutes: 5,163      trashRetentionDays: 30,164      theme: "dark" as const,165      accentColor: "peach" as const,166      textSize: "medium" as const,167      reduceMotion: false,168      onBreachMonitoringChange: vi.fn(),169      onLockOnCloseChange: vi.fn(),170      onAutoLockMinutesChange: vi.fn(),171      onTrashRetentionChange: vi.fn(),172      onThemeChange: vi.fn(),173      onAccentChange: vi.fn(),174      onTextSizeChange: vi.fn(),175      onReduceMotionChange: vi.fn(),176      shortcuts: DEFAULT_BINDINGS,177      onShortcutsChange: vi.fn(),178      onBack: vi.fn(),179      onImport: vi.fn(),180      onExport: vi.fn(),181      onBreachCheckDone: vi.fn(),182      ...overrides,183    };184  }185186  async function selectImportFile(187    container: HTMLElement,188    contents = '{"export":"test"}'189  ) {190    // The Import view now opens onto a menu — click into the file subview191    // before locating the file input. Idempotent: a second click won't re-fire192    // because the menu unmounts once the subview renders.193    const fileCard = findButton(container, "Choose file →");194    if (fileCard) {195      fileCard.click();196      await flushView();197    }198    const input = container.querySelector('input[type="file"]') as HTMLInputElement;199    const file = new File([contents], "import.json", { type: "application/json" });200    Object.defineProperty(input, "files", {201      configurable: true,202      value: [file],203    });204    input.dispatchEvent(new Event("change", { bubbles: true }));205    await flushView();206    await flushView();207  }208209  function findButton(container: HTMLElement, text: string): HTMLButtonElement {210    return Array.from(container.querySelectorAll("button")).find(211      (candidate) => candidate.textContent?.trim() === text212    ) as HTMLButtonElement;213  }214215  it("Lock sends UNLOCK with the entered passphrase", async () => {216    sendMessageMock.mockResolvedValue({ ok: true });217    const onUnlock = vi.fn(async () => undefined);218    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);219220    const password = container.querySelector("#unlock-password") as HTMLInputElement;221    inputText(password, "correct horse battery staple");222    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();223    await flushView();224225    expect(sendMessageMock).toHaveBeenCalledWith({226      type: "UNLOCK",227      payload: { password: "correct horse battery staple" },228    });229    expect(onUnlock).toHaveBeenCalled();230  });231232  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {233    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);234    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;235    const password = container.querySelector("#master-password") as HTMLInputElement;236    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;237238    inputText(password, "short");239    inputText(confirm, "short");240    await flushView();241    expect(createButton.disabled).toBe(true);242243    zxcvbnMock.mockImplementation(() => ({ score: 1 }));244    inputText(password, "weakweakweak");245    inputText(confirm, "weakweakweak");246    await flushView();247    expect(createButton.disabled).toBe(true);248  });249250  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {251    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock252    sendMessageMock.mockImplementation(253      async (msg: unknown) => {254        const m = msg as { type: string; payload?: { password?: string } };255        if (m.type === "CREATE_VAULT") return { ok: true };256        if (m.type === "GET_RECOVERY_WORDS") {257          // Return 12 fake words — must pass findInvalidWords258          return {259            ok: true,260            data: [261              "abandon", "ability", "able", "about", "above", "absent",262              "absorb", "abstract", "absurd", "abuse", "access", "accident",263            ],264          };265        }266        return { ok: true };267      }268    );269    zxcvbnMock.mockImplementation(() => ({ score: 3 }));270    const onUnlock = vi.fn(async () => undefined);271    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);272273    const password = container.querySelector("#master-password") as HTMLInputElement;274    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;275    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;276277    inputText(password, "correct horse battery staple");278    inputText(confirm, "correct horse battery staple");279    await flushView();280    expect(createButton.disabled).toBe(false);281282    pressEnter(confirm);283    await flushView();284285    expect(sendMessageMock).toHaveBeenCalledWith({286      type: "CREATE_VAULT",287      payload: { password: "correct horse battery staple" },288    });289    // After CREATE_VAULT success, the recovery word display is shown.290    // onUnlock is NOT called yet — user must first confirm their recovery words.291    expect(onUnlock).not.toHaveBeenCalled();292  });293294  it("Recovery words flow submits the new master password and completes", async () => {295    sendMessageMock.mockImplementation(async (msg: unknown) => {296      const type = (msg as { type?: string }).type;297      if (type === "VALIDATE_RECOVERY_WORDS") {298        return { ok: true, data: { valid: true, invalid: [] } };299      }300      if (type === "RECOVER_WITH_WORDS") return { ok: true };301      return { ok: true };302    });303    const canvasContext = {304      clearRect: vi.fn(),305      fillText: vi.fn(),306      beginPath: vi.fn(),307      closePath: vi.fn(),308      arc: vi.fn(),309      fill: vi.fn(),310      stroke: vi.fn(),311      save: vi.fn(),312      restore: vi.fn(),313      translate: vi.fn(),314      rotate: vi.fn(),315      scale: vi.fn(),316      drawImage: vi.fn(),317      measureText: vi.fn(() => ({ width: 0 })),318      setTransform: vi.fn(),319      fillRect: vi.fn(),320      strokeRect: vi.fn(),321    } as unknown as CanvasRenderingContext2D;322    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);323324    const onRecoveryComplete = vi.fn(async () => undefined);325    const container = mount(326      <RecoveryWordEntryView327        onCancel={vi.fn()}328        onRecoveryComplete={onRecoveryComplete}329      />330    );331332    const recoveryWords = WORD_LIST.slice(0, 12);333    for (let i = 0; i < recoveryWords.length; i++) {334      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;335      inputText(input, recoveryWords[i]!);336    }337    await flushView();338339    findButton(container, "verify words").click();340    await flushView();341342    expect(container.textContent).toContain("new master password");343344    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;345    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;346    inputText(newPassword, "fresh-recovery-password");347    inputText(confirmPassword, "fresh-recovery-password");348    await flushView();349350    findButton(container, "recover vault").click();351    await flushView();352353    expect(sendMessageMock).toHaveBeenCalledWith({354      type: "RECOVER_WITH_WORDS",355      payload: {356        words: recoveryWords,357        newPassword: "fresh-recovery-password",358      },359    });360    expect(onRecoveryComplete).toHaveBeenCalled();361  });362363  it("Export menu surfaces both backup formats", async () => {364    const container = mount(<ExportView onBack={vi.fn()} />);365    await flushView();366367    expect(container.textContent).toContain("PeachScript");368    expect(container.textContent).toContain("CSV");369  });370371  it("Lock onboarding restore options include Peach Codex", async () => {372    const onOpenCodexRestore = vi.fn();373    const container = mount(374      <LockView375        hasVault={false}376        ready={true}377        onUnlock={vi.fn()}378        onOpenCodexRestore={onOpenCodexRestore}379      />380    );381382    const restoreButton = Array.from(container.querySelectorAll("button")).find(383      (candidate) => candidate.textContent?.includes("restore from backup")384    ) as HTMLButtonElement;385    restoreButton.click();386    await flushView();387388    expect(container.textContent).toContain("PeachScript");389    expect(container.textContent).toContain("Codex");390    expect(container.textContent).toContain("CSV");391392    const codexTab = Array.from(container.querySelectorAll("button")).find(393      (candidate) => candidate.textContent?.trim() === "Codex"394    ) as HTMLButtonElement;395    codexTab.click();396    await flushView();397398    expect(container.textContent).toContain("Restore from Peach Codex");399    expect(container.textContent).toContain("Scan printed Codex pages");400401    const openCodexButton = Array.from(container.querySelectorAll("button")).find(402      (candidate) => candidate.textContent?.trim() === "open Codex restore →"403    ) as HTMLButtonElement;404    openCodexButton.click();405406    expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);407  });408409  it("Lock can open directly on backup restore from onboarding", async () => {410    const container = mount(() => (411      <LockView412        hasVault={false}413        ready={true}414        onUnlock={vi.fn()}415        initialRestore={true}416      />417    ));418    await flushView();419420    expect(container.textContent).toContain("PeachScript");421    expect(container.textContent).toContain("Codex");422    expect(container.textContent).toContain("CSV");423    expect(container.querySelector("#master-password")).toBeNull();424  });425426  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {427    const device = {428      deviceId: "phone-1",429      deviceName: "June's Phone",430      deviceType: "android" as const,431      longTermPublicKeyHex: "a".repeat(64),432      signingPublicKeyHex: "b".repeat(64),433      pairedAt: 1,434      trustSource: "direct" as const,435    };436    sendMessageMock.mockImplementation(async (msg: unknown) => {437      const m = msg as { type: string };438      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };439      return { ok: true, data: null };440    });441442    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);443    for (let i = 0; i < 30; i++) await Promise.resolve();444445    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>446      /sync/i.test(b.textContent ?? ""),447    );448    openSync?.click();449    for (let i = 0; i < 30; i++) await Promise.resolve();450451    const remove = Array.from(container.querySelectorAll("button")).find(452      (b) => (b.textContent ?? "").trim() === "remove",453    );454    expect(remove).toBeTruthy();455456    // Guard against a vacuous pass: the disclosure must not already be on457    // screen before the user asks to remove anything.458    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();459460    remove!.click();461    for (let i = 0; i < 10; i++) await Promise.resolve();462463    // Revocation must never be presented as securing the vault against the464    // removed device — recovery words survive it and restore everything.465    const note = container.querySelector(".peach-unpair-confirm-note");466    expect(note).toBeTruthy();467    const text = note!.textContent ?? "";468    expect(text).toContain("master password");469    expect(text).toContain("recovery words");470    expect(text).toContain("future changes");471  });472473  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {474    const device = {475      deviceId: "phone-1",476      deviceName: "June's Phone",477      deviceType: "android" as const,478      longTermPublicKeyHex: "a".repeat(64),479      signingPublicKeyHex: "b".repeat(64),480      pairedAt: 1,481      trustSource: "direct" as const,482    };483    let paired: typeof device[] = [device];484    sendMessageMock.mockImplementation(async (msg: any) => {485      if (msg?.type === "LIST_PAIRED_DEVICES") {486        return { ok: true, data: { devices: [...paired] } };487      }488      if (msg?.type === "UNPAIR_DEVICE") {489        // Mirror the real handler: UNPAIR_DEVICE drops the row from the490        // registry. The next LIST_PAIRED_DEVICES must not see it.491        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);492        return { ok: true };493      }494      return { ok: true, data: null };495    });496497    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);498    for (let i = 0; i < 30; i++) await Promise.resolve();499    Array.from(container.querySelectorAll("button"))500      .find((b) => /sync/i.test(b.textContent ?? ""))501      ?.click();502    for (let i = 0; i < 30; i++) await Promise.resolve();503504    // The list itself: extracting the relay selector must not take the hub with it.505    expect(container.textContent).toContain("PAIRED DEVICES");506    expect(container.textContent).toContain("June's Phone");507    expect(container.textContent).toContain("Android");508509    Array.from(container.querySelectorAll("button"))510      .find((b) => (b.textContent ?? "").trim() === "remove")!511      .click();512    for (let i = 0; i < 10; i++) await Promise.resolve();513    const confirm = Array.from(514      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),515    ).find((b) => (b.textContent ?? "").trim() === "remove");516    confirm!.click();517    for (let i = 0; i < 30; i++) await Promise.resolve();518519    expect(sendMessageMock).toHaveBeenCalledWith({520      type: "UNPAIR_DEVICE",521      payload: { deviceId: "phone-1" },522    });523    // The list must be refreshed and the device must be gone. The regression524    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but525    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.526    expect(container.textContent).not.toContain("June's Phone");527    expect(container.textContent).not.toContain("PAIRED DEVICES");528  });529530  it("Settings keeps connection and recovery details collapsed until requested", async () => {531    sendMessageMock.mockImplementation(async (msg: any) => {532      if (msg?.type === "LIST_PAIRED_DEVICES") {533        return { ok: true, data: { devices: [] } };534      }535      return { ok: true, data: null };536    });537538    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);539    for (let i = 0; i < 30; i++) await Promise.resolve();540    Array.from(container.querySelectorAll("button"))541      .find((button) => /sync/i.test(button.textContent ?? ""))542      ?.click();543    for (let i = 0; i < 20; i++) await Promise.resolve();544545    expect(container.textContent).toContain("SYNC STATUS");546    expect(container.textContent).toContain("ADD A DEVICE");547    expect(container.textContent).not.toContain("Public relay");548    expect(container.textContent).not.toContain("Rotate sync identity");549550    const connection = Array.from(container.querySelectorAll("button"))551      .find((button) => button.textContent?.includes("CONNECTION"));552    connection?.click();553    await Promise.resolve();554    expect(container.textContent).toContain("Public relay");555556    const recovery = Array.from(container.querySelectorAll("button"))557      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));558    recovery?.click();559    await Promise.resolve();560    expect(container.textContent).toContain("Rotate sync identity");561  });562563  it("Settings requires current, new, and confirm passwords before enabling save", async () => {564    sendMessageMock.mockResolvedValue({ ok: true, data: null });565    const container = mount(566      <SettingsView567        entriesCount={1}568        breachMonitoring={true}569        lockOnClose={true}570        autoLockMinutes={5}571        trashRetentionDays={30}572        theme="dark"573        accentColor="peach"574        textSize="medium"575        reduceMotion={false}576        onBreachMonitoringChange={vi.fn()}577        onLockOnCloseChange={vi.fn()}578        onAutoLockMinutesChange={vi.fn()}579        onTrashRetentionChange={vi.fn()}580        onThemeChange={vi.fn()}581        onAccentChange={vi.fn()}582        onTextSizeChange={vi.fn()}583        onReduceMotionChange={vi.fn()}584        shortcuts={DEFAULT_BINDINGS}585        onShortcutsChange={vi.fn()}586        onBack={vi.fn()}587        onImport={vi.fn()}588        onExport={vi.fn()}589        onBreachCheckDone={vi.fn()}590      />591    );592    await flushView();593594    expect(container.querySelectorAll(".stk-card").length).toBe(7);595    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]596    // "open settings to card I (Peach Pro) instead of card II (Protection)".597    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");598599    const changeButton = Array.from(container.querySelectorAll("button")).find(600      (candidate) => candidate.textContent?.trim() === "change"601    ) as HTMLButtonElement;602    changeButton.click();603    await flushView();604605    const saveButton = Array.from(container.querySelectorAll("button")).find(606      (candidate) => candidate.textContent?.trim() === "save new password"607    ) as HTMLButtonElement;608    expect(saveButton.disabled).toBe(true);609610    const inputs = container.querySelectorAll('input[type="password"]');611    inputText(inputs[0] as HTMLInputElement, "current-pass");612    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");613    await flushView();614    expect(saveButton.disabled).toBe(true);615616    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");617    await flushView();618    expect(saveButton.disabled).toBe(false);619  });620621  it("Settings feedback highlights the selected category and hides the form after submit", async () => {622    sendMessageMock.mockResolvedValue({ ok: true, data: null });623    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({624      ok: true,625      json: async () => ({ success: true }),626    }));627    vi.stubGlobal("fetch", fetchMock);628629    const container = mount(630      <SettingsView631        entriesCount={1}632        breachMonitoring={true}633        lockOnClose={true}634        autoLockMinutes={5}635        trashRetentionDays={30}636        theme="dark"637        accentColor="peach"638        textSize="medium"639        reduceMotion={false}640        initialSection="feedback"641        onBreachMonitoringChange={vi.fn()}642        onLockOnCloseChange={vi.fn()}643        onAutoLockMinutesChange={vi.fn()}644        onTrashRetentionChange={vi.fn()}645        onThemeChange={vi.fn()}646        onAccentChange={vi.fn()}647        onTextSizeChange={vi.fn()}648        onReduceMotionChange={vi.fn()}649        shortcuts={DEFAULT_BINDINGS}650        onShortcutsChange={vi.fn()}651        onBack={vi.fn()}652        onImport={vi.fn()}653        onExport={vi.fn()}654        onBreachCheckDone={vi.fn()}655      />656    );657    await flushView();658659    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;660    expect(feedbackCard.classList.contains("is-active")).toBe(true);661662    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];663    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;664    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;665    expect(bugButton.getAttribute("aria-pressed")).toBe("true");666    expect(featureButton.getAttribute("aria-pressed")).toBe("false");667668    featureButton.click();669    await flushView();670671    expect(bugButton.getAttribute("aria-pressed")).toBe("false");672    expect(featureButton.getAttribute("aria-pressed")).toBe("true");673    expect(featureButton.classList.contains("is-active")).toBe(true);674675    inputText(676      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,677      '<script>alert("xss")</script> Please make this easier to see & safer.'678    );679    const sendButton = Array.from(container.querySelectorAll("button")).find(680      (candidate) => candidate.textContent?.trim() === "Send"681    ) as HTMLButtonElement;682    sendButton.click();683684    await vi.waitFor(() => {685      expect(container.textContent).toContain("Thank you");686    });687688    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));689    expect(requestBody.subject).toContain("Feature request");690    expect(requestBody.message).toBe(691      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."692    );693    expect(requestBody.message).not.toContain("<script>");694    expect(container.textContent).toContain("Someone will process it soon");695    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();696    expect(container.querySelector(".peach-feedback-submit")).toBeNull();697  });698699  it("Settings keeps license activation on the full-screen celebration path", async () => {700    const onLicenseChange = vi.fn(async () => undefined);701    sendMessageMock.mockImplementation(async (msg: unknown) => {702      const type = (msg as { type?: string }).type;703      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };704      if (type === "ACTIVATE_LICENSE") {705        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };706      }707      if (type === "GET_ENTRIES") return { ok: true, data: [] };708      return { ok: true, data: null };709    });710711    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);712    await flushView();713714    inputText(715      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,716      "PEACH-TEST-KEY",717    );718    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;719    apply.click();720721    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));722    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);723    expect(container.textContent).not.toContain("thank you ✦");724  });725726  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {727    sendMessageMock.mockImplementation(async (msg: unknown) => {728      const type = (msg as { type?: string }).type;729      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };730      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };731      if (type === "GET_ENTRIES") return { ok: true, data: [] };732      return { ok: true, data: null };733    });734735    const onLicenseChangeFromStar = vi.fn();736    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);737    await flushView();738    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();739    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);740    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();741742    const onLicenseChangeFromPeach = vi.fn();743    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);744    await flushView();745    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();746    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);747    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();748  });749750  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {751    sendMessageMock.mockResolvedValue({ ok: true, data: null });752    const fetchMock = vi.fn();753    const requestMock = vi.fn(async () => false);754    vi.stubGlobal("fetch", fetchMock);755    vi.stubGlobal("browser", {756      permissions: {757        getAll: vi.fn(async () => ({ data_collection: [] })),758        request: requestMock,759      },760    });761762    const container = mount(763      <SettingsView764        entriesCount={1}765        breachMonitoring={true}766        lockOnClose={true}767        autoLockMinutes={5}768        trashRetentionDays={30}769        theme="dark"770        accentColor="peach"771        textSize="medium"772        reduceMotion={false}773        initialSection="feedback"774        onBreachMonitoringChange={vi.fn()}775        onLockOnCloseChange={vi.fn()}776        onAutoLockMinutesChange={vi.fn()}777        onTrashRetentionChange={vi.fn()}778        onThemeChange={vi.fn()}779        onAccentChange={vi.fn()}780        onTextSizeChange={vi.fn()}781        onReduceMotionChange={vi.fn()}782        shortcuts={DEFAULT_BINDINGS}783        onShortcutsChange={vi.fn()}784        onBack={vi.fn()}785        onImport={vi.fn()}786        onExport={vi.fn()}787        onBreachCheckDone={vi.fn()}788      />789    );790    await flushView();791792    inputText(793      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,794      "Please improve the feedback flow."795    );796    const sendButton = Array.from(container.querySelectorAll("button")).find(797      (candidate) => candidate.textContent?.trim() === "Send"798    ) as HTMLButtonElement;799    sendButton.click();800801    await vi.waitFor(() => {802      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });803    });804    expect(fetchMock).not.toHaveBeenCalled();805    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();806  });807808  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {809    sendMessageMock.mockImplementation(async (msg: unknown) => {810      const m = msg as { type: string };811      if (m.type === "GET_RECOVERY_WORDS") {812        return {813          ok: true,814          data: [815            "abandon", "ability", "able", "about", "above", "absent",816            "absorb", "abstract", "absurd", "abuse", "access", "accident",817          ],818        };819      }820      return { ok: true, data: null };821    });822    const onAutoLockMinutesChange = vi.fn();823    const onThemeChange = vi.fn();824    const container = mount(825      <SettingsView826        entriesCount={1}827        breachMonitoring={true}828        lockOnClose={true}829        autoLockMinutes={5}830        trashRetentionDays={30}831        theme="dark"832        accentColor="peach"833        textSize="medium"834        reduceMotion={false}835        onBreachMonitoringChange={vi.fn()}836        onLockOnCloseChange={vi.fn()}837        onAutoLockMinutesChange={onAutoLockMinutesChange}838        onTrashRetentionChange={vi.fn()}839        onThemeChange={onThemeChange}840        onAccentChange={vi.fn()}841        onTextSizeChange={vi.fn()}842        onReduceMotionChange={vi.fn()}843        shortcuts={DEFAULT_BINDINGS}844        onShortcutsChange={vi.fn()}845        onBack={vi.fn()}846        onImport={vi.fn()}847        onExport={vi.fn()}848        onBreachCheckDone={vi.fn()}849      />850    );851    await flushView();852    sendMessageMock.mockClear();853854    // ── Auto-lock + theme changes (before password change) ─────────────855    const selects = container.querySelectorAll("select");856    changeSelect(selects[0] as HTMLSelectElement, "15");857    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);858859    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;860    appearanceCard.click();861    await flushView();862    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");863    const themeButton = Array.from(container.querySelectorAll("button")).find(864      (candidate) => candidate.textContent?.trim() === "Switch to light"865    ) as HTMLButtonElement;866    themeButton.click();867    expect(onThemeChange).toHaveBeenCalledWith("light");868869    // ── Password change → recovery words display ────────────────────────870    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;871    protectionCard.click();872    await flushView();873874    const changeButton = Array.from(container.querySelectorAll("button")).find(875      (candidate) => candidate.textContent?.trim() === "change"876    ) as HTMLButtonElement;877    changeButton.click();878    await flushView();879880    const inputs = container.querySelectorAll('input[type="password"]');881    inputText(inputs[0] as HTMLInputElement, "current-pass");882    inputText(inputs[1] as HTMLInputElement, "new-password-123");883    inputText(inputs[2] as HTMLInputElement, "new-password-123");884    await flushView();885886    const saveButton = Array.from(container.querySelectorAll("button")).find(887      (candidate) => candidate.textContent?.trim() === "save new password"888    ) as HTMLButtonElement;889    saveButton.click();890    await flushView();891892    expect(sendMessageMock).toHaveBeenCalledWith({893      type: "CHANGE_PASSWORD",894      payload: {895        currentPassword: "current-pass",896        newPassword: "new-password-123",897      },898    });899900    // After a successful password change the recovery word display replaces settings.901    expect(container.textContent).toContain("save your recovery words");902    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });903  });904905  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {906    sendMessageMock.mockResolvedValue({ ok: true });907    const container = mount(908      <EntryView909        entry={sampleEntry()}910        onDelete={vi.fn()}911        onFieldSaved={vi.fn(async () => undefined)}912      />913    );914915    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;916    passwordButton.click();917    await flushView();918919    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {920      type: "COPY",921      payload: { text: "secret123", label: "password", alreadyWritten: false },922    });923    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {924      type: "FIRE_CANARY_PING",925      payload: {926        webhookUrl: "https://example.com/hook",927        entryTitle: "GitHub",928        trigger: "password_copied",929      },930    });931932    sendMessageMock.mockClear();933    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;934    usernameButton.click();935    await flushView();936937    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {938      type: "COPY",939      payload: { text: "june", label: "username", alreadyWritten: false },940    });941    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {942      type: "FIRE_CANARY_PING",943      payload: {944        webhookUrl: "https://example.com/hook",945        entryTitle: "GitHub",946        trigger: "username_copied",947      },948    });949  });950951  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {952    sendMessageMock.mockResolvedValue({ ok: true });953    const onFieldSaved = vi.fn(async () => undefined);954    const container = mount(955      <EntryView956        entry={sampleEntry()}957        onDelete={vi.fn()}958        onFieldSaved={onFieldSaved}959      />960    );961962    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;963    passwordValue.click();964    await flushView();965966    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;967    inputText(passwordInput, "");968    await flushView();969970    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;971    saveButton.click();972    await flushView();973974    expect(sendMessageMock).not.toHaveBeenCalledWith(975      expect.objectContaining({ type: "UPDATE_ENTRY" })976    );977    expect(onFieldSaved).not.toHaveBeenCalled();978979    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;980    totpMain.click();981982    await vi.waitFor(() => {983      expect(sendMessageMock).toHaveBeenLastCalledWith({984        type: "COPY",985        payload: { text: "123456", label: "totp", alreadyWritten: false },986      });987    }, { timeout: 500 });988  });989990  it("Entry closes an unchanged inline editor when focus leaves it", async () => {991    sendMessageMock.mockResolvedValue({ ok: true });992    const container = mount(993      <EntryView994        entry={sampleEntry()}995        onDelete={vi.fn()}996        onFieldSaved={vi.fn(async () => undefined)}997      />998    );9991000    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1001    usernameValue.click();1002    await flushView();10031004    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1005    usernameInput.blur();1006    await flushView();10071008    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1009    expect(sendMessageMock).not.toHaveBeenCalledWith(1010      expect.objectContaining({ type: "UPDATE_ENTRY" })1011    );1012  });10131014  it("Entry lets TOTP secrets be edited inline", async () => {1015    sendMessageMock.mockResolvedValue({ ok: true });1016    const onFieldSaved = vi.fn(async () => undefined);1017    const container = mount(1018      <EntryView1019        entry={sampleEntry()}1020        onDelete={vi.fn()}1021        onFieldSaved={onFieldSaved}1022      />1023    );10241025    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1026    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1027    await flushView();10281029    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1030    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1031    await flushView();10321033    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1034    saveButton.click();1035    await flushView();10361037    expect(sendMessageMock).toHaveBeenCalledWith({1038      type: "UPDATE_ENTRY",1039      payload: {1040        id: "entry-1",1041        patch: { totp: "NB2W45DFOIZA====" },1042      },1043    });1044    expect(onFieldSaved).toHaveBeenCalled();1045  });10461047  it("Vault seeds and focuses search when a search seed is provided", async () => {1048    const container = mount(1049      <VaultView1050        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1051        sortOrder="url-first"1052        searchSeed="g"1053        searchSeedNonce={1}1054        onSelectEntry={vi.fn()}1055        onSortOrderChange={vi.fn()}1056        onAdd={vi.fn()}1057        onClose={vi.fn()}1058      />1059    );10601061    await flushView();10621063    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1064    expect(search.value).toBe("g");1065    expect(document.activeElement).toBe(search);1066  });10671068  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1069    parseImportSourceMock.mockResolvedValue(1070      sampleImportResult({1071        entries: [1072          {1073            type: "login",1074            title: "Imported site",1075            username: "imported-user",1076            password: "imported-pass",1077            url: "https://imported.test",1078          },1079        ],1080      })1081    );1082    sendMessageMock.mockImplementation(async (msg: unknown) => {1083      const type = (msg as { type?: string }).type;1084      if (type === "GET_ENTRIES") {1085        return {1086          ok: true,1087          data: [1088            sampleEntry({1089              id: "existing-1",1090              title: "Imported site",1091              username: "imported-user",1092              url: "https://imported.test",1093            }),1094          ],1095        };1096      }1097      if (type === "BATCH_ADD_ENTRIES") {1098        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1099      }1100      return { ok: true };1101    });1102    const onImport = vi.fn(async () => undefined);1103    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1104    await flushView();1105    sendMessageMock.mockClear();11061107    await selectImportFile(container);1108    findButton(container, "Import 1").click();1109    await flushView();11101111    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1112    expect(sendMessageMock).toHaveBeenCalledWith({1113      type: "BATCH_ADD_ENTRIES",1114      payload: {1115        entries: [1116          expect.objectContaining({1117            title: "Imported site",1118            username: "imported-user",1119            password: "imported-pass",1120            url: "https://imported.test",1121          }),1122        ],1123      },1124    });1125    expect(onImport).toHaveBeenCalled();1126    expect(container.textContent).toContain("1 entry appears to be duplicates");1127  });11281129  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1130    parseImportSourceMock.mockResolvedValue(sampleImportResult());1131    sendMessageMock.mockImplementation(async (msg: unknown) => {1132      const type = (msg as { type?: string }).type;1133      if (type === "GET_ENTRIES") {1134        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1135      }1136      if (type === "CLEAR_VAULT_ENTRIES") {1137        return { ok: true };1138      }1139      if (type === "BATCH_ADD_ENTRIES") {1140        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1141      }1142      return { ok: true };1143    });1144    const onImport = vi.fn(async () => undefined);1145    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1146    await flushView();1147    sendMessageMock.mockClear();11481149    await selectImportFile(container);1150    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1151    await flushView();11521153    findButton(container, "Replace vault with 1").click();1154    await flushView();11551156    expect(sendMessageMock).not.toHaveBeenCalledWith(1157      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1158    );1159    expect(container.textContent).toContain("Replace forever? This cannot be undone.");11601161    findButton(container, "Yes, replace forever").click();1162    await flushView();11631164    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1165    expect(sendMessageMock).toHaveBeenCalledWith({1166      type: "BATCH_ADD_ENTRIES",1167      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1168    });1169    expect(onImport).toHaveBeenCalled();1170  });11711172  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1173    parseImportSourceMock.mockResolvedValue(sampleImportResult());1174    sendMessageMock.mockImplementation(async (msg: unknown) => {1175      const type = (msg as { type?: string }).type;1176      if (type === "CREATE_VAULT") return { ok: true };1177      if (type === "BATCH_ADD_ENTRIES") {1178        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1179      }1180      return { ok: true };1181    });1182    const onImport = vi.fn(async () => undefined);1183    const container = mount(1184      <ImportView1185        mode="onboarding"1186        replacesExistingVault={true}1187        onBack={vi.fn()}1188        onImport={onImport}1189      />1190    );11911192    await selectImportFile(container);11931194    const createButton = findButton(container, "Create vault + import 1");1195    expect(createButton.disabled).toBe(true);1196    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1197    expect(container.textContent).toContain("Use at least 8 characters");11981199    const masterInput = container.querySelector(1200      'input[placeholder="new master password"]'1201    ) as HTMLInputElement;1202    const confirmInput = container.querySelector(1203      'input[placeholder="confirm master password"]'1204    ) as HTMLInputElement;1205    const revealButton = container.querySelector(1206      'button[aria-label="Show passwords"]'1207    ) as HTMLButtonElement;1208    expect(revealButton).toBeTruthy();1209    revealButton.click();1210    await flushView();1211    expect(masterInput.type).toBe("text");1212    expect(confirmInput.type).toBe("text");12131214    inputText(masterInput, "fresh-master-password");1215    inputText(confirmInput, "fresh-master-password");1216    await flushView();12171218    expect(createButton.disabled).toBe(false);1219    createButton.click();1220    await flushView();12211222    expect(sendMessageMock).toHaveBeenCalledWith({1223      type: "CREATE_VAULT",1224      payload: { password: "fresh-master-password" },1225    });1226    expect(sendMessageMock).toHaveBeenCalledWith({1227      type: "BATCH_ADD_ENTRIES",1228      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1229    });1230    expect(onImport).toHaveBeenCalled();1231  });12321233  it("Import encrypted backups reparse with the backup password before import", async () => {1234    parseImportSourceMock1235      .mockResolvedValueOnce(1236        sampleImportResult({1237          format: "peach-json",1238          label: "Peach encrypted backup",1239          entries: [],1240          totalItems: 0,1241          requiresPassword: true,1242        })1243      )1244      .mockResolvedValueOnce(1245        sampleImportResult({1246          format: "peach-json",1247          label: "Peach encrypted backup",1248        })1249      );1250    sendMessageMock.mockImplementation(async (msg: unknown) => {1251      const type = (msg as { type?: string }).type;1252      if (type === "GET_ENTRIES") return { ok: true, data: [] };1253      if (type === "BATCH_ADD_ENTRIES") {1254        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1255      }1256      return { ok: true };1257    });1258    const onImport = vi.fn(async () => undefined);1259    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1260    await flushView();1261    sendMessageMock.mockClear();12621263    await selectImportFile(container, '{"type":"peach-export"}');1264    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1265    inputText(password, "backup-passphrase");1266    pressEnter(password);1267    await flushView();1268    await flushView();12691270    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1271    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12721273    findButton(container, "Import 1").click();1274    await flushView();12751276    expect(sendMessageMock).toHaveBeenCalledWith({1277      type: "BATCH_ADD_ENTRIES",1278      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1279    });1280    expect(onImport).toHaveBeenCalled();1281  });1282});