contracts.test.tsx

39.6 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { RecoveryWordEntryView } from "./RecoveryWordEntry";16import { VaultView } from "./Vault";17import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1819const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();20const triggerRingMock = vi.fn();21const triggerPipPulseMock = vi.fn();22const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();23const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2425vi.mock("../App", () => ({26  sendMessage: (msg: unknown) => sendMessageMock(msg),27  triggerRing: () => triggerRingMock(),28  triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/import", () => ({32  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),33}));3435vi.mock("../../shared/favicon", async (importOriginal) => {36  const actual = await importOriginal<typeof import("../../shared/favicon")>();37  return { ...actual, faviconUrl: () => null };38});3940vi.mock("../../crypto/totp", () => ({41  generateTOTP: () => ({ code: "123 456", remaining: 24 }),42  parseOtpauthURI: (value: string) =>43    value.startsWith("otpauth://")44      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }45      : null,46}));4748vi.mock("../components/GeneratorDrawer", () => ({49  GeneratorDrawer: () => null,50}));5152vi.mock("zxcvbn", () => ({53  default: (value: string) => zxcvbnMock(value),54}));5556describe("popup contract tests", () => {57  const disposers: Array<() => void> = [];5859  beforeEach(() => {60    document.body.innerHTML = "";61    sendMessageMock.mockReset();62    triggerRingMock.mockReset();63    triggerPipPulseMock.mockReset();64    parseImportSourceMock.mockReset();65    zxcvbnMock.mockReset();66    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));67    parseImportSourceMock.mockResolvedValue({68      format: "unknown",69      label: "Unknown format",70      entries: [],71      totalItems: 0,72      skippedItems: 0,73      requiresPassword: false,74    });7576    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {77      return window.setTimeout(() => cb(0), 0) as unknown as number;78    });79    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));80    vi.stubGlobal("URL", {81      createObjectURL: vi.fn(() => "blob:peach-test"),82      revokeObjectURL: vi.fn(),83    });84    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8586    (globalThis as { chrome?: typeof chrome }).chrome = {87      runtime: {88        getManifest: () => ({ version: "1.4.47" }),89        onMessage: { addListener: vi.fn() },90      },91    } as unknown as typeof chrome;92  });9394  afterEach(() => {95    while (disposers.length > 0) {96      disposers.pop()?.();97    }98    vi.restoreAllMocks();99    vi.unstubAllGlobals();100  });101102  function mount(view: JSX.Element) {103    const mounted = renderView(view);104    disposers.push(mounted.dispose);105    return mounted.container;106  }107108  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {109    return {110      id: "entry-1",111      type: "login",112      title: "GitHub",113      username: "june",114      password: "secret123",115      url: "https://github.com",116      totp: "JBSWY3DPEHPK3PXP",117      canary: {118        webhookUrl: "https://example.com/hook",119      },120      createdAt: 1,121      modifiedAt: 1,122      ...overrides,123    };124  }125126  function sampleImportResult(127    overrides: Partial<ImportParseResult> = {}128  ): ImportParseResult {129    return {130      format: "csv",131      label: "CSV import",132      entries: [133        {134          type: "login",135          title: "Imported site",136          username: "imported-user",137          password: "imported-pass",138          url: "https://imported.test",139        },140      ],141      totalItems: 1,142      skippedItems: 0,143      requiresPassword: false,144      ...overrides,145    };146  }147148  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {149    return {150      entriesCount: 1,151      breachMonitoring: true,152      lockOnClose: true,153      autoLockMinutes: 5,154      trashRetentionDays: 30,155      theme: "dark" as const,156      accentColor: "peach" as const,157      textSize: "medium" as const,158      reduceMotion: false,159      onBreachMonitoringChange: vi.fn(),160      onLockOnCloseChange: vi.fn(),161      onAutoLockMinutesChange: vi.fn(),162      onTrashRetentionChange: vi.fn(),163      onThemeChange: vi.fn(),164      onAccentChange: vi.fn(),165      onTextSizeChange: vi.fn(),166      onReduceMotionChange: vi.fn(),167      shortcuts: DEFAULT_BINDINGS,168      onShortcutsChange: vi.fn(),169      onBack: vi.fn(),170      onImport: vi.fn(),171      onExport: vi.fn(),172      onBreachCheckDone: vi.fn(),173      ...overrides,174    };175  }176177  async function selectImportFile(178    container: HTMLElement,179    contents = '{"export":"test"}'180  ) {181    // The Import view now opens onto a menu — click into the file subview182    // before locating the file input. Idempotent: a second click won't re-fire183    // because the menu unmounts once the subview renders.184    const fileCard = findButton(container, "Choose file →");185    if (fileCard) {186      fileCard.click();187      await flushView();188    }189    const input = container.querySelector('input[type="file"]') as HTMLInputElement;190    const file = new File([contents], "import.json", { type: "application/json" });191    Object.defineProperty(input, "files", {192      configurable: true,193      value: [file],194    });195    input.dispatchEvent(new Event("change", { bubbles: true }));196    await flushView();197    await flushView();198  }199200  function findButton(container: HTMLElement, text: string): HTMLButtonElement {201    return Array.from(container.querySelectorAll("button")).find(202      (candidate) => candidate.textContent?.trim() === text203    ) as HTMLButtonElement;204  }205206  it("Lock sends UNLOCK with the entered passphrase", async () => {207    sendMessageMock.mockResolvedValue({ ok: true });208    const onUnlock = vi.fn(async () => undefined);209    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);210211    const password = container.querySelector("#unlock-password") as HTMLInputElement;212    inputText(password, "correct horse battery staple");213    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();214    await flushView();215216    expect(sendMessageMock).toHaveBeenCalledWith({217      type: "UNLOCK",218      payload: { password: "correct horse battery staple" },219    });220    expect(onUnlock).toHaveBeenCalled();221  });222223  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {224    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);225    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;226    const password = container.querySelector("#master-password") as HTMLInputElement;227    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;228229    inputText(password, "short");230    inputText(confirm, "short");231    await flushView();232    expect(createButton.disabled).toBe(true);233234    zxcvbnMock.mockImplementation(() => ({ score: 1 }));235    inputText(password, "weakweakweak");236    inputText(confirm, "weakweakweak");237    await flushView();238    expect(createButton.disabled).toBe(true);239  });240241  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {242    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock243    sendMessageMock.mockImplementation(244      async (msg: unknown) => {245        const m = msg as { type: string; payload?: { password?: string } };246        if (m.type === "CREATE_VAULT") return { ok: true };247        if (m.type === "GET_RECOVERY_WORDS") {248          // Return 12 fake words — must pass findInvalidWords249          return {250            ok: true,251            data: [252              "abandon", "ability", "able", "about", "above", "absent",253              "absorb", "abstract", "absurd", "abuse", "access", "accident",254            ],255          };256        }257        return { ok: true };258      }259    );260    zxcvbnMock.mockImplementation(() => ({ score: 3 }));261    const onUnlock = vi.fn(async () => undefined);262    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);263264    const password = container.querySelector("#master-password") as HTMLInputElement;265    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;266    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;267268    inputText(password, "correct horse battery staple");269    inputText(confirm, "correct horse battery staple");270    await flushView();271    expect(createButton.disabled).toBe(false);272273    pressEnter(confirm);274    await flushView();275276    expect(sendMessageMock).toHaveBeenCalledWith({277      type: "CREATE_VAULT",278      payload: { password: "correct horse battery staple" },279    });280    // After CREATE_VAULT success, the recovery word display is shown.281    // onUnlock is NOT called yet — user must first confirm their recovery words.282    expect(onUnlock).not.toHaveBeenCalled();283  });284285  it("Recovery words flow submits the new master password and completes", async () => {286    sendMessageMock.mockImplementation(async (msg: unknown) => {287      const type = (msg as { type?: string }).type;288      if (type === "VALIDATE_RECOVERY_WORDS") {289        return { ok: true, data: { valid: true, invalid: [] } };290      }291      if (type === "RECOVER_WITH_WORDS") return { ok: true };292      return { ok: true };293    });294    const canvasContext = {295      clearRect: vi.fn(),296      fillText: vi.fn(),297      beginPath: vi.fn(),298      closePath: vi.fn(),299      arc: vi.fn(),300      fill: vi.fn(),301      stroke: vi.fn(),302      save: vi.fn(),303      restore: vi.fn(),304      translate: vi.fn(),305      rotate: vi.fn(),306      scale: vi.fn(),307      drawImage: vi.fn(),308      measureText: vi.fn(() => ({ width: 0 })),309      setTransform: vi.fn(),310      fillRect: vi.fn(),311      strokeRect: vi.fn(),312    } as unknown as CanvasRenderingContext2D;313    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);314315    const onRecoveryComplete = vi.fn(async () => undefined);316    const container = mount(317      <RecoveryWordEntryView318        onCancel={vi.fn()}319        onRecoveryComplete={onRecoveryComplete}320      />321    );322323    const recoveryWords = WORD_LIST.slice(0, 12);324    for (let i = 0; i < recoveryWords.length; i++) {325      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;326      inputText(input, recoveryWords[i]!);327    }328    await flushView();329330    findButton(container, "verify words").click();331    await flushView();332333    expect(container.textContent).toContain("new master password");334335    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;336    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;337    inputText(newPassword, "fresh-recovery-password");338    inputText(confirmPassword, "fresh-recovery-password");339    await flushView();340341    findButton(container, "recover vault").click();342    await flushView();343344    expect(sendMessageMock).toHaveBeenCalledWith({345      type: "RECOVER_WITH_WORDS",346      payload: {347        words: recoveryWords,348        newPassword: "fresh-recovery-password",349      },350    });351    expect(onRecoveryComplete).toHaveBeenCalled();352  });353354  it("Export menu surfaces both backup formats", async () => {355    const container = mount(<ExportView onBack={vi.fn()} />);356    await flushView();357358    expect(container.textContent).toContain("PeachScript");359    expect(container.textContent).toContain("CSV");360  });361362  it("Lock onboarding restore options include Peach Codex", async () => {363    const onOpenCodexRestore = vi.fn();364    const container = mount(365      <LockView366        hasVault={false}367        ready={true}368        onUnlock={vi.fn()}369        onOpenCodexRestore={onOpenCodexRestore}370      />371    );372373    const restoreButton = Array.from(container.querySelectorAll("button")).find(374      (candidate) => candidate.textContent?.includes("restore from backup")375    ) as HTMLButtonElement;376    restoreButton.click();377    await flushView();378379    expect(container.textContent).toContain("PeachScript");380    expect(container.textContent).toContain("Codex");381    expect(container.textContent).toContain("CSV");382383    const codexTab = Array.from(container.querySelectorAll("button")).find(384      (candidate) => candidate.textContent?.trim() === "Codex"385    ) as HTMLButtonElement;386    codexTab.click();387    await flushView();388389    expect(container.textContent).toContain("Restore from Peach Codex");390    expect(container.textContent).toContain("Scan printed Codex pages");391392    const openCodexButton = Array.from(container.querySelectorAll("button")).find(393      (candidate) => candidate.textContent?.trim() === "open Codex restore →"394    ) as HTMLButtonElement;395    openCodexButton.click();396397    expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);398  });399400  it("Settings requires current, new, and confirm passwords before enabling save", async () => {401    sendMessageMock.mockResolvedValue({ ok: true, data: null });402    const container = mount(403      <SettingsView404        entriesCount={1}405        breachMonitoring={true}406        lockOnClose={true}407        autoLockMinutes={5}408        trashRetentionDays={30}409        theme="dark"410        accentColor="peach"411        textSize="medium"412        reduceMotion={false}413        onBreachMonitoringChange={vi.fn()}414        onLockOnCloseChange={vi.fn()}415        onAutoLockMinutesChange={vi.fn()}416        onTrashRetentionChange={vi.fn()}417        onThemeChange={vi.fn()}418        onAccentChange={vi.fn()}419        onTextSizeChange={vi.fn()}420        onReduceMotionChange={vi.fn()}421        shortcuts={DEFAULT_BINDINGS}422        onShortcutsChange={vi.fn()}423        onBack={vi.fn()}424        onImport={vi.fn()}425        onExport={vi.fn()}426        onBreachCheckDone={vi.fn()}427      />428    );429    await flushView();430431    expect(container.querySelectorAll(".stk-card").length).toBe(7);432    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]433    // "open settings to card I (Peach Pro) instead of card II (Protection)".434    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");435436    const changeButton = Array.from(container.querySelectorAll("button")).find(437      (candidate) => candidate.textContent?.trim() === "change"438    ) as HTMLButtonElement;439    changeButton.click();440    await flushView();441442    const saveButton = Array.from(container.querySelectorAll("button")).find(443      (candidate) => candidate.textContent?.trim() === "save new password"444    ) as HTMLButtonElement;445    expect(saveButton.disabled).toBe(true);446447    const inputs = container.querySelectorAll('input[type="password"]');448    inputText(inputs[0] as HTMLInputElement, "current-pass");449    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");450    await flushView();451    expect(saveButton.disabled).toBe(true);452453    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");454    await flushView();455    expect(saveButton.disabled).toBe(false);456  });457458  it("Settings feedback highlights the selected category and hides the form after submit", async () => {459    sendMessageMock.mockResolvedValue({ ok: true, data: null });460    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({461      ok: true,462      json: async () => ({ success: true }),463    }));464    vi.stubGlobal("fetch", fetchMock);465466    const container = mount(467      <SettingsView468        entriesCount={1}469        breachMonitoring={true}470        lockOnClose={true}471        autoLockMinutes={5}472        trashRetentionDays={30}473        theme="dark"474        accentColor="peach"475        textSize="medium"476        reduceMotion={false}477        initialSection="feedback"478        onBreachMonitoringChange={vi.fn()}479        onLockOnCloseChange={vi.fn()}480        onAutoLockMinutesChange={vi.fn()}481        onTrashRetentionChange={vi.fn()}482        onThemeChange={vi.fn()}483        onAccentChange={vi.fn()}484        onTextSizeChange={vi.fn()}485        onReduceMotionChange={vi.fn()}486        shortcuts={DEFAULT_BINDINGS}487        onShortcutsChange={vi.fn()}488        onBack={vi.fn()}489        onImport={vi.fn()}490        onExport={vi.fn()}491        onBreachCheckDone={vi.fn()}492      />493    );494    await flushView();495496    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;497    expect(feedbackCard.classList.contains("is-active")).toBe(true);498499    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];500    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;501    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;502    expect(bugButton.getAttribute("aria-pressed")).toBe("true");503    expect(featureButton.getAttribute("aria-pressed")).toBe("false");504505    featureButton.click();506    await flushView();507508    expect(bugButton.getAttribute("aria-pressed")).toBe("false");509    expect(featureButton.getAttribute("aria-pressed")).toBe("true");510    expect(featureButton.classList.contains("is-active")).toBe(true);511512    inputText(513      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,514      '<script>alert("xss")</script> Please make this easier to see & safer.'515    );516    const sendButton = Array.from(container.querySelectorAll("button")).find(517      (candidate) => candidate.textContent?.trim() === "Send"518    ) as HTMLButtonElement;519    sendButton.click();520521    await vi.waitFor(() => {522      expect(container.textContent).toContain("Thank you");523    });524525    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));526    expect(requestBody.subject).toContain("Feature request");527    expect(requestBody.message).toBe(528      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."529    );530    expect(requestBody.message).not.toContain("<script>");531    expect(container.textContent).toContain("Someone will process it soon");532    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();533    expect(container.querySelector(".peach-feedback-submit")).toBeNull();534  });535536  it("Settings keeps license activation on the full-screen celebration path", async () => {537    const onLicenseChange = vi.fn(async () => undefined);538    sendMessageMock.mockImplementation(async (msg: unknown) => {539      const type = (msg as { type?: string }).type;540      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };541      if (type === "ACTIVATE_LICENSE") {542        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };543      }544      if (type === "GET_ENTRIES") return { ok: true, data: [] };545      return { ok: true, data: null };546    });547548    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);549    await flushView();550551    inputText(552      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,553      "PEACH-TEST-KEY",554    );555    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;556    apply.click();557558    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));559    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);560    expect(container.textContent).not.toContain("thank you ✦");561  });562563  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {564    sendMessageMock.mockImplementation(async (msg: unknown) => {565      const type = (msg as { type?: string }).type;566      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };567      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };568      if (type === "GET_ENTRIES") return { ok: true, data: [] };569      return { ok: true, data: null };570    });571572    const onLicenseChangeFromStar = vi.fn();573    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);574    await flushView();575    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();576    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);577    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();578579    const onLicenseChangeFromPeach = vi.fn();580    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);581    await flushView();582    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();583    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);584    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();585  });586587  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {588    sendMessageMock.mockResolvedValue({ ok: true, data: null });589    const fetchMock = vi.fn();590    const requestMock = vi.fn(async () => false);591    vi.stubGlobal("fetch", fetchMock);592    vi.stubGlobal("browser", {593      permissions: {594        getAll: vi.fn(async () => ({ data_collection: [] })),595        request: requestMock,596      },597    });598599    const container = mount(600      <SettingsView601        entriesCount={1}602        breachMonitoring={true}603        lockOnClose={true}604        autoLockMinutes={5}605        trashRetentionDays={30}606        theme="dark"607        accentColor="peach"608        textSize="medium"609        reduceMotion={false}610        initialSection="feedback"611        onBreachMonitoringChange={vi.fn()}612        onLockOnCloseChange={vi.fn()}613        onAutoLockMinutesChange={vi.fn()}614        onTrashRetentionChange={vi.fn()}615        onThemeChange={vi.fn()}616        onAccentChange={vi.fn()}617        onTextSizeChange={vi.fn()}618        onReduceMotionChange={vi.fn()}619        shortcuts={DEFAULT_BINDINGS}620        onShortcutsChange={vi.fn()}621        onBack={vi.fn()}622        onImport={vi.fn()}623        onExport={vi.fn()}624        onBreachCheckDone={vi.fn()}625      />626    );627    await flushView();628629    inputText(630      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,631      "Please improve the feedback flow."632    );633    const sendButton = Array.from(container.querySelectorAll("button")).find(634      (candidate) => candidate.textContent?.trim() === "Send"635    ) as HTMLButtonElement;636    sendButton.click();637638    await vi.waitFor(() => {639      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });640    });641    expect(fetchMock).not.toHaveBeenCalled();642    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();643  });644645  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {646    sendMessageMock.mockImplementation(async (msg: unknown) => {647      const m = msg as { type: string };648      if (m.type === "GET_RECOVERY_WORDS") {649        return {650          ok: true,651          data: [652            "abandon", "ability", "able", "about", "above", "absent",653            "absorb", "abstract", "absurd", "abuse", "access", "accident",654          ],655        };656      }657      return { ok: true, data: null };658    });659    const onAutoLockMinutesChange = vi.fn();660    const onThemeChange = vi.fn();661    const container = mount(662      <SettingsView663        entriesCount={1}664        breachMonitoring={true}665        lockOnClose={true}666        autoLockMinutes={5}667        trashRetentionDays={30}668        theme="dark"669        accentColor="peach"670        textSize="medium"671        reduceMotion={false}672        onBreachMonitoringChange={vi.fn()}673        onLockOnCloseChange={vi.fn()}674        onAutoLockMinutesChange={onAutoLockMinutesChange}675        onTrashRetentionChange={vi.fn()}676        onThemeChange={onThemeChange}677        onAccentChange={vi.fn()}678        onTextSizeChange={vi.fn()}679        onReduceMotionChange={vi.fn()}680        shortcuts={DEFAULT_BINDINGS}681        onShortcutsChange={vi.fn()}682        onBack={vi.fn()}683        onImport={vi.fn()}684        onExport={vi.fn()}685        onBreachCheckDone={vi.fn()}686      />687    );688    await flushView();689    sendMessageMock.mockClear();690691    // ── Auto-lock + theme changes (before password change) ─────────────692    const selects = container.querySelectorAll("select");693    changeSelect(selects[0] as HTMLSelectElement, "15");694    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);695696    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;697    appearanceCard.click();698    await flushView();699    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");700    const themeButton = Array.from(container.querySelectorAll("button")).find(701      (candidate) => candidate.textContent?.trim() === "Switch to light"702    ) as HTMLButtonElement;703    themeButton.click();704    expect(onThemeChange).toHaveBeenCalledWith("light");705706    // ── Password change → recovery words display ────────────────────────707    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;708    protectionCard.click();709    await flushView();710711    const changeButton = Array.from(container.querySelectorAll("button")).find(712      (candidate) => candidate.textContent?.trim() === "change"713    ) as HTMLButtonElement;714    changeButton.click();715    await flushView();716717    const inputs = container.querySelectorAll('input[type="password"]');718    inputText(inputs[0] as HTMLInputElement, "current-pass");719    inputText(inputs[1] as HTMLInputElement, "new-password-123");720    inputText(inputs[2] as HTMLInputElement, "new-password-123");721    await flushView();722723    const saveButton = Array.from(container.querySelectorAll("button")).find(724      (candidate) => candidate.textContent?.trim() === "save new password"725    ) as HTMLButtonElement;726    saveButton.click();727    await flushView();728729    expect(sendMessageMock).toHaveBeenCalledWith({730      type: "CHANGE_PASSWORD",731      payload: {732        currentPassword: "current-pass",733        newPassword: "new-password-123",734      },735    });736737    // After a successful password change the recovery word display replaces settings.738    expect(container.textContent).toContain("save your recovery words");739    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });740  });741742  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {743    sendMessageMock.mockResolvedValue({ ok: true });744    const container = mount(745      <EntryView746        entry={sampleEntry()}747        onDelete={vi.fn()}748        onFieldSaved={vi.fn(async () => undefined)}749      />750    );751752    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;753    passwordButton.click();754    await flushView();755756    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {757      type: "COPY",758      payload: { text: "secret123", label: "password", alreadyWritten: false },759    });760    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {761      type: "FIRE_CANARY_PING",762      payload: {763        webhookUrl: "https://example.com/hook",764        entryTitle: "GitHub",765        trigger: "password_copied",766      },767    });768769    sendMessageMock.mockClear();770    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;771    usernameButton.click();772    await flushView();773774    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {775      type: "COPY",776      payload: { text: "june", label: "username", alreadyWritten: false },777    });778    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {779      type: "FIRE_CANARY_PING",780      payload: {781        webhookUrl: "https://example.com/hook",782        entryTitle: "GitHub",783        trigger: "username_copied",784      },785    });786  });787788  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {789    sendMessageMock.mockResolvedValue({ ok: true });790    const onFieldSaved = vi.fn(async () => undefined);791    const container = mount(792      <EntryView793        entry={sampleEntry()}794        onDelete={vi.fn()}795        onFieldSaved={onFieldSaved}796      />797    );798799    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;800    passwordValue.click();801    await flushView();802803    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;804    inputText(passwordInput, "");805    await flushView();806807    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;808    saveButton.click();809    await flushView();810811    expect(sendMessageMock).not.toHaveBeenCalledWith(812      expect.objectContaining({ type: "UPDATE_ENTRY" })813    );814    expect(onFieldSaved).not.toHaveBeenCalled();815816    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;817    totpMain.click();818819    await vi.waitFor(() => {820      expect(sendMessageMock).toHaveBeenLastCalledWith({821        type: "COPY",822        payload: { text: "123456", label: "totp", alreadyWritten: false },823      });824    }, { timeout: 500 });825  });826827  it("Entry closes an unchanged inline editor when focus leaves it", async () => {828    sendMessageMock.mockResolvedValue({ ok: true });829    const container = mount(830      <EntryView831        entry={sampleEntry()}832        onDelete={vi.fn()}833        onFieldSaved={vi.fn(async () => undefined)}834      />835    );836837    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;838    usernameValue.click();839    await flushView();840841    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;842    usernameInput.blur();843    await flushView();844845    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();846    expect(sendMessageMock).not.toHaveBeenCalledWith(847      expect.objectContaining({ type: "UPDATE_ENTRY" })848    );849  });850851  it("Entry lets TOTP secrets be edited inline", async () => {852    sendMessageMock.mockResolvedValue({ ok: true });853    const onFieldSaved = vi.fn(async () => undefined);854    const container = mount(855      <EntryView856        entry={sampleEntry()}857        onDelete={vi.fn()}858        onFieldSaved={onFieldSaved}859      />860    );861862    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;863    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));864    await flushView();865866    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;867    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");868    await flushView();869870    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;871    saveButton.click();872    await flushView();873874    expect(sendMessageMock).toHaveBeenCalledWith({875      type: "UPDATE_ENTRY",876      payload: {877        id: "entry-1",878        patch: { totp: "NB2W45DFOIZA====" },879      },880    });881    expect(onFieldSaved).toHaveBeenCalled();882  });883884  it("Vault seeds and focuses search when a search seed is provided", async () => {885    const container = mount(886      <VaultView887        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}888        sortOrder="url-first"889        searchSeed="g"890        searchSeedNonce={1}891        onSelectEntry={vi.fn()}892        onSortOrderChange={vi.fn()}893        onAdd={vi.fn()}894        onClose={vi.fn()}895      />896    );897898    await flushView();899900    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;901    expect(search.value).toBe("g");902    expect(document.activeElement).toBe(search);903  });904905  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {906    parseImportSourceMock.mockResolvedValue(907      sampleImportResult({908        entries: [909          {910            type: "login",911            title: "Imported site",912            username: "imported-user",913            password: "imported-pass",914            url: "https://imported.test",915          },916        ],917      })918    );919    sendMessageMock.mockImplementation(async (msg: unknown) => {920      const type = (msg as { type?: string }).type;921      if (type === "GET_ENTRIES") {922        return {923          ok: true,924          data: [925            sampleEntry({926              id: "existing-1",927              title: "Imported site",928              username: "imported-user",929              url: "https://imported.test",930            }),931          ],932        };933      }934      if (type === "BATCH_ADD_ENTRIES") {935        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };936      }937      return { ok: true };938    });939    const onImport = vi.fn(async () => undefined);940    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);941    await flushView();942    sendMessageMock.mockClear();943944    await selectImportFile(container);945    findButton(container, "Import 1").click();946    await flushView();947948    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);949    expect(sendMessageMock).toHaveBeenCalledWith({950      type: "BATCH_ADD_ENTRIES",951      payload: {952        entries: [953          expect.objectContaining({954            title: "Imported site",955            username: "imported-user",956            password: "imported-pass",957            url: "https://imported.test",958          }),959        ],960      },961    });962    expect(onImport).toHaveBeenCalled();963    expect(container.textContent).toContain("1 entry appears to be duplicates");964  });965966  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {967    parseImportSourceMock.mockResolvedValue(sampleImportResult());968    sendMessageMock.mockImplementation(async (msg: unknown) => {969      const type = (msg as { type?: string }).type;970      if (type === "GET_ENTRIES") {971        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };972      }973      if (type === "CLEAR_VAULT_ENTRIES") {974        return { ok: true };975      }976      if (type === "BATCH_ADD_ENTRIES") {977        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };978      }979      return { ok: true };980    });981    const onImport = vi.fn(async () => undefined);982    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);983    await flushView();984    sendMessageMock.mockClear();985986    await selectImportFile(container);987    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();988    await flushView();989990    findButton(container, "Replace vault with 1").click();991    await flushView();992993    expect(sendMessageMock).not.toHaveBeenCalledWith(994      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })995    );996    expect(container.textContent).toContain("Replace forever? This cannot be undone.");997998    findButton(container, "Yes, replace forever").click();999    await flushView();10001001    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1002    expect(sendMessageMock).toHaveBeenCalledWith({1003      type: "BATCH_ADD_ENTRIES",1004      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1005    });1006    expect(onImport).toHaveBeenCalled();1007  });10081009  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1010    parseImportSourceMock.mockResolvedValue(sampleImportResult());1011    sendMessageMock.mockImplementation(async (msg: unknown) => {1012      const type = (msg as { type?: string }).type;1013      if (type === "CREATE_VAULT") return { ok: true };1014      if (type === "BATCH_ADD_ENTRIES") {1015        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1016      }1017      return { ok: true };1018    });1019    const onImport = vi.fn(async () => undefined);1020    const container = mount(1021      <ImportView1022        mode="onboarding"1023        replacesExistingVault={true}1024        onBack={vi.fn()}1025        onImport={onImport}1026      />1027    );10281029    await selectImportFile(container);10301031    const createButton = findButton(container, "Create vault + import 1");1032    expect(createButton.disabled).toBe(true);1033    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1034    expect(container.textContent).toContain("Use at least 8 characters");10351036    const masterInput = container.querySelector(1037      'input[placeholder="new master password"]'1038    ) as HTMLInputElement;1039    const confirmInput = container.querySelector(1040      'input[placeholder="confirm master password"]'1041    ) as HTMLInputElement;1042    const revealButton = container.querySelector(1043      'button[aria-label="Show passwords"]'1044    ) as HTMLButtonElement;1045    expect(revealButton).toBeTruthy();1046    revealButton.click();1047    await flushView();1048    expect(masterInput.type).toBe("text");1049    expect(confirmInput.type).toBe("text");10501051    inputText(masterInput, "fresh-master-password");1052    inputText(confirmInput, "fresh-master-password");1053    await flushView();10541055    expect(createButton.disabled).toBe(false);1056    createButton.click();1057    await flushView();10581059    expect(sendMessageMock).toHaveBeenCalledWith({1060      type: "CREATE_VAULT",1061      payload: { password: "fresh-master-password" },1062    });1063    expect(sendMessageMock).toHaveBeenCalledWith({1064      type: "BATCH_ADD_ENTRIES",1065      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1066    });1067    expect(onImport).toHaveBeenCalled();1068  });10691070  it("Import encrypted backups reparse with the backup password before import", async () => {1071    parseImportSourceMock1072      .mockResolvedValueOnce(1073        sampleImportResult({1074          format: "peach-json",1075          label: "Peach encrypted backup",1076          entries: [],1077          totalItems: 0,1078          requiresPassword: true,1079        })1080      )1081      .mockResolvedValueOnce(1082        sampleImportResult({1083          format: "peach-json",1084          label: "Peach encrypted backup",1085        })1086      );1087    sendMessageMock.mockImplementation(async (msg: unknown) => {1088      const type = (msg as { type?: string }).type;1089      if (type === "GET_ENTRIES") return { ok: true, data: [] };1090      if (type === "BATCH_ADD_ENTRIES") {1091        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1092      }1093      return { ok: true };1094    });1095    const onImport = vi.fn(async () => undefined);1096    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1097    await flushView();1098    sendMessageMock.mockClear();10991100    await selectImportFile(container, '{"type":"peach-export"}');1101    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1102    inputText(password, "backup-passphrase");1103    pressEnter(password);1104    await flushView();1105    await flushView();11061107    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1108    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");11091110    findButton(container, "Import 1").click();1111    await flushView();11121113    expect(sendMessageMock).toHaveBeenCalledWith({1114      type: "BATCH_ADD_ENTRIES",1115      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1116    });1117    expect(onImport).toHaveBeenCalled();1118  });1119});