contracts.test.tsx

45.3 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { 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 can open directly on backup restore from onboarding", async () => {372    const container = mount(() => (373      <LockView374        hasVault={false}375        ready={true}376        onUnlock={vi.fn()}377        initialRestore={true}378      />379    ));380    await flushView();381382    expect(container.textContent).toContain("PeachScript");383    expect(container.textContent).toContain("Codex");384    expect(container.textContent).toContain("CSV");385    expect(container.querySelector("#master-password")).toBeNull();386  });387388  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {389    const device = {390      deviceId: "phone-1",391      deviceName: "June's Phone",392      deviceType: "android" as const,393      longTermPublicKeyHex: "a".repeat(64),394      signingPublicKeyHex: "b".repeat(64),395      pairedAt: 1,396      trustSource: "direct" as const,397    };398    sendMessageMock.mockImplementation(async (msg: unknown) => {399      const m = msg as { type: string };400      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };401      return { ok: true, data: null };402    });403404    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);405    for (let i = 0; i < 30; i++) await Promise.resolve();406407    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>408      /sync/i.test(b.textContent ?? ""),409    );410    openSync?.click();411    for (let i = 0; i < 30; i++) await Promise.resolve();412413    const remove = Array.from(container.querySelectorAll("button")).find(414      (b) => (b.textContent ?? "").trim() === "remove",415    );416    expect(remove).toBeTruthy();417418    // Guard against a vacuous pass: the disclosure must not already be on419    // screen before the user asks to remove anything.420    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();421422    remove!.click();423    for (let i = 0; i < 10; i++) await Promise.resolve();424425    // Revocation must never be presented as securing the vault against the426    // removed device — recovery words survive it and restore everything.427    const note = container.querySelector(".peach-unpair-confirm-note");428    expect(note).toBeTruthy();429    const text = note!.textContent ?? "";430    expect(text).toContain("master password");431    expect(text).toContain("recovery words");432    expect(text).toContain("future changes");433  });434435  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {436    const device = {437      deviceId: "phone-1",438      deviceName: "June's Phone",439      deviceType: "android" as const,440      longTermPublicKeyHex: "a".repeat(64),441      signingPublicKeyHex: "b".repeat(64),442      pairedAt: 1,443      trustSource: "direct" as const,444    };445    let paired: typeof device[] = [device];446    sendMessageMock.mockImplementation(async (msg: any) => {447      if (msg?.type === "LIST_PAIRED_DEVICES") {448        return { ok: true, data: { devices: [...paired] } };449      }450      if (msg?.type === "UNPAIR_DEVICE") {451        // Mirror the real handler: UNPAIR_DEVICE drops the row from the452        // registry. The next LIST_PAIRED_DEVICES must not see it.453        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);454        return { ok: true };455      }456      return { ok: true, data: null };457    });458459    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);460    for (let i = 0; i < 30; i++) await Promise.resolve();461    Array.from(container.querySelectorAll("button"))462      .find((b) => /sync/i.test(b.textContent ?? ""))463      ?.click();464    for (let i = 0; i < 30; i++) await Promise.resolve();465466    // The list itself: extracting the relay selector must not take the hub with it.467    expect(container.textContent).toContain("PAIRED DEVICES");468    expect(container.textContent).toContain("June's Phone");469    expect(container.textContent).toContain("Android");470    const syncNowButton = container.querySelector<HTMLButtonElement>(471      'button[aria-label="Sync this vault now"]',472    )!;473    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));474    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;475    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;476    expect(477      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,478    ).toBeTruthy();479    expect(480      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,481    ).toBeTruthy();482483    Array.from(container.querySelectorAll("button"))484      .find((b) => (b.textContent ?? "").trim() === "remove")!485      .click();486    for (let i = 0; i < 10; i++) await Promise.resolve();487    const confirm = Array.from(488      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),489    ).find((b) => (b.textContent ?? "").trim() === "remove");490    confirm!.click();491    for (let i = 0; i < 30; i++) await Promise.resolve();492493    expect(sendMessageMock).toHaveBeenCalledWith({494      type: "UNPAIR_DEVICE",495      payload: { deviceId: "phone-1" },496    });497    // The list must be refreshed and the device must be gone. The regression498    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but499    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.500    expect(container.textContent).not.toContain("June's Phone");501    expect(container.textContent).not.toContain("PAIRED DEVICES");502  });503504  it("Settings keeps connection and recovery details collapsed until requested", async () => {505    sendMessageMock.mockImplementation(async (msg: any) => {506      if (msg?.type === "LIST_PAIRED_DEVICES") {507        return { ok: true, data: { devices: [] } };508      }509      return { ok: true, data: null };510    });511512    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);513    for (let i = 0; i < 30; i++) await Promise.resolve();514    Array.from(container.querySelectorAll("button"))515      .find((button) => /sync/i.test(button.textContent ?? ""))516      ?.click();517    for (let i = 0; i < 20; i++) await Promise.resolve();518519    expect(container.textContent).toContain("SYNC STATUS");520    expect(container.textContent).toContain("ADD A DEVICE");521    expect(container.textContent).not.toContain("Public relay");522    expect(container.textContent).not.toContain("Rotate sync identity");523524    const connection = Array.from(container.querySelectorAll("button"))525      .find((button) => button.textContent?.includes("CONNECTION"));526    connection?.click();527    await Promise.resolve();528    expect(container.textContent).toContain("Public relay");529530    const recovery = Array.from(container.querySelectorAll("button"))531      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));532    recovery?.click();533    await Promise.resolve();534    expect(container.textContent).toContain("Rotate sync identity");535  });536537  it("Settings requires current, new, and confirm passwords before enabling save", async () => {538    sendMessageMock.mockResolvedValue({ ok: true, data: null });539    const container = mount(540      <SettingsView541        entriesCount={1}542        breachMonitoring={true}543        lockOnClose={true}544        autoLockMinutes={5}545        trashRetentionDays={30}546        theme="dark"547        accentColor="peach"548        textSize="medium"549        reduceMotion={false}550        onBreachMonitoringChange={vi.fn()}551        onLockOnCloseChange={vi.fn()}552        onAutoLockMinutesChange={vi.fn()}553        onTrashRetentionChange={vi.fn()}554        onThemeChange={vi.fn()}555        onAccentChange={vi.fn()}556        onTextSizeChange={vi.fn()}557        onReduceMotionChange={vi.fn()}558        shortcuts={DEFAULT_BINDINGS}559        onShortcutsChange={vi.fn()}560        onBack={vi.fn()}561        onImport={vi.fn()}562        onExport={vi.fn()}563        onBreachCheckDone={vi.fn()}564      />565    );566    await flushView();567568    expect(container.querySelectorAll(".stk-card").length).toBe(7);569    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]570    // "open settings to card I (Peach Pro) instead of card II (Protection)".571    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");572573    const changeButton = Array.from(container.querySelectorAll("button")).find(574      (candidate) => candidate.textContent?.trim() === "change"575    ) as HTMLButtonElement;576    changeButton.click();577    await flushView();578579    const saveButton = Array.from(container.querySelectorAll("button")).find(580      (candidate) => candidate.textContent?.trim() === "save new password"581    ) as HTMLButtonElement;582    expect(saveButton.disabled).toBe(true);583584    const inputs = container.querySelectorAll('input[type="password"]');585    inputText(inputs[0] as HTMLInputElement, "current-pass");586    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");587    await flushView();588    expect(saveButton.disabled).toBe(true);589590    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");591    await flushView();592    expect(saveButton.disabled).toBe(false);593  });594595  it("Settings feedback highlights the selected category and hides the form after submit", async () => {596    sendMessageMock.mockResolvedValue({ ok: true, data: null });597    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({598      ok: true,599      json: async () => ({ success: true }),600    }));601    vi.stubGlobal("fetch", fetchMock);602603    const container = mount(604      <SettingsView605        entriesCount={1}606        breachMonitoring={true}607        lockOnClose={true}608        autoLockMinutes={5}609        trashRetentionDays={30}610        theme="dark"611        accentColor="peach"612        textSize="medium"613        reduceMotion={false}614        initialSection="feedback"615        onBreachMonitoringChange={vi.fn()}616        onLockOnCloseChange={vi.fn()}617        onAutoLockMinutesChange={vi.fn()}618        onTrashRetentionChange={vi.fn()}619        onThemeChange={vi.fn()}620        onAccentChange={vi.fn()}621        onTextSizeChange={vi.fn()}622        onReduceMotionChange={vi.fn()}623        shortcuts={DEFAULT_BINDINGS}624        onShortcutsChange={vi.fn()}625        onBack={vi.fn()}626        onImport={vi.fn()}627        onExport={vi.fn()}628        onBreachCheckDone={vi.fn()}629      />630    );631    await flushView();632633    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;634    expect(feedbackCard.classList.contains("is-active")).toBe(true);635636    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];637    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;638    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;639    expect(bugButton.getAttribute("aria-pressed")).toBe("true");640    expect(featureButton.getAttribute("aria-pressed")).toBe("false");641642    featureButton.click();643    await flushView();644645    expect(bugButton.getAttribute("aria-pressed")).toBe("false");646    expect(featureButton.getAttribute("aria-pressed")).toBe("true");647    expect(featureButton.classList.contains("is-active")).toBe(true);648649    inputText(650      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,651      '<script>alert("xss")</script> Please make this easier to see & safer.'652    );653    const sendButton = Array.from(container.querySelectorAll("button")).find(654      (candidate) => candidate.textContent?.trim() === "Send"655    ) as HTMLButtonElement;656    sendButton.click();657658    await vi.waitFor(() => {659      expect(container.textContent).toContain("Thank you");660    });661662    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));663    expect(requestBody.subject).toContain("Feature request");664    expect(requestBody.message).toBe(665      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."666    );667    expect(requestBody.message).not.toContain("<script>");668    expect(container.textContent).toContain("Someone will process it soon");669    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();670    expect(container.querySelector(".peach-feedback-submit")).toBeNull();671  });672673  it("Settings keeps license activation on the full-screen celebration path", async () => {674    const onLicenseChange = vi.fn(async () => undefined);675    sendMessageMock.mockImplementation(async (msg: unknown) => {676      const type = (msg as { type?: string }).type;677      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };678      if (type === "ACTIVATE_LICENSE") {679        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };680      }681      if (type === "GET_ENTRIES") return { ok: true, data: [] };682      return { ok: true, data: null };683    });684685    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);686    await flushView();687688    inputText(689      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,690      "PEACH-TEST-KEY",691    );692    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;693    apply.click();694695    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));696    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);697    expect(container.textContent).not.toContain("thank you ✦");698  });699700  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {701    sendMessageMock.mockImplementation(async (msg: unknown) => {702      const type = (msg as { type?: string }).type;703      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };704      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };705      if (type === "GET_ENTRIES") return { ok: true, data: [] };706      return { ok: true, data: null };707    });708709    const onLicenseChangeFromStar = vi.fn();710    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);711    await flushView();712    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();713    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);714    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();715716    const onLicenseChangeFromPeach = vi.fn();717    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);718    await flushView();719    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();720    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);721    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();722  });723724  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {725    sendMessageMock.mockResolvedValue({ ok: true, data: null });726    const fetchMock = vi.fn();727    const requestMock = vi.fn(async () => false);728    vi.stubGlobal("fetch", fetchMock);729    vi.stubGlobal("browser", {730      permissions: {731        getAll: vi.fn(async () => ({ data_collection: [] })),732        request: requestMock,733      },734    });735736    const container = mount(737      <SettingsView738        entriesCount={1}739        breachMonitoring={true}740        lockOnClose={true}741        autoLockMinutes={5}742        trashRetentionDays={30}743        theme="dark"744        accentColor="peach"745        textSize="medium"746        reduceMotion={false}747        initialSection="feedback"748        onBreachMonitoringChange={vi.fn()}749        onLockOnCloseChange={vi.fn()}750        onAutoLockMinutesChange={vi.fn()}751        onTrashRetentionChange={vi.fn()}752        onThemeChange={vi.fn()}753        onAccentChange={vi.fn()}754        onTextSizeChange={vi.fn()}755        onReduceMotionChange={vi.fn()}756        shortcuts={DEFAULT_BINDINGS}757        onShortcutsChange={vi.fn()}758        onBack={vi.fn()}759        onImport={vi.fn()}760        onExport={vi.fn()}761        onBreachCheckDone={vi.fn()}762      />763    );764    await flushView();765766    inputText(767      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,768      "Please improve the feedback flow."769    );770    const sendButton = Array.from(container.querySelectorAll("button")).find(771      (candidate) => candidate.textContent?.trim() === "Send"772    ) as HTMLButtonElement;773    sendButton.click();774775    await vi.waitFor(() => {776      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });777    });778    expect(fetchMock).not.toHaveBeenCalled();779    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();780  });781782  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {783    sendMessageMock.mockImplementation(async (msg: unknown) => {784      const m = msg as { type: string };785      if (m.type === "GET_RECOVERY_WORDS") {786        return {787          ok: true,788          data: [789            "abandon", "ability", "able", "about", "above", "absent",790            "absorb", "abstract", "absurd", "abuse", "access", "accident",791          ],792        };793      }794      return { ok: true, data: null };795    });796    const onAutoLockMinutesChange = vi.fn();797    const onThemeChange = vi.fn();798    const container = mount(799      <SettingsView800        entriesCount={1}801        breachMonitoring={true}802        lockOnClose={true}803        autoLockMinutes={5}804        trashRetentionDays={30}805        theme="dark"806        accentColor="peach"807        textSize="medium"808        reduceMotion={false}809        onBreachMonitoringChange={vi.fn()}810        onLockOnCloseChange={vi.fn()}811        onAutoLockMinutesChange={onAutoLockMinutesChange}812        onTrashRetentionChange={vi.fn()}813        onThemeChange={onThemeChange}814        onAccentChange={vi.fn()}815        onTextSizeChange={vi.fn()}816        onReduceMotionChange={vi.fn()}817        shortcuts={DEFAULT_BINDINGS}818        onShortcutsChange={vi.fn()}819        onBack={vi.fn()}820        onImport={vi.fn()}821        onExport={vi.fn()}822        onBreachCheckDone={vi.fn()}823      />824    );825    await flushView();826    sendMessageMock.mockClear();827828    // ── Auto-lock + theme changes (before password change) ─────────────829    const selects = container.querySelectorAll("select");830    changeSelect(selects[0] as HTMLSelectElement, "15");831    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);832833    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;834    appearanceCard.click();835    await flushView();836    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");837    const themeButton = Array.from(container.querySelectorAll("button")).find(838      (candidate) => candidate.textContent?.trim() === "Switch to light"839    ) as HTMLButtonElement;840    themeButton.click();841    expect(onThemeChange).toHaveBeenCalledWith("light");842843    // ── Password change → recovery words display ────────────────────────844    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;845    protectionCard.click();846    await flushView();847848    const changeButton = Array.from(container.querySelectorAll("button")).find(849      (candidate) => candidate.textContent?.trim() === "change"850    ) as HTMLButtonElement;851    changeButton.click();852    await flushView();853854    const inputs = container.querySelectorAll('input[type="password"]');855    inputText(inputs[0] as HTMLInputElement, "current-pass");856    inputText(inputs[1] as HTMLInputElement, "new-password-123");857    inputText(inputs[2] as HTMLInputElement, "new-password-123");858    await flushView();859860    const saveButton = Array.from(container.querySelectorAll("button")).find(861      (candidate) => candidate.textContent?.trim() === "save new password"862    ) as HTMLButtonElement;863    saveButton.click();864    await flushView();865866    expect(sendMessageMock).toHaveBeenCalledWith({867      type: "CHANGE_PASSWORD",868      payload: {869        currentPassword: "current-pass",870        newPassword: "new-password-123",871      },872    });873874    // After a successful password change the recovery word display replaces settings.875    expect(container.textContent).toContain("save your recovery words");876    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });877  });878879  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {880    sendMessageMock.mockResolvedValue({ ok: true });881    const container = mount(882      <EntryView883        entry={sampleEntry()}884        onDelete={vi.fn()}885        onFieldSaved={vi.fn(async () => undefined)}886      />887    );888889    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;890    passwordButton.click();891    await flushView();892893    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {894      type: "COPY",895      payload: { text: "secret123", label: "password", alreadyWritten: false },896    });897    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {898      type: "FIRE_CANARY_PING",899      payload: {900        webhookUrl: "https://example.com/hook",901        entryTitle: "GitHub",902        trigger: "password_copied",903      },904    });905906    sendMessageMock.mockClear();907    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;908    usernameButton.click();909    await flushView();910911    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {912      type: "COPY",913      payload: { text: "june", label: "username", alreadyWritten: false },914    });915    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {916      type: "FIRE_CANARY_PING",917      payload: {918        webhookUrl: "https://example.com/hook",919        entryTitle: "GitHub",920        trigger: "username_copied",921      },922    });923  });924925  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {926    sendMessageMock.mockResolvedValue({ ok: true });927    const onFieldSaved = vi.fn(async () => undefined);928    const container = mount(929      <EntryView930        entry={sampleEntry()}931        onDelete={vi.fn()}932        onFieldSaved={onFieldSaved}933      />934    );935936    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;937    passwordValue.click();938    await flushView();939940    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;941    inputText(passwordInput, "");942    await flushView();943944    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;945    saveButton.click();946    await flushView();947948    expect(sendMessageMock).not.toHaveBeenCalledWith(949      expect.objectContaining({ type: "UPDATE_ENTRY" })950    );951    expect(onFieldSaved).not.toHaveBeenCalled();952953    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;954    totpMain.click();955956    await vi.waitFor(() => {957      expect(sendMessageMock).toHaveBeenLastCalledWith({958        type: "COPY",959        payload: { text: "123456", label: "totp", alreadyWritten: false },960      });961    }, { timeout: 500 });962  });963964  it("Entry closes an unchanged inline editor when focus leaves it", async () => {965    sendMessageMock.mockResolvedValue({ ok: true });966    const container = mount(967      <EntryView968        entry={sampleEntry()}969        onDelete={vi.fn()}970        onFieldSaved={vi.fn(async () => undefined)}971      />972    );973974    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;975    usernameValue.click();976    await flushView();977978    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;979    usernameInput.blur();980    await flushView();981982    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();983    expect(sendMessageMock).not.toHaveBeenCalledWith(984      expect.objectContaining({ type: "UPDATE_ENTRY" })985    );986  });987988  it("Entry lets TOTP secrets be edited inline", async () => {989    sendMessageMock.mockResolvedValue({ ok: true });990    const onFieldSaved = vi.fn(async () => undefined);991    const container = mount(992      <EntryView993        entry={sampleEntry()}994        onDelete={vi.fn()}995        onFieldSaved={onFieldSaved}996      />997    );998999    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1000    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1001    await flushView();10021003    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1004    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1005    await flushView();10061007    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1008    saveButton.click();1009    await flushView();10101011    expect(sendMessageMock).toHaveBeenCalledWith({1012      type: "UPDATE_ENTRY",1013      payload: {1014        id: "entry-1",1015        patch: { totp: "NB2W45DFOIZA====" },1016      },1017    });1018    expect(onFieldSaved).toHaveBeenCalled();1019  });10201021  it("Vault seeds and focuses search when a search seed is provided", async () => {1022    const container = mount(1023      <VaultView1024        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1025        sortOrder="url-first"1026        searchSeed="g"1027        searchSeedNonce={1}1028        onSelectEntry={vi.fn()}1029        onSortOrderChange={vi.fn()}1030        onAdd={vi.fn()}1031        onClose={vi.fn()}1032      />1033    );10341035    await flushView();10361037    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1038    expect(search.value).toBe("g");1039    expect(document.activeElement).toBe(search);1040  });10411042  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1043    parseImportSourceMock.mockResolvedValue(1044      sampleImportResult({1045        entries: [1046          {1047            type: "login",1048            title: "Imported site",1049            username: "imported-user",1050            password: "imported-pass",1051            url: "https://imported.test",1052          },1053        ],1054      })1055    );1056    sendMessageMock.mockImplementation(async (msg: unknown) => {1057      const type = (msg as { type?: string }).type;1058      if (type === "GET_ENTRIES") {1059        return {1060          ok: true,1061          data: [1062            sampleEntry({1063              id: "existing-1",1064              title: "Imported site",1065              username: "imported-user",1066              url: "https://imported.test",1067            }),1068          ],1069        };1070      }1071      if (type === "BATCH_ADD_ENTRIES") {1072        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1073      }1074      return { ok: true };1075    });1076    const onImport = vi.fn(async () => undefined);1077    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1078    await flushView();1079    sendMessageMock.mockClear();10801081    await selectImportFile(container);1082    findButton(container, "Import 1").click();1083    await flushView();10841085    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1086    expect(sendMessageMock).toHaveBeenCalledWith({1087      type: "BATCH_ADD_ENTRIES",1088      payload: {1089        entries: [1090          expect.objectContaining({1091            title: "Imported site",1092            username: "imported-user",1093            password: "imported-pass",1094            url: "https://imported.test",1095          }),1096        ],1097      },1098    });1099    expect(onImport).toHaveBeenCalled();1100    expect(container.textContent).toContain("1 entry appears to be duplicates");1101  });11021103  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1104    parseImportSourceMock.mockResolvedValue(sampleImportResult());1105    sendMessageMock.mockImplementation(async (msg: unknown) => {1106      const type = (msg as { type?: string }).type;1107      if (type === "GET_ENTRIES") {1108        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1109      }1110      if (type === "CLEAR_VAULT_ENTRIES") {1111        return { ok: true };1112      }1113      if (type === "BATCH_ADD_ENTRIES") {1114        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1115      }1116      return { ok: true };1117    });1118    const onImport = vi.fn(async () => undefined);1119    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1120    await flushView();1121    sendMessageMock.mockClear();11221123    await selectImportFile(container);1124    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1125    await flushView();11261127    findButton(container, "Replace vault with 1").click();1128    await flushView();11291130    expect(sendMessageMock).not.toHaveBeenCalledWith(1131      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1132    );1133    expect(container.textContent).toContain("Replace forever? This cannot be undone.");11341135    findButton(container, "Yes, replace forever").click();1136    await flushView();11371138    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1139    expect(sendMessageMock).toHaveBeenCalledWith({1140      type: "BATCH_ADD_ENTRIES",1141      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1142    });1143    expect(onImport).toHaveBeenCalled();1144  });11451146  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1147    parseImportSourceMock.mockResolvedValue(sampleImportResult());1148    sendMessageMock.mockImplementation(async (msg: unknown) => {1149      const type = (msg as { type?: string }).type;1150      if (type === "CREATE_VAULT") return { ok: true };1151      if (type === "BATCH_ADD_ENTRIES") {1152        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1153      }1154      return { ok: true };1155    });1156    const onImport = vi.fn(async () => undefined);1157    const container = mount(1158      <ImportView1159        mode="onboarding"1160        replacesExistingVault={true}1161        onBack={vi.fn()}1162        onImport={onImport}1163      />1164    );11651166    await selectImportFile(container);11671168    const createButton = findButton(container, "Create vault + import 1");1169    expect(createButton.disabled).toBe(true);1170    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1171    expect(container.textContent).toContain("Use at least 8 characters");11721173    const masterInput = container.querySelector(1174      'input[placeholder="new master password"]'1175    ) as HTMLInputElement;1176    const confirmInput = container.querySelector(1177      'input[placeholder="confirm master password"]'1178    ) as HTMLInputElement;1179    const revealButton = container.querySelector(1180      'button[aria-label="Show passwords"]'1181    ) as HTMLButtonElement;1182    expect(revealButton).toBeTruthy();1183    revealButton.click();1184    await flushView();1185    expect(masterInput.type).toBe("text");1186    expect(confirmInput.type).toBe("text");11871188    inputText(masterInput, "fresh-master-password");1189    inputText(confirmInput, "fresh-master-password");1190    await flushView();11911192    expect(createButton.disabled).toBe(false);1193    createButton.click();1194    await flushView();11951196    expect(sendMessageMock).toHaveBeenCalledWith({1197      type: "CREATE_VAULT",1198      payload: { password: "fresh-master-password" },1199    });1200    expect(sendMessageMock).toHaveBeenCalledWith({1201      type: "BATCH_ADD_ENTRIES",1202      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1203    });1204    expect(onImport).toHaveBeenCalled();1205  });12061207  it("Import encrypted backups reparse with the backup password before import", async () => {1208    parseImportSourceMock1209      .mockResolvedValueOnce(1210        sampleImportResult({1211          format: "peach-json",1212          label: "Peach encrypted backup",1213          entries: [],1214          totalItems: 0,1215          requiresPassword: true,1216        })1217      )1218      .mockResolvedValueOnce(1219        sampleImportResult({1220          format: "peach-json",1221          label: "Peach encrypted backup",1222        })1223      );1224    sendMessageMock.mockImplementation(async (msg: unknown) => {1225      const type = (msg as { type?: string }).type;1226      if (type === "GET_ENTRIES") return { ok: true, data: [] };1227      if (type === "BATCH_ADD_ENTRIES") {1228        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1229      }1230      return { ok: true };1231    });1232    const onImport = vi.fn(async () => undefined);1233    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1234    await flushView();1235    sendMessageMock.mockClear();12361237    await selectImportFile(container, '{"type":"peach-export"}');1238    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1239    inputText(password, "backup-passphrase");1240    pressEnter(password);1241    await flushView();1242    await flushView();12431244    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1245    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12461247    findButton(container, "Import 1").click();1248    await flushView();12491250    expect(sendMessageMock).toHaveBeenCalledWith({1251      type: "BATCH_ADD_ENTRIES",1252      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1253    });1254    expect(onImport).toHaveBeenCalled();1255  });1256});