contracts.test.tsx

39.8 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { RecoveryWordEntryView } from "./RecoveryWordEntry";16import { VaultView } from "./Vault";17import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1819const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();20const triggerRingMock = vi.fn();21const triggerPipPulseMock = vi.fn();22const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();23const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2425vi.mock("../App", () => ({26  sendMessage: (msg: unknown) => sendMessageMock(msg),27  triggerRing: () => triggerRingMock(),28  triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/import", () => ({32  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),33}));3435vi.mock("../../shared/favicon", async (importOriginal) => {36  const actual = await importOriginal<typeof import("../../shared/favicon")>();37  return { ...actual, faviconUrl: () => null };38});3940vi.mock("../../crypto/totp", () => ({41  generateTOTP: () => ({ code: "123 456", remaining: 24 }),42  parseOtpauthURI: (value: string) =>43    value.startsWith("otpauth://")44      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }45      : null,46}));4748vi.mock("../components/GeneratorDrawer", () => ({49  GeneratorDrawer: () => null,50}));5152vi.mock("@zxcvbn-ts/core", () => ({53  zxcvbn: (value: string) => zxcvbnMock(value),54  zxcvbnOptions: { setOptions: vi.fn() },55}));56vi.mock("@zxcvbn-ts/language-common", () => ({57  adjacencyGraphs: {},58  dictionary: {},59}));60vi.mock("@zxcvbn-ts/language-en", () => ({61  translations: {},62  dictionary: {},63}));6465describe("popup contract tests", () => {66  const disposers: Array<() => void> = [];6768  beforeEach(() => {69    document.body.innerHTML = "";70    sendMessageMock.mockReset();71    triggerRingMock.mockReset();72    triggerPipPulseMock.mockReset();73    parseImportSourceMock.mockReset();74    zxcvbnMock.mockReset();75    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));76    parseImportSourceMock.mockResolvedValue({77      format: "unknown",78      label: "Unknown format",79      entries: [],80      totalItems: 0,81      skippedItems: 0,82      requiresPassword: false,83    });8485    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {86      return window.setTimeout(() => cb(0), 0) as unknown as number;87    });88    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));89    vi.stubGlobal("URL", {90      createObjectURL: vi.fn(() => "blob:peach-test"),91      revokeObjectURL: vi.fn(),92    });93    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9495    (globalThis as { chrome?: typeof chrome }).chrome = {96      runtime: {97        getManifest: () => ({ version: "1.4.47" }),98        onMessage: { addListener: vi.fn() },99      },100    } as unknown as typeof chrome;101  });102103  afterEach(() => {104    while (disposers.length > 0) {105      disposers.pop()?.();106    }107    vi.restoreAllMocks();108    vi.unstubAllGlobals();109  });110111  function mount(view: JSX.Element) {112    const mounted = renderView(view);113    disposers.push(mounted.dispose);114    return mounted.container;115  }116117  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {118    return {119      id: "entry-1",120      type: "login",121      title: "GitHub",122      username: "june",123      password: "secret123",124      url: "https://github.com",125      totp: "JBSWY3DPEHPK3PXP",126      canary: {127        webhookUrl: "https://example.com/hook",128      },129      createdAt: 1,130      modifiedAt: 1,131      ...overrides,132    };133  }134135  function sampleImportResult(136    overrides: Partial<ImportParseResult> = {}137  ): ImportParseResult {138    return {139      format: "csv",140      label: "CSV import",141      entries: [142        {143          type: "login",144          title: "Imported site",145          username: "imported-user",146          password: "imported-pass",147          url: "https://imported.test",148        },149      ],150      totalItems: 1,151      skippedItems: 0,152      requiresPassword: false,153      ...overrides,154    };155  }156157  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {158    return {159      entriesCount: 1,160      breachMonitoring: true,161      lockOnClose: true,162      autoLockMinutes: 5,163      trashRetentionDays: 30,164      theme: "dark" as const,165      accentColor: "peach" as const,166      textSize: "medium" as const,167      reduceMotion: false,168      onBreachMonitoringChange: vi.fn(),169      onLockOnCloseChange: vi.fn(),170      onAutoLockMinutesChange: vi.fn(),171      onTrashRetentionChange: vi.fn(),172      onThemeChange: vi.fn(),173      onAccentChange: vi.fn(),174      onTextSizeChange: vi.fn(),175      onReduceMotionChange: vi.fn(),176      shortcuts: DEFAULT_BINDINGS,177      onShortcutsChange: vi.fn(),178      onBack: vi.fn(),179      onImport: vi.fn(),180      onExport: vi.fn(),181      onBreachCheckDone: vi.fn(),182      ...overrides,183    };184  }185186  async function selectImportFile(187    container: HTMLElement,188    contents = '{"export":"test"}'189  ) {190    // The Import view now opens onto a menu — click into the file subview191    // before locating the file input. Idempotent: a second click won't re-fire192    // because the menu unmounts once the subview renders.193    const fileCard = findButton(container, "Choose file →");194    if (fileCard) {195      fileCard.click();196      await flushView();197    }198    const input = container.querySelector('input[type="file"]') as HTMLInputElement;199    const file = new File([contents], "import.json", { type: "application/json" });200    Object.defineProperty(input, "files", {201      configurable: true,202      value: [file],203    });204    input.dispatchEvent(new Event("change", { bubbles: true }));205    await flushView();206    await flushView();207  }208209  function findButton(container: HTMLElement, text: string): HTMLButtonElement {210    return Array.from(container.querySelectorAll("button")).find(211      (candidate) => candidate.textContent?.trim() === text212    ) as HTMLButtonElement;213  }214215  it("Lock sends UNLOCK with the entered passphrase", async () => {216    sendMessageMock.mockResolvedValue({ ok: true });217    const onUnlock = vi.fn(async () => undefined);218    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);219220    const password = container.querySelector("#unlock-password") as HTMLInputElement;221    inputText(password, "correct horse battery staple");222    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();223    await flushView();224225    expect(sendMessageMock).toHaveBeenCalledWith({226      type: "UNLOCK",227      payload: { password: "correct horse battery staple" },228    });229    expect(onUnlock).toHaveBeenCalled();230  });231232  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {233    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);234    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;235    const password = container.querySelector("#master-password") as HTMLInputElement;236    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;237238    inputText(password, "short");239    inputText(confirm, "short");240    await flushView();241    expect(createButton.disabled).toBe(true);242243    zxcvbnMock.mockImplementation(() => ({ score: 1 }));244    inputText(password, "weakweakweak");245    inputText(confirm, "weakweakweak");246    await flushView();247    expect(createButton.disabled).toBe(true);248  });249250  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {251    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock252    sendMessageMock.mockImplementation(253      async (msg: unknown) => {254        const m = msg as { type: string; payload?: { password?: string } };255        if (m.type === "CREATE_VAULT") return { ok: true };256        if (m.type === "GET_RECOVERY_WORDS") {257          // Return 12 fake words — must pass findInvalidWords258          return {259            ok: true,260            data: [261              "abandon", "ability", "able", "about", "above", "absent",262              "absorb", "abstract", "absurd", "abuse", "access", "accident",263            ],264          };265        }266        return { ok: true };267      }268    );269    zxcvbnMock.mockImplementation(() => ({ score: 3 }));270    const onUnlock = vi.fn(async () => undefined);271    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);272273    const password = container.querySelector("#master-password") as HTMLInputElement;274    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;275    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;276277    inputText(password, "correct horse battery staple");278    inputText(confirm, "correct horse battery staple");279    await flushView();280    expect(createButton.disabled).toBe(false);281282    pressEnter(confirm);283    await flushView();284285    expect(sendMessageMock).toHaveBeenCalledWith({286      type: "CREATE_VAULT",287      payload: { password: "correct horse battery staple" },288    });289    // After CREATE_VAULT success, the recovery word display is shown.290    // onUnlock is NOT called yet — user must first confirm their recovery words.291    expect(onUnlock).not.toHaveBeenCalled();292  });293294  it("Recovery words flow submits the new master password and completes", async () => {295    sendMessageMock.mockImplementation(async (msg: unknown) => {296      const type = (msg as { type?: string }).type;297      if (type === "VALIDATE_RECOVERY_WORDS") {298        return { ok: true, data: { valid: true, invalid: [] } };299      }300      if (type === "RECOVER_WITH_WORDS") return { ok: true };301      return { ok: true };302    });303    const canvasContext = {304      clearRect: vi.fn(),305      fillText: vi.fn(),306      beginPath: vi.fn(),307      closePath: vi.fn(),308      arc: vi.fn(),309      fill: vi.fn(),310      stroke: vi.fn(),311      save: vi.fn(),312      restore: vi.fn(),313      translate: vi.fn(),314      rotate: vi.fn(),315      scale: vi.fn(),316      drawImage: vi.fn(),317      measureText: vi.fn(() => ({ width: 0 })),318      setTransform: vi.fn(),319      fillRect: vi.fn(),320      strokeRect: vi.fn(),321    } as unknown as CanvasRenderingContext2D;322    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);323324    const onRecoveryComplete = vi.fn(async () => undefined);325    const container = mount(326      <RecoveryWordEntryView327        onCancel={vi.fn()}328        onRecoveryComplete={onRecoveryComplete}329      />330    );331332    const recoveryWords = WORD_LIST.slice(0, 12);333    for (let i = 0; i < recoveryWords.length; i++) {334      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;335      inputText(input, recoveryWords[i]!);336    }337    await flushView();338339    findButton(container, "verify words").click();340    await flushView();341342    expect(container.textContent).toContain("new master password");343344    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;345    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;346    inputText(newPassword, "fresh-recovery-password");347    inputText(confirmPassword, "fresh-recovery-password");348    await flushView();349350    findButton(container, "recover vault").click();351    await flushView();352353    expect(sendMessageMock).toHaveBeenCalledWith({354      type: "RECOVER_WITH_WORDS",355      payload: {356        words: recoveryWords,357        newPassword: "fresh-recovery-password",358      },359    });360    expect(onRecoveryComplete).toHaveBeenCalled();361  });362363  it("Export menu surfaces both backup formats", async () => {364    const container = mount(<ExportView onBack={vi.fn()} />);365    await flushView();366367    expect(container.textContent).toContain("PeachScript");368    expect(container.textContent).toContain("CSV");369  });370371  it("Lock onboarding restore options include Peach Codex", async () => {372    const onOpenCodexRestore = vi.fn();373    const container = mount(374      <LockView375        hasVault={false}376        ready={true}377        onUnlock={vi.fn()}378        onOpenCodexRestore={onOpenCodexRestore}379      />380    );381382    const restoreButton = Array.from(container.querySelectorAll("button")).find(383      (candidate) => candidate.textContent?.includes("restore from backup")384    ) as HTMLButtonElement;385    restoreButton.click();386    await flushView();387388    expect(container.textContent).toContain("PeachScript");389    expect(container.textContent).toContain("Codex");390    expect(container.textContent).toContain("CSV");391392    const codexTab = Array.from(container.querySelectorAll("button")).find(393      (candidate) => candidate.textContent?.trim() === "Codex"394    ) as HTMLButtonElement;395    codexTab.click();396    await flushView();397398    expect(container.textContent).toContain("Restore from Peach Codex");399    expect(container.textContent).toContain("Scan printed Codex pages");400401    const openCodexButton = Array.from(container.querySelectorAll("button")).find(402      (candidate) => candidate.textContent?.trim() === "open Codex restore →"403    ) as HTMLButtonElement;404    openCodexButton.click();405406    expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);407  });408409  it("Settings requires current, new, and confirm passwords before enabling save", async () => {410    sendMessageMock.mockResolvedValue({ ok: true, data: null });411    const container = mount(412      <SettingsView413        entriesCount={1}414        breachMonitoring={true}415        lockOnClose={true}416        autoLockMinutes={5}417        trashRetentionDays={30}418        theme="dark"419        accentColor="peach"420        textSize="medium"421        reduceMotion={false}422        onBreachMonitoringChange={vi.fn()}423        onLockOnCloseChange={vi.fn()}424        onAutoLockMinutesChange={vi.fn()}425        onTrashRetentionChange={vi.fn()}426        onThemeChange={vi.fn()}427        onAccentChange={vi.fn()}428        onTextSizeChange={vi.fn()}429        onReduceMotionChange={vi.fn()}430        shortcuts={DEFAULT_BINDINGS}431        onShortcutsChange={vi.fn()}432        onBack={vi.fn()}433        onImport={vi.fn()}434        onExport={vi.fn()}435        onBreachCheckDone={vi.fn()}436      />437    );438    await flushView();439440    expect(container.querySelectorAll(".stk-card").length).toBe(7);441    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]442    // "open settings to card I (Peach Pro) instead of card II (Protection)".443    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");444445    const changeButton = Array.from(container.querySelectorAll("button")).find(446      (candidate) => candidate.textContent?.trim() === "change"447    ) as HTMLButtonElement;448    changeButton.click();449    await flushView();450451    const saveButton = Array.from(container.querySelectorAll("button")).find(452      (candidate) => candidate.textContent?.trim() === "save new password"453    ) as HTMLButtonElement;454    expect(saveButton.disabled).toBe(true);455456    const inputs = container.querySelectorAll('input[type="password"]');457    inputText(inputs[0] as HTMLInputElement, "current-pass");458    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");459    await flushView();460    expect(saveButton.disabled).toBe(true);461462    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");463    await flushView();464    expect(saveButton.disabled).toBe(false);465  });466467  it("Settings feedback highlights the selected category and hides the form after submit", async () => {468    sendMessageMock.mockResolvedValue({ ok: true, data: null });469    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({470      ok: true,471      json: async () => ({ success: true }),472    }));473    vi.stubGlobal("fetch", fetchMock);474475    const container = mount(476      <SettingsView477        entriesCount={1}478        breachMonitoring={true}479        lockOnClose={true}480        autoLockMinutes={5}481        trashRetentionDays={30}482        theme="dark"483        accentColor="peach"484        textSize="medium"485        reduceMotion={false}486        initialSection="feedback"487        onBreachMonitoringChange={vi.fn()}488        onLockOnCloseChange={vi.fn()}489        onAutoLockMinutesChange={vi.fn()}490        onTrashRetentionChange={vi.fn()}491        onThemeChange={vi.fn()}492        onAccentChange={vi.fn()}493        onTextSizeChange={vi.fn()}494        onReduceMotionChange={vi.fn()}495        shortcuts={DEFAULT_BINDINGS}496        onShortcutsChange={vi.fn()}497        onBack={vi.fn()}498        onImport={vi.fn()}499        onExport={vi.fn()}500        onBreachCheckDone={vi.fn()}501      />502    );503    await flushView();504505    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;506    expect(feedbackCard.classList.contains("is-active")).toBe(true);507508    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];509    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;510    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;511    expect(bugButton.getAttribute("aria-pressed")).toBe("true");512    expect(featureButton.getAttribute("aria-pressed")).toBe("false");513514    featureButton.click();515    await flushView();516517    expect(bugButton.getAttribute("aria-pressed")).toBe("false");518    expect(featureButton.getAttribute("aria-pressed")).toBe("true");519    expect(featureButton.classList.contains("is-active")).toBe(true);520521    inputText(522      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,523      '<script>alert("xss")</script> Please make this easier to see & safer.'524    );525    const sendButton = Array.from(container.querySelectorAll("button")).find(526      (candidate) => candidate.textContent?.trim() === "Send"527    ) as HTMLButtonElement;528    sendButton.click();529530    await vi.waitFor(() => {531      expect(container.textContent).toContain("Thank you");532    });533534    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));535    expect(requestBody.subject).toContain("Feature request");536    expect(requestBody.message).toBe(537      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."538    );539    expect(requestBody.message).not.toContain("<script>");540    expect(container.textContent).toContain("Someone will process it soon");541    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();542    expect(container.querySelector(".peach-feedback-submit")).toBeNull();543  });544545  it("Settings keeps license activation on the full-screen celebration path", async () => {546    const onLicenseChange = vi.fn(async () => undefined);547    sendMessageMock.mockImplementation(async (msg: unknown) => {548      const type = (msg as { type?: string }).type;549      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };550      if (type === "ACTIVATE_LICENSE") {551        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };552      }553      if (type === "GET_ENTRIES") return { ok: true, data: [] };554      return { ok: true, data: null };555    });556557    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);558    await flushView();559560    inputText(561      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,562      "PEACH-TEST-KEY",563    );564    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;565    apply.click();566567    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));568    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);569    expect(container.textContent).not.toContain("thank you ✦");570  });571572  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {573    sendMessageMock.mockImplementation(async (msg: unknown) => {574      const type = (msg as { type?: string }).type;575      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };576      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };577      if (type === "GET_ENTRIES") return { ok: true, data: [] };578      return { ok: true, data: null };579    });580581    const onLicenseChangeFromStar = vi.fn();582    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);583    await flushView();584    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();585    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);586    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();587588    const onLicenseChangeFromPeach = vi.fn();589    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);590    await flushView();591    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();592    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);593    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();594  });595596  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {597    sendMessageMock.mockResolvedValue({ ok: true, data: null });598    const fetchMock = vi.fn();599    const requestMock = vi.fn(async () => false);600    vi.stubGlobal("fetch", fetchMock);601    vi.stubGlobal("browser", {602      permissions: {603        getAll: vi.fn(async () => ({ data_collection: [] })),604        request: requestMock,605      },606    });607608    const container = mount(609      <SettingsView610        entriesCount={1}611        breachMonitoring={true}612        lockOnClose={true}613        autoLockMinutes={5}614        trashRetentionDays={30}615        theme="dark"616        accentColor="peach"617        textSize="medium"618        reduceMotion={false}619        initialSection="feedback"620        onBreachMonitoringChange={vi.fn()}621        onLockOnCloseChange={vi.fn()}622        onAutoLockMinutesChange={vi.fn()}623        onTrashRetentionChange={vi.fn()}624        onThemeChange={vi.fn()}625        onAccentChange={vi.fn()}626        onTextSizeChange={vi.fn()}627        onReduceMotionChange={vi.fn()}628        shortcuts={DEFAULT_BINDINGS}629        onShortcutsChange={vi.fn()}630        onBack={vi.fn()}631        onImport={vi.fn()}632        onExport={vi.fn()}633        onBreachCheckDone={vi.fn()}634      />635    );636    await flushView();637638    inputText(639      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,640      "Please improve the feedback flow."641    );642    const sendButton = Array.from(container.querySelectorAll("button")).find(643      (candidate) => candidate.textContent?.trim() === "Send"644    ) as HTMLButtonElement;645    sendButton.click();646647    await vi.waitFor(() => {648      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });649    });650    expect(fetchMock).not.toHaveBeenCalled();651    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();652  });653654  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {655    sendMessageMock.mockImplementation(async (msg: unknown) => {656      const m = msg as { type: string };657      if (m.type === "GET_RECOVERY_WORDS") {658        return {659          ok: true,660          data: [661            "abandon", "ability", "able", "about", "above", "absent",662            "absorb", "abstract", "absurd", "abuse", "access", "accident",663          ],664        };665      }666      return { ok: true, data: null };667    });668    const onAutoLockMinutesChange = vi.fn();669    const onThemeChange = vi.fn();670    const container = mount(671      <SettingsView672        entriesCount={1}673        breachMonitoring={true}674        lockOnClose={true}675        autoLockMinutes={5}676        trashRetentionDays={30}677        theme="dark"678        accentColor="peach"679        textSize="medium"680        reduceMotion={false}681        onBreachMonitoringChange={vi.fn()}682        onLockOnCloseChange={vi.fn()}683        onAutoLockMinutesChange={onAutoLockMinutesChange}684        onTrashRetentionChange={vi.fn()}685        onThemeChange={onThemeChange}686        onAccentChange={vi.fn()}687        onTextSizeChange={vi.fn()}688        onReduceMotionChange={vi.fn()}689        shortcuts={DEFAULT_BINDINGS}690        onShortcutsChange={vi.fn()}691        onBack={vi.fn()}692        onImport={vi.fn()}693        onExport={vi.fn()}694        onBreachCheckDone={vi.fn()}695      />696    );697    await flushView();698    sendMessageMock.mockClear();699700    // ── Auto-lock + theme changes (before password change) ─────────────701    const selects = container.querySelectorAll("select");702    changeSelect(selects[0] as HTMLSelectElement, "15");703    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);704705    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;706    appearanceCard.click();707    await flushView();708    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");709    const themeButton = Array.from(container.querySelectorAll("button")).find(710      (candidate) => candidate.textContent?.trim() === "Switch to light"711    ) as HTMLButtonElement;712    themeButton.click();713    expect(onThemeChange).toHaveBeenCalledWith("light");714715    // ── Password change → recovery words display ────────────────────────716    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;717    protectionCard.click();718    await flushView();719720    const changeButton = Array.from(container.querySelectorAll("button")).find(721      (candidate) => candidate.textContent?.trim() === "change"722    ) as HTMLButtonElement;723    changeButton.click();724    await flushView();725726    const inputs = container.querySelectorAll('input[type="password"]');727    inputText(inputs[0] as HTMLInputElement, "current-pass");728    inputText(inputs[1] as HTMLInputElement, "new-password-123");729    inputText(inputs[2] as HTMLInputElement, "new-password-123");730    await flushView();731732    const saveButton = Array.from(container.querySelectorAll("button")).find(733      (candidate) => candidate.textContent?.trim() === "save new password"734    ) as HTMLButtonElement;735    saveButton.click();736    await flushView();737738    expect(sendMessageMock).toHaveBeenCalledWith({739      type: "CHANGE_PASSWORD",740      payload: {741        currentPassword: "current-pass",742        newPassword: "new-password-123",743      },744    });745746    // After a successful password change the recovery word display replaces settings.747    expect(container.textContent).toContain("save your recovery words");748    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });749  });750751  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {752    sendMessageMock.mockResolvedValue({ ok: true });753    const container = mount(754      <EntryView755        entry={sampleEntry()}756        onDelete={vi.fn()}757        onFieldSaved={vi.fn(async () => undefined)}758      />759    );760761    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;762    passwordButton.click();763    await flushView();764765    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {766      type: "COPY",767      payload: { text: "secret123", label: "password", alreadyWritten: false },768    });769    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {770      type: "FIRE_CANARY_PING",771      payload: {772        webhookUrl: "https://example.com/hook",773        entryTitle: "GitHub",774        trigger: "password_copied",775      },776    });777778    sendMessageMock.mockClear();779    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;780    usernameButton.click();781    await flushView();782783    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {784      type: "COPY",785      payload: { text: "june", label: "username", alreadyWritten: false },786    });787    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {788      type: "FIRE_CANARY_PING",789      payload: {790        webhookUrl: "https://example.com/hook",791        entryTitle: "GitHub",792        trigger: "username_copied",793      },794    });795  });796797  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {798    sendMessageMock.mockResolvedValue({ ok: true });799    const onFieldSaved = vi.fn(async () => undefined);800    const container = mount(801      <EntryView802        entry={sampleEntry()}803        onDelete={vi.fn()}804        onFieldSaved={onFieldSaved}805      />806    );807808    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;809    passwordValue.click();810    await flushView();811812    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;813    inputText(passwordInput, "");814    await flushView();815816    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;817    saveButton.click();818    await flushView();819820    expect(sendMessageMock).not.toHaveBeenCalledWith(821      expect.objectContaining({ type: "UPDATE_ENTRY" })822    );823    expect(onFieldSaved).not.toHaveBeenCalled();824825    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;826    totpMain.click();827828    await vi.waitFor(() => {829      expect(sendMessageMock).toHaveBeenLastCalledWith({830        type: "COPY",831        payload: { text: "123456", label: "totp", alreadyWritten: false },832      });833    }, { timeout: 500 });834  });835836  it("Entry closes an unchanged inline editor when focus leaves it", async () => {837    sendMessageMock.mockResolvedValue({ ok: true });838    const container = mount(839      <EntryView840        entry={sampleEntry()}841        onDelete={vi.fn()}842        onFieldSaved={vi.fn(async () => undefined)}843      />844    );845846    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;847    usernameValue.click();848    await flushView();849850    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;851    usernameInput.blur();852    await flushView();853854    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();855    expect(sendMessageMock).not.toHaveBeenCalledWith(856      expect.objectContaining({ type: "UPDATE_ENTRY" })857    );858  });859860  it("Entry lets TOTP secrets be edited inline", async () => {861    sendMessageMock.mockResolvedValue({ ok: true });862    const onFieldSaved = vi.fn(async () => undefined);863    const container = mount(864      <EntryView865        entry={sampleEntry()}866        onDelete={vi.fn()}867        onFieldSaved={onFieldSaved}868      />869    );870871    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;872    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));873    await flushView();874875    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;876    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");877    await flushView();878879    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;880    saveButton.click();881    await flushView();882883    expect(sendMessageMock).toHaveBeenCalledWith({884      type: "UPDATE_ENTRY",885      payload: {886        id: "entry-1",887        patch: { totp: "NB2W45DFOIZA====" },888      },889    });890    expect(onFieldSaved).toHaveBeenCalled();891  });892893  it("Vault seeds and focuses search when a search seed is provided", async () => {894    const container = mount(895      <VaultView896        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}897        sortOrder="url-first"898        searchSeed="g"899        searchSeedNonce={1}900        onSelectEntry={vi.fn()}901        onSortOrderChange={vi.fn()}902        onAdd={vi.fn()}903        onClose={vi.fn()}904      />905    );906907    await flushView();908909    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;910    expect(search.value).toBe("g");911    expect(document.activeElement).toBe(search);912  });913914  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {915    parseImportSourceMock.mockResolvedValue(916      sampleImportResult({917        entries: [918          {919            type: "login",920            title: "Imported site",921            username: "imported-user",922            password: "imported-pass",923            url: "https://imported.test",924          },925        ],926      })927    );928    sendMessageMock.mockImplementation(async (msg: unknown) => {929      const type = (msg as { type?: string }).type;930      if (type === "GET_ENTRIES") {931        return {932          ok: true,933          data: [934            sampleEntry({935              id: "existing-1",936              title: "Imported site",937              username: "imported-user",938              url: "https://imported.test",939            }),940          ],941        };942      }943      if (type === "BATCH_ADD_ENTRIES") {944        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };945      }946      return { ok: true };947    });948    const onImport = vi.fn(async () => undefined);949    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);950    await flushView();951    sendMessageMock.mockClear();952953    await selectImportFile(container);954    findButton(container, "Import 1").click();955    await flushView();956957    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);958    expect(sendMessageMock).toHaveBeenCalledWith({959      type: "BATCH_ADD_ENTRIES",960      payload: {961        entries: [962          expect.objectContaining({963            title: "Imported site",964            username: "imported-user",965            password: "imported-pass",966            url: "https://imported.test",967          }),968        ],969      },970    });971    expect(onImport).toHaveBeenCalled();972    expect(container.textContent).toContain("1 entry appears to be duplicates");973  });974975  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {976    parseImportSourceMock.mockResolvedValue(sampleImportResult());977    sendMessageMock.mockImplementation(async (msg: unknown) => {978      const type = (msg as { type?: string }).type;979      if (type === "GET_ENTRIES") {980        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };981      }982      if (type === "CLEAR_VAULT_ENTRIES") {983        return { ok: true };984      }985      if (type === "BATCH_ADD_ENTRIES") {986        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };987      }988      return { ok: true };989    });990    const onImport = vi.fn(async () => undefined);991    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);992    await flushView();993    sendMessageMock.mockClear();994995    await selectImportFile(container);996    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();997    await flushView();998999    findButton(container, "Replace vault with 1").click();1000    await flushView();10011002    expect(sendMessageMock).not.toHaveBeenCalledWith(1003      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1004    );1005    expect(container.textContent).toContain("Replace forever? This cannot be undone.");10061007    findButton(container, "Yes, replace forever").click();1008    await flushView();10091010    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1011    expect(sendMessageMock).toHaveBeenCalledWith({1012      type: "BATCH_ADD_ENTRIES",1013      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1014    });1015    expect(onImport).toHaveBeenCalled();1016  });10171018  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1019    parseImportSourceMock.mockResolvedValue(sampleImportResult());1020    sendMessageMock.mockImplementation(async (msg: unknown) => {1021      const type = (msg as { type?: string }).type;1022      if (type === "CREATE_VAULT") return { ok: true };1023      if (type === "BATCH_ADD_ENTRIES") {1024        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1025      }1026      return { ok: true };1027    });1028    const onImport = vi.fn(async () => undefined);1029    const container = mount(1030      <ImportView1031        mode="onboarding"1032        replacesExistingVault={true}1033        onBack={vi.fn()}1034        onImport={onImport}1035      />1036    );10371038    await selectImportFile(container);10391040    const createButton = findButton(container, "Create vault + import 1");1041    expect(createButton.disabled).toBe(true);1042    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1043    expect(container.textContent).toContain("Use at least 8 characters");10441045    const masterInput = container.querySelector(1046      'input[placeholder="new master password"]'1047    ) as HTMLInputElement;1048    const confirmInput = container.querySelector(1049      'input[placeholder="confirm master password"]'1050    ) as HTMLInputElement;1051    const revealButton = container.querySelector(1052      'button[aria-label="Show passwords"]'1053    ) as HTMLButtonElement;1054    expect(revealButton).toBeTruthy();1055    revealButton.click();1056    await flushView();1057    expect(masterInput.type).toBe("text");1058    expect(confirmInput.type).toBe("text");10591060    inputText(masterInput, "fresh-master-password");1061    inputText(confirmInput, "fresh-master-password");1062    await flushView();10631064    expect(createButton.disabled).toBe(false);1065    createButton.click();1066    await flushView();10671068    expect(sendMessageMock).toHaveBeenCalledWith({1069      type: "CREATE_VAULT",1070      payload: { password: "fresh-master-password" },1071    });1072    expect(sendMessageMock).toHaveBeenCalledWith({1073      type: "BATCH_ADD_ENTRIES",1074      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1075    });1076    expect(onImport).toHaveBeenCalled();1077  });10781079  it("Import encrypted backups reparse with the backup password before import", async () => {1080    parseImportSourceMock1081      .mockResolvedValueOnce(1082        sampleImportResult({1083          format: "peach-json",1084          label: "Peach encrypted backup",1085          entries: [],1086          totalItems: 0,1087          requiresPassword: true,1088        })1089      )1090      .mockResolvedValueOnce(1091        sampleImportResult({1092          format: "peach-json",1093          label: "Peach encrypted backup",1094        })1095      );1096    sendMessageMock.mockImplementation(async (msg: unknown) => {1097      const type = (msg as { type?: string }).type;1098      if (type === "GET_ENTRIES") return { ok: true, data: [] };1099      if (type === "BATCH_ADD_ENTRIES") {1100        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1101      }1102      return { ok: true };1103    });1104    const onImport = vi.fn(async () => undefined);1105    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1106    await flushView();1107    sendMessageMock.mockClear();11081109    await selectImportFile(container, '{"type":"peach-export"}');1110    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1111    inputText(password, "backup-passphrase");1112    pressEnter(password);1113    await flushView();1114    await flushView();11151116    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1117    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");11181119    findButton(container, "Import 1").click();1120    await flushView();11211122    expect(sendMessageMock).toHaveBeenCalledWith({1123      type: "BATCH_ADD_ENTRIES",1124      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1125    });1126    expect(onImport).toHaveBeenCalled();1127  });1128});