contracts.test.tsx

46.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      if (msg?.type === "RENAME_PAIRED_DEVICE") {461        paired = paired.map((d) =>462          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,463        );464        return { ok: true };465      }466      return { ok: true, data: null };467    });468469    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);470    for (let i = 0; i < 30; i++) await Promise.resolve();471    Array.from(container.querySelectorAll("button"))472      .find((b) => /sync/i.test(b.textContent ?? ""))473      ?.click();474    for (let i = 0; i < 30; i++) await Promise.resolve();475476    // The list itself: extracting the relay selector must not take the hub with it.477    expect(container.textContent).toContain("PAIRED DEVICES");478    expect(container.textContent).toContain("June's Phone");479    expect(container.textContent).toContain("Android");480    Array.from(container.querySelectorAll("button"))481      .find((b) => (b.textContent ?? "").trim() === "rename")!482      .click();483    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;484    renameInput.value = "Travel phone";485    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));486    Array.from(container.querySelectorAll("button"))487      .find((b) => (b.textContent ?? "").trim() === "save")!488      .click();489    for (let i = 0; i < 30; i++) await Promise.resolve();490    expect(sendMessageMock).toHaveBeenCalledWith({491      type: "RENAME_PAIRED_DEVICE",492      payload: { deviceId: "phone-1", name: "Travel phone" },493    });494    expect(container.textContent).toContain("Travel phone");495    const syncNowButton = container.querySelector<HTMLButtonElement>(496      'button[aria-label="Sync this vault now"]',497    )!;498    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));499    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;500    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;501    expect(502      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,503    ).toBeTruthy();504    expect(505      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,506    ).toBeTruthy();507508    Array.from(container.querySelectorAll("button"))509      .find((b) => (b.textContent ?? "").trim() === "remove")!510      .click();511    for (let i = 0; i < 10; i++) await Promise.resolve();512    const confirm = Array.from(513      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),514    ).find((b) => (b.textContent ?? "").trim() === "remove");515    confirm!.click();516    for (let i = 0; i < 30; i++) await Promise.resolve();517518    expect(sendMessageMock).toHaveBeenCalledWith({519      type: "UNPAIR_DEVICE",520      payload: { deviceId: "phone-1" },521    });522    // The list must be refreshed and the device must be gone. The regression523    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but524    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.525    expect(container.textContent).not.toContain("June's Phone");526    expect(container.textContent).not.toContain("PAIRED DEVICES");527  });528529  it("Settings keeps connection and recovery details collapsed until requested", async () => {530    sendMessageMock.mockImplementation(async (msg: any) => {531      if (msg?.type === "LIST_PAIRED_DEVICES") {532        return { ok: true, data: { devices: [] } };533      }534      return { ok: true, data: null };535    });536537    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);538    for (let i = 0; i < 30; i++) await Promise.resolve();539    Array.from(container.querySelectorAll("button"))540      .find((button) => /sync/i.test(button.textContent ?? ""))541      ?.click();542    for (let i = 0; i < 20; i++) await Promise.resolve();543544    expect(container.textContent).toContain("SYNC STATUS");545    expect(container.textContent).toContain("ADD A DEVICE");546    expect(container.textContent).not.toContain("Public relay");547    expect(container.textContent).not.toContain("Rotate sync identity");548549    const connection = Array.from(container.querySelectorAll("button"))550      .find((button) => button.textContent?.includes("CONNECTION"));551    connection?.click();552    await Promise.resolve();553    expect(container.textContent).toContain("Public relay");554555    const recovery = Array.from(container.querySelectorAll("button"))556      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));557    recovery?.click();558    await Promise.resolve();559    expect(container.textContent).toContain("Rotate sync identity");560  });561562  it("Settings requires current, new, and confirm passwords before enabling save", async () => {563    sendMessageMock.mockResolvedValue({ ok: true, data: null });564    const container = mount(565      <SettingsView566        entriesCount={1}567        breachMonitoring={true}568        lockOnClose={true}569        autoLockMinutes={5}570        trashRetentionDays={30}571        theme="dark"572        accentColor="peach"573        textSize="medium"574        reduceMotion={false}575        onBreachMonitoringChange={vi.fn()}576        onLockOnCloseChange={vi.fn()}577        onAutoLockMinutesChange={vi.fn()}578        onTrashRetentionChange={vi.fn()}579        onThemeChange={vi.fn()}580        onAccentChange={vi.fn()}581        onTextSizeChange={vi.fn()}582        onReduceMotionChange={vi.fn()}583        shortcuts={DEFAULT_BINDINGS}584        onShortcutsChange={vi.fn()}585        onBack={vi.fn()}586        onImport={vi.fn()}587        onExport={vi.fn()}588        onBreachCheckDone={vi.fn()}589      />590    );591    await flushView();592593    expect(container.querySelectorAll(".stk-card").length).toBe(7);594    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]595    // "open settings to card I (Peach Pro) instead of card II (Protection)".596    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");597598    const changeButton = Array.from(container.querySelectorAll("button")).find(599      (candidate) => candidate.textContent?.trim() === "change"600    ) as HTMLButtonElement;601    changeButton.click();602    await flushView();603604    const saveButton = Array.from(container.querySelectorAll("button")).find(605      (candidate) => candidate.textContent?.trim() === "save new password"606    ) as HTMLButtonElement;607    expect(saveButton.disabled).toBe(true);608609    const inputs = container.querySelectorAll('input[type="password"]');610    inputText(inputs[0] as HTMLInputElement, "current-pass");611    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");612    await flushView();613    expect(saveButton.disabled).toBe(true);614615    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");616    await flushView();617    expect(saveButton.disabled).toBe(false);618  });619620  it("Settings feedback highlights the selected category and hides the form after submit", async () => {621    sendMessageMock.mockResolvedValue({ ok: true, data: null });622    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({623      ok: true,624      json: async () => ({ success: true }),625    }));626    vi.stubGlobal("fetch", fetchMock);627628    const container = mount(629      <SettingsView630        entriesCount={1}631        breachMonitoring={true}632        lockOnClose={true}633        autoLockMinutes={5}634        trashRetentionDays={30}635        theme="dark"636        accentColor="peach"637        textSize="medium"638        reduceMotion={false}639        initialSection="feedback"640        onBreachMonitoringChange={vi.fn()}641        onLockOnCloseChange={vi.fn()}642        onAutoLockMinutesChange={vi.fn()}643        onTrashRetentionChange={vi.fn()}644        onThemeChange={vi.fn()}645        onAccentChange={vi.fn()}646        onTextSizeChange={vi.fn()}647        onReduceMotionChange={vi.fn()}648        shortcuts={DEFAULT_BINDINGS}649        onShortcutsChange={vi.fn()}650        onBack={vi.fn()}651        onImport={vi.fn()}652        onExport={vi.fn()}653        onBreachCheckDone={vi.fn()}654      />655    );656    await flushView();657658    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;659    expect(feedbackCard.classList.contains("is-active")).toBe(true);660661    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];662    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;663    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;664    expect(bugButton.getAttribute("aria-pressed")).toBe("true");665    expect(featureButton.getAttribute("aria-pressed")).toBe("false");666667    featureButton.click();668    await flushView();669670    expect(bugButton.getAttribute("aria-pressed")).toBe("false");671    expect(featureButton.getAttribute("aria-pressed")).toBe("true");672    expect(featureButton.classList.contains("is-active")).toBe(true);673674    inputText(675      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,676      '<script>alert("xss")</script> Please make this easier to see & safer.'677    );678    const sendButton = Array.from(container.querySelectorAll("button")).find(679      (candidate) => candidate.textContent?.trim() === "Send"680    ) as HTMLButtonElement;681    sendButton.click();682683    await vi.waitFor(() => {684      expect(container.textContent).toContain("Thank you");685    });686687    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));688    expect(requestBody.subject).toContain("Feature request");689    expect(requestBody.message).toBe(690      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."691    );692    expect(requestBody.message).not.toContain("<script>");693    expect(container.textContent).toContain("Someone will process it soon");694    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();695    expect(container.querySelector(".peach-feedback-submit")).toBeNull();696  });697698  it("Settings keeps license activation on the full-screen celebration path", async () => {699    const onLicenseChange = vi.fn(async () => undefined);700    sendMessageMock.mockImplementation(async (msg: unknown) => {701      const type = (msg as { type?: string }).type;702      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };703      if (type === "ACTIVATE_LICENSE") {704        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };705      }706      if (type === "GET_ENTRIES") return { ok: true, data: [] };707      return { ok: true, data: null };708    });709710    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);711    await flushView();712713    inputText(714      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,715      "PEACH-TEST-KEY",716    );717    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;718    apply.click();719720    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));721    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);722    expect(container.textContent).not.toContain("thank you ✦");723  });724725  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {726    sendMessageMock.mockImplementation(async (msg: unknown) => {727      const type = (msg as { type?: string }).type;728      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };729      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };730      if (type === "GET_ENTRIES") return { ok: true, data: [] };731      return { ok: true, data: null };732    });733734    const onLicenseChangeFromStar = vi.fn();735    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);736    await flushView();737    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();738    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);739    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();740741    const onLicenseChangeFromPeach = vi.fn();742    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);743    await flushView();744    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();745    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);746    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();747  });748749  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {750    sendMessageMock.mockResolvedValue({ ok: true, data: null });751    const fetchMock = vi.fn();752    const requestMock = vi.fn(async () => false);753    vi.stubGlobal("fetch", fetchMock);754    vi.stubGlobal("browser", {755      permissions: {756        getAll: vi.fn(async () => ({ data_collection: [] })),757        request: requestMock,758      },759    });760761    const container = mount(762      <SettingsView763        entriesCount={1}764        breachMonitoring={true}765        lockOnClose={true}766        autoLockMinutes={5}767        trashRetentionDays={30}768        theme="dark"769        accentColor="peach"770        textSize="medium"771        reduceMotion={false}772        initialSection="feedback"773        onBreachMonitoringChange={vi.fn()}774        onLockOnCloseChange={vi.fn()}775        onAutoLockMinutesChange={vi.fn()}776        onTrashRetentionChange={vi.fn()}777        onThemeChange={vi.fn()}778        onAccentChange={vi.fn()}779        onTextSizeChange={vi.fn()}780        onReduceMotionChange={vi.fn()}781        shortcuts={DEFAULT_BINDINGS}782        onShortcutsChange={vi.fn()}783        onBack={vi.fn()}784        onImport={vi.fn()}785        onExport={vi.fn()}786        onBreachCheckDone={vi.fn()}787      />788    );789    await flushView();790791    inputText(792      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,793      "Please improve the feedback flow."794    );795    const sendButton = Array.from(container.querySelectorAll("button")).find(796      (candidate) => candidate.textContent?.trim() === "Send"797    ) as HTMLButtonElement;798    sendButton.click();799800    await vi.waitFor(() => {801      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });802    });803    expect(fetchMock).not.toHaveBeenCalled();804    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();805  });806807  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {808    sendMessageMock.mockImplementation(async (msg: unknown) => {809      const m = msg as { type: string };810      if (m.type === "GET_RECOVERY_WORDS") {811        return {812          ok: true,813          data: [814            "abandon", "ability", "able", "about", "above", "absent",815            "absorb", "abstract", "absurd", "abuse", "access", "accident",816          ],817        };818      }819      return { ok: true, data: null };820    });821    const onAutoLockMinutesChange = vi.fn();822    const onThemeChange = vi.fn();823    const container = mount(824      <SettingsView825        entriesCount={1}826        breachMonitoring={true}827        lockOnClose={true}828        autoLockMinutes={5}829        trashRetentionDays={30}830        theme="dark"831        accentColor="peach"832        textSize="medium"833        reduceMotion={false}834        onBreachMonitoringChange={vi.fn()}835        onLockOnCloseChange={vi.fn()}836        onAutoLockMinutesChange={onAutoLockMinutesChange}837        onTrashRetentionChange={vi.fn()}838        onThemeChange={onThemeChange}839        onAccentChange={vi.fn()}840        onTextSizeChange={vi.fn()}841        onReduceMotionChange={vi.fn()}842        shortcuts={DEFAULT_BINDINGS}843        onShortcutsChange={vi.fn()}844        onBack={vi.fn()}845        onImport={vi.fn()}846        onExport={vi.fn()}847        onBreachCheckDone={vi.fn()}848      />849    );850    await flushView();851    sendMessageMock.mockClear();852853    // ── Auto-lock + theme changes (before password change) ─────────────854    const selects = container.querySelectorAll("select");855    changeSelect(selects[0] as HTMLSelectElement, "15");856    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);857858    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;859    appearanceCard.click();860    await flushView();861    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");862    const themeButton = Array.from(container.querySelectorAll("button")).find(863      (candidate) => candidate.textContent?.trim() === "Switch to light"864    ) as HTMLButtonElement;865    themeButton.click();866    expect(onThemeChange).toHaveBeenCalledWith("light");867868    // ── Password change → recovery words display ────────────────────────869    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;870    protectionCard.click();871    await flushView();872873    const changeButton = Array.from(container.querySelectorAll("button")).find(874      (candidate) => candidate.textContent?.trim() === "change"875    ) as HTMLButtonElement;876    changeButton.click();877    await flushView();878879    const inputs = container.querySelectorAll('input[type="password"]');880    inputText(inputs[0] as HTMLInputElement, "current-pass");881    inputText(inputs[1] as HTMLInputElement, "new-password-123");882    inputText(inputs[2] as HTMLInputElement, "new-password-123");883    await flushView();884885    const saveButton = Array.from(container.querySelectorAll("button")).find(886      (candidate) => candidate.textContent?.trim() === "save new password"887    ) as HTMLButtonElement;888    saveButton.click();889    await flushView();890891    expect(sendMessageMock).toHaveBeenCalledWith({892      type: "CHANGE_PASSWORD",893      payload: {894        currentPassword: "current-pass",895        newPassword: "new-password-123",896      },897    });898899    // After a successful password change the recovery word display replaces settings.900    expect(container.textContent).toContain("save your recovery words");901    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });902  });903904  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {905    sendMessageMock.mockResolvedValue({ ok: true });906    const container = mount(907      <EntryView908        entry={sampleEntry()}909        onDelete={vi.fn()}910        onFieldSaved={vi.fn(async () => undefined)}911      />912    );913914    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;915    passwordButton.click();916    await flushView();917918    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {919      type: "COPY",920      payload: { text: "secret123", label: "password", alreadyWritten: false },921    });922    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {923      type: "FIRE_CANARY_PING",924      payload: {925        webhookUrl: "https://example.com/hook",926        entryTitle: "GitHub",927        trigger: "password_copied",928      },929    });930931    sendMessageMock.mockClear();932    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;933    usernameButton.click();934    await flushView();935936    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {937      type: "COPY",938      payload: { text: "june", label: "username", alreadyWritten: false },939    });940    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {941      type: "FIRE_CANARY_PING",942      payload: {943        webhookUrl: "https://example.com/hook",944        entryTitle: "GitHub",945        trigger: "username_copied",946      },947    });948  });949950  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {951    sendMessageMock.mockResolvedValue({ ok: true });952    const onFieldSaved = vi.fn(async () => undefined);953    const container = mount(954      <EntryView955        entry={sampleEntry()}956        onDelete={vi.fn()}957        onFieldSaved={onFieldSaved}958      />959    );960961    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;962    passwordValue.click();963    await flushView();964965    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;966    inputText(passwordInput, "");967    await flushView();968969    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;970    saveButton.click();971    await flushView();972973    expect(sendMessageMock).not.toHaveBeenCalledWith(974      expect.objectContaining({ type: "UPDATE_ENTRY" })975    );976    expect(onFieldSaved).not.toHaveBeenCalled();977978    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;979    totpMain.click();980981    await vi.waitFor(() => {982      expect(sendMessageMock).toHaveBeenLastCalledWith({983        type: "COPY",984        payload: { text: "123456", label: "totp", alreadyWritten: false },985      });986    }, { timeout: 500 });987  });988989  it("Entry closes an unchanged inline editor when focus leaves it", async () => {990    sendMessageMock.mockResolvedValue({ ok: true });991    const container = mount(992      <EntryView993        entry={sampleEntry()}994        onDelete={vi.fn()}995        onFieldSaved={vi.fn(async () => undefined)}996      />997    );998999    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1000    usernameValue.click();1001    await flushView();10021003    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1004    usernameInput.blur();1005    await flushView();10061007    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1008    expect(sendMessageMock).not.toHaveBeenCalledWith(1009      expect.objectContaining({ type: "UPDATE_ENTRY" })1010    );1011  });10121013  it("Entry lets TOTP secrets be edited inline", async () => {1014    sendMessageMock.mockResolvedValue({ ok: true });1015    const onFieldSaved = vi.fn(async () => undefined);1016    const container = mount(1017      <EntryView1018        entry={sampleEntry()}1019        onDelete={vi.fn()}1020        onFieldSaved={onFieldSaved}1021      />1022    );10231024    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1025    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1026    await flushView();10271028    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1029    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1030    await flushView();10311032    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1033    saveButton.click();1034    await flushView();10351036    expect(sendMessageMock).toHaveBeenCalledWith({1037      type: "UPDATE_ENTRY",1038      payload: {1039        id: "entry-1",1040        patch: { totp: "NB2W45DFOIZA====" },1041      },1042    });1043    expect(onFieldSaved).toHaveBeenCalled();1044  });10451046  it("Vault seeds and focuses search when a search seed is provided", async () => {1047    const container = mount(1048      <VaultView1049        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1050        sortOrder="url-first"1051        searchSeed="g"1052        searchSeedNonce={1}1053        onSelectEntry={vi.fn()}1054        onSortOrderChange={vi.fn()}1055        onAdd={vi.fn()}1056        onClose={vi.fn()}1057      />1058    );10591060    await flushView();10611062    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1063    expect(search.value).toBe("g");1064    expect(document.activeElement).toBe(search);1065  });10661067  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1068    parseImportSourceMock.mockResolvedValue(1069      sampleImportResult({1070        entries: [1071          {1072            type: "login",1073            title: "Imported site",1074            username: "imported-user",1075            password: "imported-pass",1076            url: "https://imported.test",1077          },1078        ],1079      })1080    );1081    sendMessageMock.mockImplementation(async (msg: unknown) => {1082      const type = (msg as { type?: string }).type;1083      if (type === "GET_ENTRIES") {1084        return {1085          ok: true,1086          data: [1087            sampleEntry({1088              id: "existing-1",1089              title: "Imported site",1090              username: "imported-user",1091              url: "https://imported.test",1092            }),1093          ],1094        };1095      }1096      if (type === "BATCH_ADD_ENTRIES") {1097        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1098      }1099      return { ok: true };1100    });1101    const onImport = vi.fn(async () => undefined);1102    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1103    await flushView();1104    sendMessageMock.mockClear();11051106    await selectImportFile(container);1107    findButton(container, "Import 1").click();1108    await flushView();11091110    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1111    expect(sendMessageMock).toHaveBeenCalledWith({1112      type: "BATCH_ADD_ENTRIES",1113      payload: {1114        entries: [1115          expect.objectContaining({1116            title: "Imported site",1117            username: "imported-user",1118            password: "imported-pass",1119            url: "https://imported.test",1120          }),1121        ],1122      },1123    });1124    expect(onImport).toHaveBeenCalled();1125    expect(container.textContent).toContain("1 entry appears to be duplicates");1126  });11271128  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1129    parseImportSourceMock.mockResolvedValue(sampleImportResult());1130    sendMessageMock.mockImplementation(async (msg: unknown) => {1131      const type = (msg as { type?: string }).type;1132      if (type === "GET_ENTRIES") {1133        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1134      }1135      if (type === "CLEAR_VAULT_ENTRIES") {1136        return { ok: true };1137      }1138      if (type === "BATCH_ADD_ENTRIES") {1139        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1140      }1141      return { ok: true };1142    });1143    const onImport = vi.fn(async () => undefined);1144    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1145    await flushView();1146    sendMessageMock.mockClear();11471148    await selectImportFile(container);1149    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1150    await flushView();11511152    findButton(container, "Replace vault with 1").click();1153    await flushView();11541155    expect(sendMessageMock).not.toHaveBeenCalledWith(1156      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1157    );1158    expect(container.textContent).toContain("Replace forever? This cannot be undone.");11591160    findButton(container, "Yes, replace forever").click();1161    await flushView();11621163    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1164    expect(sendMessageMock).toHaveBeenCalledWith({1165      type: "BATCH_ADD_ENTRIES",1166      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1167    });1168    expect(onImport).toHaveBeenCalled();1169  });11701171  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1172    parseImportSourceMock.mockResolvedValue(sampleImportResult());1173    sendMessageMock.mockImplementation(async (msg: unknown) => {1174      const type = (msg as { type?: string }).type;1175      if (type === "CREATE_VAULT") return { ok: true };1176      if (type === "BATCH_ADD_ENTRIES") {1177        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1178      }1179      return { ok: true };1180    });1181    const onImport = vi.fn(async () => undefined);1182    const container = mount(1183      <ImportView1184        mode="onboarding"1185        replacesExistingVault={true}1186        onBack={vi.fn()}1187        onImport={onImport}1188      />1189    );11901191    await selectImportFile(container);11921193    const createButton = findButton(container, "Create vault + import 1");1194    expect(createButton.disabled).toBe(true);1195    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1196    expect(container.textContent).toContain("Use at least 8 characters");11971198    const masterInput = container.querySelector(1199      'input[placeholder="new master password"]'1200    ) as HTMLInputElement;1201    const confirmInput = container.querySelector(1202      'input[placeholder="confirm master password"]'1203    ) as HTMLInputElement;1204    const revealButton = container.querySelector(1205      'button[aria-label="Show passwords"]'1206    ) as HTMLButtonElement;1207    expect(revealButton).toBeTruthy();1208    revealButton.click();1209    await flushView();1210    expect(masterInput.type).toBe("text");1211    expect(confirmInput.type).toBe("text");12121213    inputText(masterInput, "fresh-master-password");1214    inputText(confirmInput, "fresh-master-password");1215    await flushView();12161217    expect(createButton.disabled).toBe(false);1218    createButton.click();1219    await flushView();12201221    expect(sendMessageMock).toHaveBeenCalledWith({1222      type: "CREATE_VAULT",1223      payload: { password: "fresh-master-password" },1224    });1225    expect(sendMessageMock).toHaveBeenCalledWith({1226      type: "BATCH_ADD_ENTRIES",1227      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1228    });1229    expect(onImport).toHaveBeenCalled();1230  });12311232  it("Import encrypted backups reparse with the backup password before import", async () => {1233    parseImportSourceMock1234      .mockResolvedValueOnce(1235        sampleImportResult({1236          format: "peach-json",1237          label: "Peach encrypted backup",1238          entries: [],1239          totalItems: 0,1240          requiresPassword: true,1241        })1242      )1243      .mockResolvedValueOnce(1244        sampleImportResult({1245          format: "peach-json",1246          label: "Peach encrypted backup",1247        })1248      );1249    sendMessageMock.mockImplementation(async (msg: unknown) => {1250      const type = (msg as { type?: string }).type;1251      if (type === "GET_ENTRIES") return { ok: true, data: [] };1252      if (type === "BATCH_ADD_ENTRIES") {1253        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1254      }1255      return { ok: true };1256    });1257    const onImport = vi.fn(async () => undefined);1258    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1259    await flushView();1260    sendMessageMock.mockClear();12611262    await selectImportFile(container, '{"type":"peach-export"}');1263    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1264    inputText(password, "backup-passphrase");1265    pressEnter(password);1266    await flushView();1267    await flushView();12681269    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1270    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12711272    findButton(container, "Import 1").click();1273    await flushView();12741275    expect(sendMessageMock).toHaveBeenCalledWith({1276      type: "BATCH_ADD_ENTRIES",1277      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1278    });1279    expect(onImport).toHaveBeenCalled();1280  });1281});