contracts.test.tsx

45.4 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("../components/RuneBackground", () => ({53  RuneBackground: () => null,54}));5556vi.mock("@zxcvbn-ts/core", () => ({57  zxcvbn: (value: string) => zxcvbnMock(value),58  zxcvbnOptions: { setOptions: vi.fn() },59}));60vi.mock("@zxcvbn-ts/language-common", () => ({61  adjacencyGraphs: {},62  dictionary: {},63}));64vi.mock("@zxcvbn-ts/language-en", () => ({65  translations: {},66  dictionary: {},67}));6869describe("popup contract tests", () => {70  const disposers: Array<() => void> = [];7172  beforeEach(() => {73    document.body.innerHTML = "";74    sendMessageMock.mockReset();75    triggerRingMock.mockReset();76    triggerPipPulseMock.mockReset();77    parseImportSourceMock.mockReset();78    zxcvbnMock.mockReset();79    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));80    parseImportSourceMock.mockResolvedValue({81      format: "unknown",82      label: "Unknown format",83      entries: [],84      totalItems: 0,85      skippedItems: 0,86      requiresPassword: false,87    });8889    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {90      return window.setTimeout(() => cb(0), 0) as unknown as number;91    });92    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));93    vi.stubGlobal("URL", {94      createObjectURL: vi.fn(() => "blob:peach-test"),95      revokeObjectURL: vi.fn(),96    });97    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9899    (globalThis as { chrome?: typeof chrome }).chrome = {100      runtime: {101        getManifest: () => ({ version: "1.4.47" }),102        onMessage: { addListener: vi.fn() },103      },104    } as unknown as typeof chrome;105  });106107  afterEach(() => {108    while (disposers.length > 0) {109      disposers.pop()?.();110    }111    vi.restoreAllMocks();112    vi.unstubAllGlobals();113  });114115  function mount(view: JSX.Element | (() => JSX.Element)) {116    const mounted = renderView(view);117    disposers.push(mounted.dispose);118    return mounted.container;119  }120121  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {122    return {123      id: "entry-1",124      type: "login",125      title: "GitHub",126      username: "june",127      password: "secret123",128      url: "https://github.com",129      totp: "JBSWY3DPEHPK3PXP",130      canary: {131        webhookUrl: "https://example.com/hook",132      },133      createdAt: 1,134      modifiedAt: 1,135      ...overrides,136    };137  }138139  function sampleImportResult(140    overrides: Partial<ImportParseResult> = {}141  ): ImportParseResult {142    return {143      format: "csv",144      label: "CSV import",145      entries: [146        {147          type: "login",148          title: "Imported site",149          username: "imported-user",150          password: "imported-pass",151          url: "https://imported.test",152        },153      ],154      totalItems: 1,155      skippedItems: 0,156      requiresPassword: false,157      ...overrides,158    };159  }160161  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {162    return {163      entriesCount: 1,164      breachMonitoring: true,165      lockOnClose: true,166      autoLockMinutes: 5,167      trashRetentionDays: 30,168      theme: "dark" as const,169      accentColor: "peach" as const,170      textSize: "medium" as const,171      reduceMotion: false,172      onBreachMonitoringChange: vi.fn(),173      onLockOnCloseChange: vi.fn(),174      onAutoLockMinutesChange: vi.fn(),175      onTrashRetentionChange: vi.fn(),176      onThemeChange: vi.fn(),177      onAccentChange: vi.fn(),178      onTextSizeChange: vi.fn(),179      onReduceMotionChange: vi.fn(),180      shortcuts: DEFAULT_BINDINGS,181      onShortcutsChange: vi.fn(),182      onBack: vi.fn(),183      onImport: vi.fn(),184      onExport: vi.fn(),185      onBreachCheckDone: vi.fn(),186      ...overrides,187    };188  }189190  async function selectImportFile(191    container: HTMLElement,192    contents = '{"export":"test"}'193  ) {194    // The Import view now opens onto a menu — click into the file subview195    // before locating the file input. Idempotent: a second click won't re-fire196    // because the menu unmounts once the subview renders.197    const fileCard = findButton(container, "Choose file →");198    if (fileCard) {199      fileCard.click();200      await flushView();201    }202    const input = container.querySelector('input[type="file"]') as HTMLInputElement;203    const file = new File([contents], "import.json", { type: "application/json" });204    Object.defineProperty(input, "files", {205      configurable: true,206      value: [file],207    });208    input.dispatchEvent(new Event("change", { bubbles: true }));209    await flushView();210    await flushView();211  }212213  function findButton(container: HTMLElement, text: string): HTMLButtonElement {214    return Array.from(container.querySelectorAll("button")).find(215      (candidate) => candidate.textContent?.trim() === text216    ) as HTMLButtonElement;217  }218219  it("Lock sends UNLOCK with the entered passphrase", async () => {220    sendMessageMock.mockResolvedValue({ ok: true });221    const onUnlock = vi.fn(async () => undefined);222    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);223224    const password = container.querySelector("#unlock-password") as HTMLInputElement;225    inputText(password, "correct horse battery staple");226    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();227    await flushView();228229    expect(sendMessageMock).toHaveBeenCalledWith({230      type: "UNLOCK",231      payload: { password: "correct horse battery staple" },232    });233    expect(onUnlock).toHaveBeenCalled();234  });235236  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {237    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);238    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;239    const password = container.querySelector("#master-password") as HTMLInputElement;240    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;241242    inputText(password, "short");243    inputText(confirm, "short");244    await flushView();245    expect(createButton.disabled).toBe(true);246247    zxcvbnMock.mockImplementation(() => ({ score: 1 }));248    inputText(password, "weakweakweak");249    inputText(confirm, "weakweakweak");250    await flushView();251    expect(createButton.disabled).toBe(true);252  });253254  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {255    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock256    sendMessageMock.mockImplementation(257      async (msg: unknown) => {258        const m = msg as { type: string; payload?: { password?: string } };259        if (m.type === "CREATE_VAULT") return { ok: true };260        if (m.type === "GET_RECOVERY_WORDS") {261          // Return 12 fake words — must pass findInvalidWords262          return {263            ok: true,264            data: [265              "abandon", "ability", "able", "about", "above", "absent",266              "absorb", "abstract", "absurd", "abuse", "access", "accident",267            ],268          };269        }270        return { ok: true };271      }272    );273    zxcvbnMock.mockImplementation(() => ({ score: 3 }));274    const onUnlock = vi.fn(async () => undefined);275    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);276277    const password = container.querySelector("#master-password") as HTMLInputElement;278    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;279    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;280281    inputText(password, "correct horse battery staple");282    inputText(confirm, "correct horse battery staple");283    await flushView();284    expect(createButton.disabled).toBe(false);285286    pressEnter(confirm);287    await flushView();288289    expect(sendMessageMock).toHaveBeenCalledWith({290      type: "CREATE_VAULT",291      payload: { password: "correct horse battery staple" },292    });293    // After CREATE_VAULT success, the recovery word display is shown.294    // onUnlock is NOT called yet — user must first confirm their recovery words.295    expect(onUnlock).not.toHaveBeenCalled();296  });297298  it("Recovery words flow submits the new master password and completes", async () => {299    sendMessageMock.mockImplementation(async (msg: unknown) => {300      const type = (msg as { type?: string }).type;301      if (type === "VALIDATE_RECOVERY_WORDS") {302        return { ok: true, data: { valid: true, invalid: [] } };303      }304      if (type === "RECOVER_WITH_WORDS") return { ok: true };305      return { ok: true };306    });307    const canvasContext = {308      clearRect: vi.fn(),309      fillText: vi.fn(),310      beginPath: vi.fn(),311      closePath: vi.fn(),312      arc: vi.fn(),313      fill: vi.fn(),314      stroke: vi.fn(),315      save: vi.fn(),316      restore: vi.fn(),317      translate: vi.fn(),318      rotate: vi.fn(),319      scale: vi.fn(),320      drawImage: vi.fn(),321      measureText: vi.fn(() => ({ width: 0 })),322      setTransform: vi.fn(),323      fillRect: vi.fn(),324      strokeRect: vi.fn(),325    } as unknown as CanvasRenderingContext2D;326    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);327328    const onRecoveryComplete = vi.fn(async () => undefined);329    const container = mount(330      <RecoveryWordEntryView331        onCancel={vi.fn()}332        onRecoveryComplete={onRecoveryComplete}333      />334    );335336    const recoveryWords = WORD_LIST.slice(0, 12);337    for (let i = 0; i < recoveryWords.length; i++) {338      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;339      inputText(input, recoveryWords[i]!);340    }341    await flushView();342343    findButton(container, "verify words").click();344    await flushView();345346    expect(container.textContent).toContain("new master password");347348    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;349    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;350    inputText(newPassword, "fresh-recovery-password");351    inputText(confirmPassword, "fresh-recovery-password");352    await flushView();353354    findButton(container, "recover vault").click();355    await flushView();356357    expect(sendMessageMock).toHaveBeenCalledWith({358      type: "RECOVER_WITH_WORDS",359      payload: {360        words: recoveryWords,361        newPassword: "fresh-recovery-password",362      },363    });364    expect(onRecoveryComplete).toHaveBeenCalled();365  });366367  it("Export menu surfaces both backup formats", async () => {368    const container = mount(<ExportView onBack={vi.fn()} />);369    await flushView();370371    expect(container.textContent).toContain("PeachScript");372    expect(container.textContent).toContain("CSV");373  });374375  it("Lock can open directly on backup restore from onboarding", async () => {376    const container = mount(() => (377      <LockView378        hasVault={false}379        ready={true}380        onUnlock={vi.fn()}381        initialRestore={true}382      />383    ));384    await flushView();385386    expect(container.textContent).toContain("PeachScript");387    expect(container.textContent).toContain("Codex");388    expect(container.textContent).toContain("CSV");389    expect(container.querySelector("#master-password")).toBeNull();390  });391392  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {393    const device = {394      deviceId: "phone-1",395      deviceName: "June's Phone",396      deviceType: "android" as const,397      longTermPublicKeyHex: "a".repeat(64),398      signingPublicKeyHex: "b".repeat(64),399      pairedAt: 1,400      trustSource: "direct" as const,401    };402    sendMessageMock.mockImplementation(async (msg: unknown) => {403      const m = msg as { type: string };404      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };405      return { ok: true, data: null };406    });407408    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);409    for (let i = 0; i < 30; i++) await Promise.resolve();410411    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>412      /sync/i.test(b.textContent ?? ""),413    );414    openSync?.click();415    for (let i = 0; i < 30; i++) await Promise.resolve();416417    const remove = Array.from(container.querySelectorAll("button")).find(418      (b) => (b.textContent ?? "").trim() === "remove",419    );420    expect(remove).toBeTruthy();421422    // Guard against a vacuous pass: the disclosure must not already be on423    // screen before the user asks to remove anything.424    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();425426    remove!.click();427    for (let i = 0; i < 10; i++) await Promise.resolve();428429    // Revocation must never be presented as securing the vault against the430    // removed device — recovery words survive it and restore everything.431    const note = container.querySelector(".peach-unpair-confirm-note");432    expect(note).toBeTruthy();433    const text = note!.textContent ?? "";434    expect(text).toContain("master password");435    expect(text).toContain("recovery words");436    expect(text).toContain("future changes");437  });438439  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {440    const device = {441      deviceId: "phone-1",442      deviceName: "June's Phone",443      deviceType: "android" as const,444      longTermPublicKeyHex: "a".repeat(64),445      signingPublicKeyHex: "b".repeat(64),446      pairedAt: 1,447      trustSource: "direct" as const,448    };449    let paired: typeof device[] = [device];450    sendMessageMock.mockImplementation(async (msg: any) => {451      if (msg?.type === "LIST_PAIRED_DEVICES") {452        return { ok: true, data: { devices: [...paired] } };453      }454      if (msg?.type === "UNPAIR_DEVICE") {455        // Mirror the real handler: UNPAIR_DEVICE drops the row from the456        // registry. The next LIST_PAIRED_DEVICES must not see it.457        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);458        return { ok: true };459      }460      return { ok: true, data: null };461    });462463    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);464    for (let i = 0; i < 30; i++) await Promise.resolve();465    Array.from(container.querySelectorAll("button"))466      .find((b) => /sync/i.test(b.textContent ?? ""))467      ?.click();468    for (let i = 0; i < 30; i++) await Promise.resolve();469470    // The list itself: extracting the relay selector must not take the hub with it.471    expect(container.textContent).toContain("PAIRED DEVICES");472    expect(container.textContent).toContain("June's Phone");473    expect(container.textContent).toContain("Android");474    const syncNowButton = container.querySelector<HTMLButtonElement>(475      'button[aria-label="Sync this vault now"]',476    )!;477    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));478    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;479    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;480    expect(481      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,482    ).toBeTruthy();483    expect(484      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,485    ).toBeTruthy();486487    Array.from(container.querySelectorAll("button"))488      .find((b) => (b.textContent ?? "").trim() === "remove")!489      .click();490    for (let i = 0; i < 10; i++) await Promise.resolve();491    const confirm = Array.from(492      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),493    ).find((b) => (b.textContent ?? "").trim() === "remove");494    confirm!.click();495    for (let i = 0; i < 30; i++) await Promise.resolve();496497    expect(sendMessageMock).toHaveBeenCalledWith({498      type: "UNPAIR_DEVICE",499      payload: { deviceId: "phone-1" },500    });501    // The list must be refreshed and the device must be gone. The regression502    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but503    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.504    expect(container.textContent).not.toContain("June's Phone");505    expect(container.textContent).not.toContain("PAIRED DEVICES");506  });507508  it("Settings keeps connection and recovery details collapsed until requested", async () => {509    sendMessageMock.mockImplementation(async (msg: any) => {510      if (msg?.type === "LIST_PAIRED_DEVICES") {511        return { ok: true, data: { devices: [] } };512      }513      return { ok: true, data: null };514    });515516    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);517    for (let i = 0; i < 30; i++) await Promise.resolve();518    Array.from(container.querySelectorAll("button"))519      .find((button) => /sync/i.test(button.textContent ?? ""))520      ?.click();521    for (let i = 0; i < 20; i++) await Promise.resolve();522523    expect(container.textContent).toContain("SYNC STATUS");524    expect(container.textContent).toContain("ADD A DEVICE");525    expect(container.textContent).not.toContain("Public relay");526    expect(container.textContent).not.toContain("Rotate sync identity");527528    const connection = Array.from(container.querySelectorAll("button"))529      .find((button) => button.textContent?.includes("CONNECTION"));530    connection?.click();531    await Promise.resolve();532    expect(container.textContent).toContain("Public relay");533534    const recovery = Array.from(container.querySelectorAll("button"))535      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));536    recovery?.click();537    await Promise.resolve();538    expect(container.textContent).toContain("Rotate sync identity");539  });540541  it("Settings requires current, new, and confirm passwords before enabling save", async () => {542    sendMessageMock.mockResolvedValue({ ok: true, data: null });543    const container = mount(544      <SettingsView545        entriesCount={1}546        breachMonitoring={true}547        lockOnClose={true}548        autoLockMinutes={5}549        trashRetentionDays={30}550        theme="dark"551        accentColor="peach"552        textSize="medium"553        reduceMotion={false}554        onBreachMonitoringChange={vi.fn()}555        onLockOnCloseChange={vi.fn()}556        onAutoLockMinutesChange={vi.fn()}557        onTrashRetentionChange={vi.fn()}558        onThemeChange={vi.fn()}559        onAccentChange={vi.fn()}560        onTextSizeChange={vi.fn()}561        onReduceMotionChange={vi.fn()}562        shortcuts={DEFAULT_BINDINGS}563        onShortcutsChange={vi.fn()}564        onBack={vi.fn()}565        onImport={vi.fn()}566        onExport={vi.fn()}567        onBreachCheckDone={vi.fn()}568      />569    );570    await flushView();571572    expect(container.querySelectorAll(".stk-card").length).toBe(7);573    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]574    // "open settings to card I (Peach Pro) instead of card II (Protection)".575    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");576577    const changeButton = Array.from(container.querySelectorAll("button")).find(578      (candidate) => candidate.textContent?.trim() === "change"579    ) as HTMLButtonElement;580    changeButton.click();581    await flushView();582583    const saveButton = Array.from(container.querySelectorAll("button")).find(584      (candidate) => candidate.textContent?.trim() === "save new password"585    ) as HTMLButtonElement;586    expect(saveButton.disabled).toBe(true);587588    const inputs = container.querySelectorAll('input[type="password"]');589    inputText(inputs[0] as HTMLInputElement, "current-pass");590    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");591    await flushView();592    expect(saveButton.disabled).toBe(true);593594    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");595    await flushView();596    expect(saveButton.disabled).toBe(false);597  });598599  it("Settings feedback highlights the selected category and hides the form after submit", async () => {600    sendMessageMock.mockResolvedValue({ ok: true, data: null });601    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({602      ok: true,603      json: async () => ({ success: true }),604    }));605    vi.stubGlobal("fetch", fetchMock);606607    const container = mount(608      <SettingsView609        entriesCount={1}610        breachMonitoring={true}611        lockOnClose={true}612        autoLockMinutes={5}613        trashRetentionDays={30}614        theme="dark"615        accentColor="peach"616        textSize="medium"617        reduceMotion={false}618        initialSection="feedback"619        onBreachMonitoringChange={vi.fn()}620        onLockOnCloseChange={vi.fn()}621        onAutoLockMinutesChange={vi.fn()}622        onTrashRetentionChange={vi.fn()}623        onThemeChange={vi.fn()}624        onAccentChange={vi.fn()}625        onTextSizeChange={vi.fn()}626        onReduceMotionChange={vi.fn()}627        shortcuts={DEFAULT_BINDINGS}628        onShortcutsChange={vi.fn()}629        onBack={vi.fn()}630        onImport={vi.fn()}631        onExport={vi.fn()}632        onBreachCheckDone={vi.fn()}633      />634    );635    await flushView();636637    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;638    expect(feedbackCard.classList.contains("is-active")).toBe(true);639640    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];641    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;642    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;643    expect(bugButton.getAttribute("aria-pressed")).toBe("true");644    expect(featureButton.getAttribute("aria-pressed")).toBe("false");645646    featureButton.click();647    await flushView();648649    expect(bugButton.getAttribute("aria-pressed")).toBe("false");650    expect(featureButton.getAttribute("aria-pressed")).toBe("true");651    expect(featureButton.classList.contains("is-active")).toBe(true);652653    inputText(654      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,655      '<script>alert("xss")</script> Please make this easier to see & safer.'656    );657    const sendButton = Array.from(container.querySelectorAll("button")).find(658      (candidate) => candidate.textContent?.trim() === "Send"659    ) as HTMLButtonElement;660    sendButton.click();661662    await vi.waitFor(() => {663      expect(container.textContent).toContain("Thank you");664    });665666    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));667    expect(requestBody.subject).toContain("Feature request");668    expect(requestBody.message).toBe(669      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."670    );671    expect(requestBody.message).not.toContain("<script>");672    expect(container.textContent).toContain("Someone will process it soon");673    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();674    expect(container.querySelector(".peach-feedback-submit")).toBeNull();675  });676677  it("Settings keeps license activation on the full-screen celebration path", async () => {678    const onLicenseChange = vi.fn(async () => undefined);679    sendMessageMock.mockImplementation(async (msg: unknown) => {680      const type = (msg as { type?: string }).type;681      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };682      if (type === "ACTIVATE_LICENSE") {683        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };684      }685      if (type === "GET_ENTRIES") return { ok: true, data: [] };686      return { ok: true, data: null };687    });688689    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);690    await flushView();691692    inputText(693      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,694      "PEACH-TEST-KEY",695    );696    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;697    apply.click();698699    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));700    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);701    expect(container.textContent).not.toContain("thank you ✦");702  });703704  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {705    sendMessageMock.mockImplementation(async (msg: unknown) => {706      const type = (msg as { type?: string }).type;707      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };708      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };709      if (type === "GET_ENTRIES") return { ok: true, data: [] };710      return { ok: true, data: null };711    });712713    const onLicenseChangeFromStar = vi.fn();714    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);715    await flushView();716    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();717    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);718    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();719720    const onLicenseChangeFromPeach = vi.fn();721    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);722    await flushView();723    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();724    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);725    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();726  });727728  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {729    sendMessageMock.mockResolvedValue({ ok: true, data: null });730    const fetchMock = vi.fn();731    const requestMock = vi.fn(async () => false);732    vi.stubGlobal("fetch", fetchMock);733    vi.stubGlobal("browser", {734      permissions: {735        getAll: vi.fn(async () => ({ data_collection: [] })),736        request: requestMock,737      },738    });739740    const container = mount(741      <SettingsView742        entriesCount={1}743        breachMonitoring={true}744        lockOnClose={true}745        autoLockMinutes={5}746        trashRetentionDays={30}747        theme="dark"748        accentColor="peach"749        textSize="medium"750        reduceMotion={false}751        initialSection="feedback"752        onBreachMonitoringChange={vi.fn()}753        onLockOnCloseChange={vi.fn()}754        onAutoLockMinutesChange={vi.fn()}755        onTrashRetentionChange={vi.fn()}756        onThemeChange={vi.fn()}757        onAccentChange={vi.fn()}758        onTextSizeChange={vi.fn()}759        onReduceMotionChange={vi.fn()}760        shortcuts={DEFAULT_BINDINGS}761        onShortcutsChange={vi.fn()}762        onBack={vi.fn()}763        onImport={vi.fn()}764        onExport={vi.fn()}765        onBreachCheckDone={vi.fn()}766      />767    );768    await flushView();769770    inputText(771      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,772      "Please improve the feedback flow."773    );774    const sendButton = Array.from(container.querySelectorAll("button")).find(775      (candidate) => candidate.textContent?.trim() === "Send"776    ) as HTMLButtonElement;777    sendButton.click();778779    await vi.waitFor(() => {780      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });781    });782    expect(fetchMock).not.toHaveBeenCalled();783    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();784  });785786  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {787    sendMessageMock.mockImplementation(async (msg: unknown) => {788      const m = msg as { type: string };789      if (m.type === "GET_RECOVERY_WORDS") {790        return {791          ok: true,792          data: [793            "abandon", "ability", "able", "about", "above", "absent",794            "absorb", "abstract", "absurd", "abuse", "access", "accident",795          ],796        };797      }798      return { ok: true, data: null };799    });800    const onAutoLockMinutesChange = vi.fn();801    const onThemeChange = vi.fn();802    const container = mount(803      <SettingsView804        entriesCount={1}805        breachMonitoring={true}806        lockOnClose={true}807        autoLockMinutes={5}808        trashRetentionDays={30}809        theme="dark"810        accentColor="peach"811        textSize="medium"812        reduceMotion={false}813        onBreachMonitoringChange={vi.fn()}814        onLockOnCloseChange={vi.fn()}815        onAutoLockMinutesChange={onAutoLockMinutesChange}816        onTrashRetentionChange={vi.fn()}817        onThemeChange={onThemeChange}818        onAccentChange={vi.fn()}819        onTextSizeChange={vi.fn()}820        onReduceMotionChange={vi.fn()}821        shortcuts={DEFAULT_BINDINGS}822        onShortcutsChange={vi.fn()}823        onBack={vi.fn()}824        onImport={vi.fn()}825        onExport={vi.fn()}826        onBreachCheckDone={vi.fn()}827      />828    );829    await flushView();830    sendMessageMock.mockClear();831832    // ── Auto-lock + theme changes (before password change) ─────────────833    const selects = container.querySelectorAll("select");834    changeSelect(selects[0] as HTMLSelectElement, "15");835    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);836837    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;838    appearanceCard.click();839    await flushView();840    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");841    const themeButton = Array.from(container.querySelectorAll("button")).find(842      (candidate) => candidate.textContent?.trim() === "Switch to light"843    ) as HTMLButtonElement;844    themeButton.click();845    expect(onThemeChange).toHaveBeenCalledWith("light");846847    // ── Password change → recovery words display ────────────────────────848    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;849    protectionCard.click();850    await flushView();851852    const changeButton = Array.from(container.querySelectorAll("button")).find(853      (candidate) => candidate.textContent?.trim() === "change"854    ) as HTMLButtonElement;855    changeButton.click();856    await flushView();857858    const inputs = container.querySelectorAll('input[type="password"]');859    inputText(inputs[0] as HTMLInputElement, "current-pass");860    inputText(inputs[1] as HTMLInputElement, "new-password-123");861    inputText(inputs[2] as HTMLInputElement, "new-password-123");862    await flushView();863864    const saveButton = Array.from(container.querySelectorAll("button")).find(865      (candidate) => candidate.textContent?.trim() === "save new password"866    ) as HTMLButtonElement;867    saveButton.click();868    await flushView();869870    expect(sendMessageMock).toHaveBeenCalledWith({871      type: "CHANGE_PASSWORD",872      payload: {873        currentPassword: "current-pass",874        newPassword: "new-password-123",875      },876    });877878    // After a successful password change the recovery word display replaces settings.879    expect(container.textContent).toContain("save your recovery words");880    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });881  });882883  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {884    sendMessageMock.mockResolvedValue({ ok: true });885    const container = mount(886      <EntryView887        entry={sampleEntry()}888        onDelete={vi.fn()}889        onFieldSaved={vi.fn(async () => undefined)}890      />891    );892893    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;894    passwordButton.click();895    await flushView();896897    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {898      type: "COPY",899      payload: { text: "secret123", label: "password", alreadyWritten: false },900    });901    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {902      type: "FIRE_CANARY_PING",903      payload: {904        webhookUrl: "https://example.com/hook",905        entryTitle: "GitHub",906        trigger: "password_copied",907      },908    });909910    sendMessageMock.mockClear();911    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;912    usernameButton.click();913    await flushView();914915    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {916      type: "COPY",917      payload: { text: "june", label: "username", alreadyWritten: false },918    });919    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {920      type: "FIRE_CANARY_PING",921      payload: {922        webhookUrl: "https://example.com/hook",923        entryTitle: "GitHub",924        trigger: "username_copied",925      },926    });927  });928929  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {930    sendMessageMock.mockResolvedValue({ ok: true });931    const onFieldSaved = vi.fn(async () => undefined);932    const container = mount(933      <EntryView934        entry={sampleEntry()}935        onDelete={vi.fn()}936        onFieldSaved={onFieldSaved}937      />938    );939940    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;941    passwordValue.click();942    await flushView();943944    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;945    inputText(passwordInput, "");946    await flushView();947948    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;949    saveButton.click();950    await flushView();951952    expect(sendMessageMock).not.toHaveBeenCalledWith(953      expect.objectContaining({ type: "UPDATE_ENTRY" })954    );955    expect(onFieldSaved).not.toHaveBeenCalled();956957    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;958    totpMain.click();959960    await vi.waitFor(() => {961      expect(sendMessageMock).toHaveBeenLastCalledWith({962        type: "COPY",963        payload: { text: "123456", label: "totp", alreadyWritten: false },964      });965    }, { timeout: 500 });966  });967968  it("Entry closes an unchanged inline editor when focus leaves it", async () => {969    sendMessageMock.mockResolvedValue({ ok: true });970    const container = mount(971      <EntryView972        entry={sampleEntry()}973        onDelete={vi.fn()}974        onFieldSaved={vi.fn(async () => undefined)}975      />976    );977978    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;979    usernameValue.click();980    await flushView();981982    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;983    usernameInput.blur();984    await flushView();985986    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();987    expect(sendMessageMock).not.toHaveBeenCalledWith(988      expect.objectContaining({ type: "UPDATE_ENTRY" })989    );990  });991992  it("Entry lets TOTP secrets be edited inline", async () => {993    sendMessageMock.mockResolvedValue({ ok: true });994    const onFieldSaved = vi.fn(async () => undefined);995    const container = mount(996      <EntryView997        entry={sampleEntry()}998        onDelete={vi.fn()}999        onFieldSaved={onFieldSaved}1000      />1001    );10021003    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1004    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1005    await flushView();10061007    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1008    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1009    await flushView();10101011    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1012    saveButton.click();1013    await flushView();10141015    expect(sendMessageMock).toHaveBeenCalledWith({1016      type: "UPDATE_ENTRY",1017      payload: {1018        id: "entry-1",1019        patch: { totp: "NB2W45DFOIZA====" },1020      },1021    });1022    expect(onFieldSaved).toHaveBeenCalled();1023  });10241025  it("Vault seeds and focuses search when a search seed is provided", async () => {1026    const container = mount(1027      <VaultView1028        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1029        sortOrder="url-first"1030        searchSeed="g"1031        searchSeedNonce={1}1032        onSelectEntry={vi.fn()}1033        onSortOrderChange={vi.fn()}1034        onAdd={vi.fn()}1035        onClose={vi.fn()}1036      />1037    );10381039    await flushView();10401041    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1042    expect(search.value).toBe("g");1043    expect(document.activeElement).toBe(search);1044  });10451046  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1047    parseImportSourceMock.mockResolvedValue(1048      sampleImportResult({1049        entries: [1050          {1051            type: "login",1052            title: "Imported site",1053            username: "imported-user",1054            password: "imported-pass",1055            url: "https://imported.test",1056          },1057        ],1058      })1059    );1060    sendMessageMock.mockImplementation(async (msg: unknown) => {1061      const type = (msg as { type?: string }).type;1062      if (type === "GET_ENTRIES") {1063        return {1064          ok: true,1065          data: [1066            sampleEntry({1067              id: "existing-1",1068              title: "Imported site",1069              username: "imported-user",1070              url: "https://imported.test",1071            }),1072          ],1073        };1074      }1075      if (type === "BATCH_ADD_ENTRIES") {1076        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1077      }1078      return { ok: true };1079    });1080    const onImport = vi.fn(async () => undefined);1081    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1082    await flushView();1083    sendMessageMock.mockClear();10841085    await selectImportFile(container);1086    findButton(container, "Import 1").click();1087    await flushView();10881089    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1090    expect(sendMessageMock).toHaveBeenCalledWith({1091      type: "BATCH_ADD_ENTRIES",1092      payload: {1093        entries: [1094          expect.objectContaining({1095            title: "Imported site",1096            username: "imported-user",1097            password: "imported-pass",1098            url: "https://imported.test",1099          }),1100        ],1101      },1102    });1103    expect(onImport).toHaveBeenCalled();1104    expect(container.textContent).toContain("1 entry appears to be duplicates");1105  });11061107  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1108    parseImportSourceMock.mockResolvedValue(sampleImportResult());1109    sendMessageMock.mockImplementation(async (msg: unknown) => {1110      const type = (msg as { type?: string }).type;1111      if (type === "GET_ENTRIES") {1112        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1113      }1114      if (type === "CLEAR_VAULT_ENTRIES") {1115        return { ok: true };1116      }1117      if (type === "BATCH_ADD_ENTRIES") {1118        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1119      }1120      return { ok: true };1121    });1122    const onImport = vi.fn(async () => undefined);1123    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1124    await flushView();1125    sendMessageMock.mockClear();11261127    await selectImportFile(container);1128    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1129    await flushView();11301131    findButton(container, "Replace vault with 1").click();1132    await flushView();11331134    expect(sendMessageMock).not.toHaveBeenCalledWith(1135      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1136    );1137    expect(container.textContent).toContain("Replace forever? This cannot be undone.");11381139    findButton(container, "Yes, replace forever").click();1140    await flushView();11411142    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1143    expect(sendMessageMock).toHaveBeenCalledWith({1144      type: "BATCH_ADD_ENTRIES",1145      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1146    });1147    expect(onImport).toHaveBeenCalled();1148  });11491150  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1151    parseImportSourceMock.mockResolvedValue(sampleImportResult());1152    sendMessageMock.mockImplementation(async (msg: unknown) => {1153      const type = (msg as { type?: string }).type;1154      if (type === "CREATE_VAULT") return { ok: true };1155      if (type === "BATCH_ADD_ENTRIES") {1156        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1157      }1158      return { ok: true };1159    });1160    const onImport = vi.fn(async () => undefined);1161    const container = mount(1162      <ImportView1163        mode="onboarding"1164        replacesExistingVault={true}1165        onBack={vi.fn()}1166        onImport={onImport}1167      />1168    );11691170    await selectImportFile(container);11711172    const createButton = findButton(container, "Create vault + import 1");1173    expect(createButton.disabled).toBe(true);1174    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1175    expect(container.textContent).toContain("Use at least 8 characters");11761177    const masterInput = container.querySelector(1178      'input[placeholder="new master password"]'1179    ) as HTMLInputElement;1180    const confirmInput = container.querySelector(1181      'input[placeholder="confirm master password"]'1182    ) as HTMLInputElement;1183    const revealButton = container.querySelector(1184      'button[aria-label="Show passwords"]'1185    ) as HTMLButtonElement;1186    expect(revealButton).toBeTruthy();1187    revealButton.click();1188    await flushView();1189    expect(masterInput.type).toBe("text");1190    expect(confirmInput.type).toBe("text");11911192    inputText(masterInput, "fresh-master-password");1193    inputText(confirmInput, "fresh-master-password");1194    await flushView();11951196    expect(createButton.disabled).toBe(false);1197    createButton.click();1198    await flushView();11991200    expect(sendMessageMock).toHaveBeenCalledWith({1201      type: "CREATE_VAULT",1202      payload: { password: "fresh-master-password" },1203    });1204    expect(sendMessageMock).toHaveBeenCalledWith({1205      type: "BATCH_ADD_ENTRIES",1206      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1207    });1208    expect(onImport).toHaveBeenCalled();1209  });12101211  it("Import encrypted backups reparse with the backup password before import", async () => {1212    parseImportSourceMock1213      .mockResolvedValueOnce(1214        sampleImportResult({1215          format: "peach-json",1216          label: "Peach encrypted backup",1217          entries: [],1218          totalItems: 0,1219          requiresPassword: true,1220        })1221      )1222      .mockResolvedValueOnce(1223        sampleImportResult({1224          format: "peach-json",1225          label: "Peach encrypted backup",1226        })1227      );1228    sendMessageMock.mockImplementation(async (msg: unknown) => {1229      const type = (msg as { type?: string }).type;1230      if (type === "GET_ENTRIES") return { ok: true, data: [] };1231      if (type === "BATCH_ADD_ENTRIES") {1232        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1233      }1234      return { ok: true };1235    });1236    const onImport = vi.fn(async () => undefined);1237    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1238    await flushView();1239    sendMessageMock.mockClear();12401241    await selectImportFile(container, '{"type":"peach-export"}');1242    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1243    inputText(password, "backup-passphrase");1244    pressEnter(password);1245    await flushView();1246    await flushView();12471248    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1249    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12501251    findButton(container, "Import 1").click();1252    await flushView();12531254    expect(sendMessageMock).toHaveBeenCalledWith({1255      type: "BATCH_ADD_ENTRIES",1256      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1257    });1258    expect(onImport).toHaveBeenCalled();1259  });1260});