contracts.test.tsx

46.6 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");508    const syncNowButton = container.querySelector<HTMLButtonElement>(509      'button[aria-label="Sync this vault now"]',510    )!;511    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));512    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;513    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;514    expect(515      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,516    ).toBeTruthy();517    expect(518      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,519    ).toBeTruthy();520521    Array.from(container.querySelectorAll("button"))522      .find((b) => (b.textContent ?? "").trim() === "remove")!523      .click();524    for (let i = 0; i < 10; i++) await Promise.resolve();525    const confirm = Array.from(526      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),527    ).find((b) => (b.textContent ?? "").trim() === "remove");528    confirm!.click();529    for (let i = 0; i < 30; i++) await Promise.resolve();530531    expect(sendMessageMock).toHaveBeenCalledWith({532      type: "UNPAIR_DEVICE",533      payload: { deviceId: "phone-1" },534    });535    // The list must be refreshed and the device must be gone. The regression536    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but537    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.538    expect(container.textContent).not.toContain("June's Phone");539    expect(container.textContent).not.toContain("PAIRED DEVICES");540  });541542  it("Settings keeps connection and recovery details collapsed until requested", async () => {543    sendMessageMock.mockImplementation(async (msg: any) => {544      if (msg?.type === "LIST_PAIRED_DEVICES") {545        return { ok: true, data: { devices: [] } };546      }547      return { ok: true, data: null };548    });549550    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);551    for (let i = 0; i < 30; i++) await Promise.resolve();552    Array.from(container.querySelectorAll("button"))553      .find((button) => /sync/i.test(button.textContent ?? ""))554      ?.click();555    for (let i = 0; i < 20; i++) await Promise.resolve();556557    expect(container.textContent).toContain("SYNC STATUS");558    expect(container.textContent).toContain("ADD A DEVICE");559    expect(container.textContent).not.toContain("Public relay");560    expect(container.textContent).not.toContain("Rotate sync identity");561562    const connection = Array.from(container.querySelectorAll("button"))563      .find((button) => button.textContent?.includes("CONNECTION"));564    connection?.click();565    await Promise.resolve();566    expect(container.textContent).toContain("Public relay");567568    const recovery = Array.from(container.querySelectorAll("button"))569      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));570    recovery?.click();571    await Promise.resolve();572    expect(container.textContent).toContain("Rotate sync identity");573  });574575  it("Settings requires current, new, and confirm passwords before enabling save", async () => {576    sendMessageMock.mockResolvedValue({ ok: true, data: null });577    const container = mount(578      <SettingsView579        entriesCount={1}580        breachMonitoring={true}581        lockOnClose={true}582        autoLockMinutes={5}583        trashRetentionDays={30}584        theme="dark"585        accentColor="peach"586        textSize="medium"587        reduceMotion={false}588        onBreachMonitoringChange={vi.fn()}589        onLockOnCloseChange={vi.fn()}590        onAutoLockMinutesChange={vi.fn()}591        onTrashRetentionChange={vi.fn()}592        onThemeChange={vi.fn()}593        onAccentChange={vi.fn()}594        onTextSizeChange={vi.fn()}595        onReduceMotionChange={vi.fn()}596        shortcuts={DEFAULT_BINDINGS}597        onShortcutsChange={vi.fn()}598        onBack={vi.fn()}599        onImport={vi.fn()}600        onExport={vi.fn()}601        onBreachCheckDone={vi.fn()}602      />603    );604    await flushView();605606    expect(container.querySelectorAll(".stk-card").length).toBe(7);607    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]608    // "open settings to card I (Peach Pro) instead of card II (Protection)".609    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");610611    const changeButton = Array.from(container.querySelectorAll("button")).find(612      (candidate) => candidate.textContent?.trim() === "change"613    ) as HTMLButtonElement;614    changeButton.click();615    await flushView();616617    const saveButton = Array.from(container.querySelectorAll("button")).find(618      (candidate) => candidate.textContent?.trim() === "save new password"619    ) as HTMLButtonElement;620    expect(saveButton.disabled).toBe(true);621622    const inputs = container.querySelectorAll('input[type="password"]');623    inputText(inputs[0] as HTMLInputElement, "current-pass");624    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");625    await flushView();626    expect(saveButton.disabled).toBe(true);627628    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");629    await flushView();630    expect(saveButton.disabled).toBe(false);631  });632633  it("Settings feedback highlights the selected category and hides the form after submit", async () => {634    sendMessageMock.mockResolvedValue({ ok: true, data: null });635    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({636      ok: true,637      json: async () => ({ success: true }),638    }));639    vi.stubGlobal("fetch", fetchMock);640641    const container = mount(642      <SettingsView643        entriesCount={1}644        breachMonitoring={true}645        lockOnClose={true}646        autoLockMinutes={5}647        trashRetentionDays={30}648        theme="dark"649        accentColor="peach"650        textSize="medium"651        reduceMotion={false}652        initialSection="feedback"653        onBreachMonitoringChange={vi.fn()}654        onLockOnCloseChange={vi.fn()}655        onAutoLockMinutesChange={vi.fn()}656        onTrashRetentionChange={vi.fn()}657        onThemeChange={vi.fn()}658        onAccentChange={vi.fn()}659        onTextSizeChange={vi.fn()}660        onReduceMotionChange={vi.fn()}661        shortcuts={DEFAULT_BINDINGS}662        onShortcutsChange={vi.fn()}663        onBack={vi.fn()}664        onImport={vi.fn()}665        onExport={vi.fn()}666        onBreachCheckDone={vi.fn()}667      />668    );669    await flushView();670671    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;672    expect(feedbackCard.classList.contains("is-active")).toBe(true);673674    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];675    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;676    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;677    expect(bugButton.getAttribute("aria-pressed")).toBe("true");678    expect(featureButton.getAttribute("aria-pressed")).toBe("false");679680    featureButton.click();681    await flushView();682683    expect(bugButton.getAttribute("aria-pressed")).toBe("false");684    expect(featureButton.getAttribute("aria-pressed")).toBe("true");685    expect(featureButton.classList.contains("is-active")).toBe(true);686687    inputText(688      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,689      '<script>alert("xss")</script> Please make this easier to see & safer.'690    );691    const sendButton = Array.from(container.querySelectorAll("button")).find(692      (candidate) => candidate.textContent?.trim() === "Send"693    ) as HTMLButtonElement;694    sendButton.click();695696    await vi.waitFor(() => {697      expect(container.textContent).toContain("Thank you");698    });699700    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));701    expect(requestBody.subject).toContain("Feature request");702    expect(requestBody.message).toBe(703      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."704    );705    expect(requestBody.message).not.toContain("<script>");706    expect(container.textContent).toContain("Someone will process it soon");707    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();708    expect(container.querySelector(".peach-feedback-submit")).toBeNull();709  });710711  it("Settings keeps license activation on the full-screen celebration path", async () => {712    const onLicenseChange = vi.fn(async () => undefined);713    sendMessageMock.mockImplementation(async (msg: unknown) => {714      const type = (msg as { type?: string }).type;715      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };716      if (type === "ACTIVATE_LICENSE") {717        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };718      }719      if (type === "GET_ENTRIES") return { ok: true, data: [] };720      return { ok: true, data: null };721    });722723    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);724    await flushView();725726    inputText(727      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,728      "PEACH-TEST-KEY",729    );730    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;731    apply.click();732733    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));734    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);735    expect(container.textContent).not.toContain("thank you ✦");736  });737738  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {739    sendMessageMock.mockImplementation(async (msg: unknown) => {740      const type = (msg as { type?: string }).type;741      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };742      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };743      if (type === "GET_ENTRIES") return { ok: true, data: [] };744      return { ok: true, data: null };745    });746747    const onLicenseChangeFromStar = vi.fn();748    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);749    await flushView();750    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();751    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);752    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();753754    const onLicenseChangeFromPeach = vi.fn();755    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);756    await flushView();757    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();758    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);759    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();760  });761762  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {763    sendMessageMock.mockResolvedValue({ ok: true, data: null });764    const fetchMock = vi.fn();765    const requestMock = vi.fn(async () => false);766    vi.stubGlobal("fetch", fetchMock);767    vi.stubGlobal("browser", {768      permissions: {769        getAll: vi.fn(async () => ({ data_collection: [] })),770        request: requestMock,771      },772    });773774    const container = mount(775      <SettingsView776        entriesCount={1}777        breachMonitoring={true}778        lockOnClose={true}779        autoLockMinutes={5}780        trashRetentionDays={30}781        theme="dark"782        accentColor="peach"783        textSize="medium"784        reduceMotion={false}785        initialSection="feedback"786        onBreachMonitoringChange={vi.fn()}787        onLockOnCloseChange={vi.fn()}788        onAutoLockMinutesChange={vi.fn()}789        onTrashRetentionChange={vi.fn()}790        onThemeChange={vi.fn()}791        onAccentChange={vi.fn()}792        onTextSizeChange={vi.fn()}793        onReduceMotionChange={vi.fn()}794        shortcuts={DEFAULT_BINDINGS}795        onShortcutsChange={vi.fn()}796        onBack={vi.fn()}797        onImport={vi.fn()}798        onExport={vi.fn()}799        onBreachCheckDone={vi.fn()}800      />801    );802    await flushView();803804    inputText(805      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,806      "Please improve the feedback flow."807    );808    const sendButton = Array.from(container.querySelectorAll("button")).find(809      (candidate) => candidate.textContent?.trim() === "Send"810    ) as HTMLButtonElement;811    sendButton.click();812813    await vi.waitFor(() => {814      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });815    });816    expect(fetchMock).not.toHaveBeenCalled();817    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();818  });819820  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {821    sendMessageMock.mockImplementation(async (msg: unknown) => {822      const m = msg as { type: string };823      if (m.type === "GET_RECOVERY_WORDS") {824        return {825          ok: true,826          data: [827            "abandon", "ability", "able", "about", "above", "absent",828            "absorb", "abstract", "absurd", "abuse", "access", "accident",829          ],830        };831      }832      return { ok: true, data: null };833    });834    const onAutoLockMinutesChange = vi.fn();835    const onThemeChange = vi.fn();836    const container = mount(837      <SettingsView838        entriesCount={1}839        breachMonitoring={true}840        lockOnClose={true}841        autoLockMinutes={5}842        trashRetentionDays={30}843        theme="dark"844        accentColor="peach"845        textSize="medium"846        reduceMotion={false}847        onBreachMonitoringChange={vi.fn()}848        onLockOnCloseChange={vi.fn()}849        onAutoLockMinutesChange={onAutoLockMinutesChange}850        onTrashRetentionChange={vi.fn()}851        onThemeChange={onThemeChange}852        onAccentChange={vi.fn()}853        onTextSizeChange={vi.fn()}854        onReduceMotionChange={vi.fn()}855        shortcuts={DEFAULT_BINDINGS}856        onShortcutsChange={vi.fn()}857        onBack={vi.fn()}858        onImport={vi.fn()}859        onExport={vi.fn()}860        onBreachCheckDone={vi.fn()}861      />862    );863    await flushView();864    sendMessageMock.mockClear();865866    // ── Auto-lock + theme changes (before password change) ─────────────867    const selects = container.querySelectorAll("select");868    changeSelect(selects[0] as HTMLSelectElement, "15");869    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);870871    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;872    appearanceCard.click();873    await flushView();874    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");875    const themeButton = Array.from(container.querySelectorAll("button")).find(876      (candidate) => candidate.textContent?.trim() === "Switch to light"877    ) as HTMLButtonElement;878    themeButton.click();879    expect(onThemeChange).toHaveBeenCalledWith("light");880881    // ── Password change → recovery words display ────────────────────────882    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;883    protectionCard.click();884    await flushView();885886    const changeButton = Array.from(container.querySelectorAll("button")).find(887      (candidate) => candidate.textContent?.trim() === "change"888    ) as HTMLButtonElement;889    changeButton.click();890    await flushView();891892    const inputs = container.querySelectorAll('input[type="password"]');893    inputText(inputs[0] as HTMLInputElement, "current-pass");894    inputText(inputs[1] as HTMLInputElement, "new-password-123");895    inputText(inputs[2] as HTMLInputElement, "new-password-123");896    await flushView();897898    const saveButton = Array.from(container.querySelectorAll("button")).find(899      (candidate) => candidate.textContent?.trim() === "save new password"900    ) as HTMLButtonElement;901    saveButton.click();902    await flushView();903904    expect(sendMessageMock).toHaveBeenCalledWith({905      type: "CHANGE_PASSWORD",906      payload: {907        currentPassword: "current-pass",908        newPassword: "new-password-123",909      },910    });911912    // After a successful password change the recovery word display replaces settings.913    expect(container.textContent).toContain("save your recovery words");914    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });915  });916917  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {918    sendMessageMock.mockResolvedValue({ ok: true });919    const container = mount(920      <EntryView921        entry={sampleEntry()}922        onDelete={vi.fn()}923        onFieldSaved={vi.fn(async () => undefined)}924      />925    );926927    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;928    passwordButton.click();929    await flushView();930931    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {932      type: "COPY",933      payload: { text: "secret123", label: "password", alreadyWritten: false },934    });935    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {936      type: "FIRE_CANARY_PING",937      payload: {938        webhookUrl: "https://example.com/hook",939        entryTitle: "GitHub",940        trigger: "password_copied",941      },942    });943944    sendMessageMock.mockClear();945    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;946    usernameButton.click();947    await flushView();948949    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {950      type: "COPY",951      payload: { text: "june", label: "username", alreadyWritten: false },952    });953    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {954      type: "FIRE_CANARY_PING",955      payload: {956        webhookUrl: "https://example.com/hook",957        entryTitle: "GitHub",958        trigger: "username_copied",959      },960    });961  });962963  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {964    sendMessageMock.mockResolvedValue({ ok: true });965    const onFieldSaved = vi.fn(async () => undefined);966    const container = mount(967      <EntryView968        entry={sampleEntry()}969        onDelete={vi.fn()}970        onFieldSaved={onFieldSaved}971      />972    );973974    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;975    passwordValue.click();976    await flushView();977978    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;979    inputText(passwordInput, "");980    await flushView();981982    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;983    saveButton.click();984    await flushView();985986    expect(sendMessageMock).not.toHaveBeenCalledWith(987      expect.objectContaining({ type: "UPDATE_ENTRY" })988    );989    expect(onFieldSaved).not.toHaveBeenCalled();990991    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;992    totpMain.click();993994    await vi.waitFor(() => {995      expect(sendMessageMock).toHaveBeenLastCalledWith({996        type: "COPY",997        payload: { text: "123456", label: "totp", alreadyWritten: false },998      });999    }, { timeout: 500 });1000  });10011002  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1003    sendMessageMock.mockResolvedValue({ ok: true });1004    const container = mount(1005      <EntryView1006        entry={sampleEntry()}1007        onDelete={vi.fn()}1008        onFieldSaved={vi.fn(async () => undefined)}1009      />1010    );10111012    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1013    usernameValue.click();1014    await flushView();10151016    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1017    usernameInput.blur();1018    await flushView();10191020    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1021    expect(sendMessageMock).not.toHaveBeenCalledWith(1022      expect.objectContaining({ type: "UPDATE_ENTRY" })1023    );1024  });10251026  it("Entry lets TOTP secrets be edited inline", async () => {1027    sendMessageMock.mockResolvedValue({ ok: true });1028    const onFieldSaved = vi.fn(async () => undefined);1029    const container = mount(1030      <EntryView1031        entry={sampleEntry()}1032        onDelete={vi.fn()}1033        onFieldSaved={onFieldSaved}1034      />1035    );10361037    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1038    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1039    await flushView();10401041    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1042    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1043    await flushView();10441045    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1046    saveButton.click();1047    await flushView();10481049    expect(sendMessageMock).toHaveBeenCalledWith({1050      type: "UPDATE_ENTRY",1051      payload: {1052        id: "entry-1",1053        patch: { totp: "NB2W45DFOIZA====" },1054      },1055    });1056    expect(onFieldSaved).toHaveBeenCalled();1057  });10581059  it("Vault seeds and focuses search when a search seed is provided", async () => {1060    const container = mount(1061      <VaultView1062        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1063        sortOrder="url-first"1064        searchSeed="g"1065        searchSeedNonce={1}1066        onSelectEntry={vi.fn()}1067        onSortOrderChange={vi.fn()}1068        onAdd={vi.fn()}1069        onClose={vi.fn()}1070      />1071    );10721073    await flushView();10741075    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1076    expect(search.value).toBe("g");1077    expect(document.activeElement).toBe(search);1078  });10791080  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1081    parseImportSourceMock.mockResolvedValue(1082      sampleImportResult({1083        entries: [1084          {1085            type: "login",1086            title: "Imported site",1087            username: "imported-user",1088            password: "imported-pass",1089            url: "https://imported.test",1090          },1091        ],1092      })1093    );1094    sendMessageMock.mockImplementation(async (msg: unknown) => {1095      const type = (msg as { type?: string }).type;1096      if (type === "GET_ENTRIES") {1097        return {1098          ok: true,1099          data: [1100            sampleEntry({1101              id: "existing-1",1102              title: "Imported site",1103              username: "imported-user",1104              url: "https://imported.test",1105            }),1106          ],1107        };1108      }1109      if (type === "BATCH_ADD_ENTRIES") {1110        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1111      }1112      return { ok: true };1113    });1114    const onImport = vi.fn(async () => undefined);1115    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1116    await flushView();1117    sendMessageMock.mockClear();11181119    await selectImportFile(container);1120    findButton(container, "Import 1").click();1121    await flushView();11221123    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1124    expect(sendMessageMock).toHaveBeenCalledWith({1125      type: "BATCH_ADD_ENTRIES",1126      payload: {1127        entries: [1128          expect.objectContaining({1129            title: "Imported site",1130            username: "imported-user",1131            password: "imported-pass",1132            url: "https://imported.test",1133          }),1134        ],1135      },1136    });1137    expect(onImport).toHaveBeenCalled();1138    expect(container.textContent).toContain("1 entry appears to be duplicates");1139  });11401141  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1142    parseImportSourceMock.mockResolvedValue(sampleImportResult());1143    sendMessageMock.mockImplementation(async (msg: unknown) => {1144      const type = (msg as { type?: string }).type;1145      if (type === "GET_ENTRIES") {1146        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1147      }1148      if (type === "CLEAR_VAULT_ENTRIES") {1149        return { ok: true };1150      }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(<ImportView onBack={vi.fn()} onImport={onImport} />);1158    await flushView();1159    sendMessageMock.mockClear();11601161    await selectImportFile(container);1162    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1163    await flushView();11641165    findButton(container, "Replace vault with 1").click();1166    await flushView();11671168    expect(sendMessageMock).not.toHaveBeenCalledWith(1169      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1170    );1171    expect(container.textContent).toContain("Replace forever? This cannot be undone.");11721173    findButton(container, "Yes, replace forever").click();1174    await flushView();11751176    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1177    expect(sendMessageMock).toHaveBeenCalledWith({1178      type: "BATCH_ADD_ENTRIES",1179      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1180    });1181    expect(onImport).toHaveBeenCalled();1182  });11831184  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1185    parseImportSourceMock.mockResolvedValue(sampleImportResult());1186    sendMessageMock.mockImplementation(async (msg: unknown) => {1187      const type = (msg as { type?: string }).type;1188      if (type === "CREATE_VAULT") return { ok: true };1189      if (type === "BATCH_ADD_ENTRIES") {1190        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1191      }1192      return { ok: true };1193    });1194    const onImport = vi.fn(async () => undefined);1195    const container = mount(1196      <ImportView1197        mode="onboarding"1198        replacesExistingVault={true}1199        onBack={vi.fn()}1200        onImport={onImport}1201      />1202    );12031204    await selectImportFile(container);12051206    const createButton = findButton(container, "Create vault + import 1");1207    expect(createButton.disabled).toBe(true);1208    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1209    expect(container.textContent).toContain("Use at least 8 characters");12101211    const masterInput = container.querySelector(1212      'input[placeholder="new master password"]'1213    ) as HTMLInputElement;1214    const confirmInput = container.querySelector(1215      'input[placeholder="confirm master password"]'1216    ) as HTMLInputElement;1217    const revealButton = container.querySelector(1218      'button[aria-label="Show passwords"]'1219    ) as HTMLButtonElement;1220    expect(revealButton).toBeTruthy();1221    revealButton.click();1222    await flushView();1223    expect(masterInput.type).toBe("text");1224    expect(confirmInput.type).toBe("text");12251226    inputText(masterInput, "fresh-master-password");1227    inputText(confirmInput, "fresh-master-password");1228    await flushView();12291230    expect(createButton.disabled).toBe(false);1231    createButton.click();1232    await flushView();12331234    expect(sendMessageMock).toHaveBeenCalledWith({1235      type: "CREATE_VAULT",1236      payload: { password: "fresh-master-password" },1237    });1238    expect(sendMessageMock).toHaveBeenCalledWith({1239      type: "BATCH_ADD_ENTRIES",1240      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1241    });1242    expect(onImport).toHaveBeenCalled();1243  });12441245  it("Import encrypted backups reparse with the backup password before import", async () => {1246    parseImportSourceMock1247      .mockResolvedValueOnce(1248        sampleImportResult({1249          format: "peach-json",1250          label: "Peach encrypted backup",1251          entries: [],1252          totalItems: 0,1253          requiresPassword: true,1254        })1255      )1256      .mockResolvedValueOnce(1257        sampleImportResult({1258          format: "peach-json",1259          label: "Peach encrypted backup",1260        })1261      );1262    sendMessageMock.mockImplementation(async (msg: unknown) => {1263      const type = (msg as { type?: string }).type;1264      if (type === "GET_ENTRIES") return { ok: true, data: [] };1265      if (type === "BATCH_ADD_ENTRIES") {1266        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1267      }1268      return { ok: true };1269    });1270    const onImport = vi.fn(async () => undefined);1271    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1272    await flushView();1273    sendMessageMock.mockClear();12741275    await selectImportFile(container, '{"type":"peach-export"}');1276    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1277    inputText(password, "backup-passphrase");1278    pressEnter(password);1279    await flushView();1280    await flushView();12811282    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1283    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12841285    findButton(container, "Import 1").click();1286    await flushView();12871288    expect(sendMessageMock).toHaveBeenCalledWith({1289      type: "BATCH_ADD_ENTRIES",1290      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1291    });1292    expect(onImport).toHaveBeenCalled();1293  });1294});