contracts.test.tsx

57.2 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 { AddView } from "./Add";16import { NotesEditor } from "./NotesEditor";17import { RecoveryWordEntryView } from "./RecoveryWordEntry";18import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";19import { VaultView } from "./Vault";20import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2122const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();23const triggerRingMock = vi.fn();24const triggerPipPulseMock = vi.fn();25const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();26const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2728vi.mock("../App", () => ({29  sendMessage: (msg: unknown) => sendMessageMock(msg),30  triggerRing: () => triggerRingMock(),31  triggerPipPulse: () => triggerPipPulseMock(),32}));3334vi.mock("../../shared/bridge", () => ({35  sendMessage: (msg: unknown) => sendMessageMock(msg),36}));3738vi.mock("../../shared/import", () => ({39  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),40}));4142vi.mock("../../shared/favicon", async (importOriginal) => {43  const actual = await importOriginal<typeof import("../../shared/favicon")>();44  return { ...actual, faviconUrl: () => null };45});4647vi.mock("../../crypto/totp", () => ({48  generateTOTP: () => ({ code: "123 456", remaining: 24 }),49  parseOtpauthURI: (value: string) =>50    value.startsWith("otpauth://")51      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }52      : null,53}));5455vi.mock("../components/GeneratorDrawer", () => ({56  GeneratorDrawer: () => null,57}));5859vi.mock("../components/RuneBackground", () => ({60  RuneBackground: () => null,61}));6263vi.mock("@zxcvbn-ts/core", () => ({64  zxcvbn: (value: string) => zxcvbnMock(value),65  zxcvbnOptions: { setOptions: vi.fn() },66}));67vi.mock("@zxcvbn-ts/language-common", () => ({68  adjacencyGraphs: {},69  dictionary: {},70}));71vi.mock("@zxcvbn-ts/language-en", () => ({72  translations: {},73  dictionary: {},74}));7576describe("popup contract tests", () => {77  const disposers: Array<() => void> = [];7879  beforeEach(() => {80    document.body.innerHTML = "";81    sendMessageMock.mockReset();82    triggerRingMock.mockReset();83    triggerPipPulseMock.mockReset();84    parseImportSourceMock.mockReset();85    zxcvbnMock.mockReset();86    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));87    parseImportSourceMock.mockResolvedValue({88      format: "unknown",89      label: "Unknown format",90      entries: [],91      totalItems: 0,92      skippedItems: 0,93    });9495    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {96      return window.setTimeout(() => cb(0), 0) as unknown as number;97    });98    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));99    vi.stubGlobal("URL", {100      createObjectURL: vi.fn(() => "blob:peach-test"),101      revokeObjectURL: vi.fn(),102    });103    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});104105    (globalThis as { chrome?: typeof chrome }).chrome = {106      runtime: {107        getManifest: () => ({ version: "1.4.47" }),108        onMessage: { addListener: vi.fn() },109      },110    } as unknown as typeof chrome;111  });112113  afterEach(() => {114    while (disposers.length > 0) {115      disposers.pop()?.();116    }117    vi.restoreAllMocks();118    vi.unstubAllGlobals();119  });120121  function mount(view: JSX.Element | (() => JSX.Element)) {122    const mounted = renderView(view);123    disposers.push(mounted.dispose);124    return mounted.container;125  }126127  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {128    return {129      id: "entry-1",130      type: "login",131      title: "GitHub",132      username: "june",133      password: "secret123",134      url: "https://github.com",135      totp: "JBSWY3DPEHPK3PXP",136      canary: {137        webhookUrl: "https://example.com/hook",138      },139      createdAt: 1,140      modifiedAt: 1,141      ...overrides,142    };143  }144145  function sampleImportResult(146    overrides: Partial<ImportParseResult> = {}147  ): ImportParseResult {148    return {149      format: "csv",150      label: "CSV import",151      entries: [152        {153          type: "login",154          title: "Imported site",155          username: "imported-user",156          password: "imported-pass",157          url: "https://imported.test",158        },159      ],160      totalItems: 1,161      skippedItems: 0,162      ...overrides,163    };164  }165166  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {167    return {168      entriesCount: 1,169      breachMonitoring: true,170      lockOnClose: true,171      autoLockMinutes: 5,172      trashRetentionDays: 30,173      theme: "dark" as const,174      accentColor: "peach" as const,175      textSize: "medium" as const,176      reduceMotion: false,177      onBreachMonitoringChange: vi.fn(),178      onLockOnCloseChange: vi.fn(),179      onAutoLockMinutesChange: vi.fn(),180      onTrashRetentionChange: vi.fn(),181      onThemeChange: vi.fn(),182      onAccentChange: vi.fn(),183      onTextSizeChange: vi.fn(),184      onReduceMotionChange: vi.fn(),185      shortcuts: DEFAULT_BINDINGS,186      onShortcutsChange: vi.fn(),187      onBack: vi.fn(),188      onImport: vi.fn(),189      onExport: vi.fn(),190      onBreachCheckDone: vi.fn(),191      ...overrides,192    };193  }194195  async function selectImportFile(196    container: HTMLElement,197    contents = '{"export":"test"}'198  ) {199    // The Import view now opens onto a menu — click into the file subview200    // before locating the file input. Idempotent: a second click won't re-fire201    // because the menu unmounts once the subview renders.202    const fileCard = findButton(container, "Choose file →");203    if (fileCard) {204      fileCard.click();205      await flushView();206    }207    const input = container.querySelector('input[type="file"]') as HTMLInputElement;208    const file = new File([contents], "import.json", { type: "application/json" });209    Object.defineProperty(input, "files", {210      configurable: true,211      value: [file],212    });213    input.dispatchEvent(new Event("change", { bubbles: true }));214    await flushView();215    await flushView();216  }217218  function findButton(container: HTMLElement, text: string): HTMLButtonElement {219    return Array.from(container.querySelectorAll("button")).find(220      (candidate) => candidate.textContent?.trim() === text221    ) as HTMLButtonElement;222  }223224  it("Lock sends UNLOCK with the entered passphrase", async () => {225    sendMessageMock.mockResolvedValue({ ok: true });226    const onUnlock = vi.fn(async () => undefined);227    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);228229    const password = container.querySelector("#unlock-password") as HTMLInputElement;230    inputText(password, "correct horse battery staple");231    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();232    await flushView();233234    expect(sendMessageMock).toHaveBeenCalledWith({235      type: "UNLOCK",236      payload: { password: "correct horse battery staple" },237    });238    expect(onUnlock).toHaveBeenCalled();239  });240241  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {242    sendMessageMock.mockImplementation(async (msg: unknown) => {243      return (msg as { type: string }).type === "GET_SETTINGS"244        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }245        : { ok: true };246    });247    const onUnlock = vi.fn(async () => undefined);248    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);249    await flushView();250251    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");252    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();253    await flushView();254255    expect(sendMessageMock).toHaveBeenCalledWith({256      type: "UNLOCK_WITH_PIN",257      payload: { pin: "1234" },258    });259    expect(onUnlock).toHaveBeenCalled();260    expect(container.textContent).toContain("use master password");261  });262263  it("places the PIN method switch below the unlock button", async () => {264    sendMessageMock.mockImplementation(async (msg: unknown) => {265      return (msg as { type: string }).type === "GET_SETTINGS"266        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }267        : { ok: true };268    });269    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);270    await flushView();271272    const buttons = Array.from(container.querySelectorAll("button"));273    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");274    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");275    expect(pinIndex).toBeGreaterThan(unlockIndex);276  });277278  it("keeps standalone note creation in Notes mode", async () => {279    const chromeApi = globalThis.chrome as unknown as {280      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };281    };282    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };283284    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);285    await flushView();286287    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))288      .toEqual(["login", "card"]);289    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();290  });291292  it("gives Notes mode the full note-formatting toolbar", async () => {293    const container = mount(294      <NotesEditor295        note={null}296        onBack={vi.fn()}297        onSaved={vi.fn()}298        onDelete={vi.fn()}299      />300    );301    await flushView();302303    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();304    expect(container.querySelector('[title="Divider"]')).toBeTruthy();305    expect(container.querySelector('[title="Quote"]')).toBeTruthy();306    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);307    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);308    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");309  });310311  it("saves formatting applied by the Notes toolbar before leaving", async () => {312    const savedNote = {313      id: "note-1",314      type: "note" as const,315      title: "Untitled",316      notes: "<b>formatted</b>",317      createdAt: 1,318      modifiedAt: 2,319    };320    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });321    const originalExecCommand = document.execCommand;322    const execCommandMock = vi.fn(() => {323      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");324      if (body) body.innerHTML = "<b>formatted</b>";325      return true;326    });327    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });328329    try {330      const container = mount(331        <NotesEditor332          note={null}333          onBack={vi.fn()}334          onSaved={vi.fn()}335          onDelete={vi.fn()}336        />337      );338      await flushView();339      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(340        new MouseEvent("mousedown", { bubbles: true, cancelable: true })341      );342      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);343      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();344      await flushView();345346      expect(sendMessageMock).toHaveBeenCalledWith({347        type: "SAVE_NOTE",348        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },349      });350    } finally {351      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });352    }353  });354355  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {356    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);357    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;358    const password = container.querySelector("#master-password") as HTMLInputElement;359    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;360361    inputText(password, "short");362    inputText(confirm, "short");363    await flushView();364    expect(createButton.disabled).toBe(true);365366    zxcvbnMock.mockImplementation(() => ({ score: 1 }));367    inputText(password, "weakweakweak");368    inputText(confirm, "weakweakweak");369    await flushView();370    expect(createButton.disabled).toBe(true);371  });372373  it("Lock create-vault header pins the wordmark left with back on the right", async () => {374    const container = mount(375      <LockView376        hasVault={false}377        ready={true}378        onUnlock={vi.fn()}379        onBackToOnboarding={() => {}}380      />381    );382    const header = container.querySelector(".peach-newvault-header") as HTMLElement;383    const wordmark = header.querySelector(".peach-wordmark-stack");384    const back = header.querySelector(".peach-newvault-back");385    expect(wordmark).toBeTruthy();386    expect(back).toBeTruthy();387    // Wordmark first so it never shifts between onboarding screens; back388    // takes the opposite corner instead of displacing it.389    expect(header.firstElementChild).toBe(wordmark);390    expect(header.lastElementChild).toBe(back);391    await flushView();392  });393394  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {395    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock396    sendMessageMock.mockImplementation(397      async (msg: unknown) => {398        const m = msg as { type: string; payload?: { password?: string } };399        if (m.type === "CREATE_VAULT") return { ok: true };400        if (m.type === "GET_RECOVERY_WORDS") {401          // Return 12 fake words — must pass findInvalidWords402          return {403            ok: true,404            data: [405              "abandon", "ability", "able", "about", "above", "absent",406              "absorb", "abstract", "absurd", "abuse", "access", "accident",407            ],408          };409        }410        return { ok: true };411      }412    );413    zxcvbnMock.mockImplementation(() => ({ score: 3 }));414    const onUnlock = vi.fn(async () => undefined);415    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);416417    const password = container.querySelector("#master-password") as HTMLInputElement;418    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;419    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;420421    inputText(password, "correct horse battery staple");422    inputText(confirm, "correct horse battery staple");423    await flushView();424    expect(createButton.disabled).toBe(false);425426    pressEnter(confirm);427    await flushView();428429    expect(sendMessageMock).toHaveBeenCalledWith({430      type: "CREATE_VAULT",431      payload: { password: "correct horse battery staple" },432    });433    // After CREATE_VAULT success, the recovery word display is shown.434    // onUnlock is NOT called yet — user must first confirm their recovery words.435    expect(onUnlock).not.toHaveBeenCalled();436  });437438  it("recovery display asks for three distinct randomized word positions and confirms", async () => {439    const words12 = [440      "abandon", "ability", "able", "about", "above", "absent",441      "absorb", "abstract", "absurd", "abuse", "access", "accident",442    ];443    sendMessageMock.mockImplementation(async (msg: unknown) => {444      const type = (msg as { type?: string }).type;445      if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };446      return { ok: true };447    });448449    vi.useFakeTimers();450    const onDone = vi.fn();451    try {452      const container = mount(<RecoveryWordDisplayView onDone={onDone} />);453      // onMount fires GET_RECOVERY_WORDS; let the resolved promise + render settle454      await vi.advanceTimersByTimeAsync(0);455456      const prompts = Array.from(457        container.querySelectorAll(".peach-recovery-confirm-num")458      ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));459      expect(prompts).toHaveLength(3);460      expect(new Set(prompts).size).toBe(3);461      for (const p of prompts) {462        expect(p).toBeGreaterThanOrEqual(1);463        expect(p).toBeLessThanOrEqual(12);464      }465466      // Inputs stay disabled until the 5s countdown expires467      const inputs = Array.from(468        container.querySelectorAll(".peach-recovery-confirm-input")469      ) as HTMLInputElement[];470      expect(inputs).toHaveLength(3);471      expect(inputs[0]!.disabled).toBe(true);472      await vi.advanceTimersByTimeAsync(5000);473      expect(inputs[0]!.disabled).toBe(false);474475      prompts.forEach((pos, i) => {476        inputText(inputs[i]!, words12[pos - 1]!);477      });478479      const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;480      expect(confirmBtn.disabled).toBe(false);481      confirmBtn.click();482      await vi.advanceTimersByTimeAsync(700); // success pause before onDone483484      expect(onDone).toHaveBeenCalledTimes(1);485    } finally {486      vi.useRealTimers();487    }488  });489490  it("Recovery words flow submits the new master password and completes", async () => {491    sendMessageMock.mockImplementation(async (msg: unknown) => {492      const type = (msg as { type?: string }).type;493      if (type === "VALIDATE_RECOVERY_WORDS") {494        return { ok: true, data: { valid: true, invalid: [] } };495      }496      if (type === "RECOVER_WITH_WORDS") return { ok: true };497      return { ok: true };498    });499    const canvasContext = {500      clearRect: vi.fn(),501      fillText: vi.fn(),502      beginPath: vi.fn(),503      closePath: vi.fn(),504      arc: vi.fn(),505      fill: vi.fn(),506      stroke: vi.fn(),507      save: vi.fn(),508      restore: vi.fn(),509      translate: vi.fn(),510      rotate: vi.fn(),511      scale: vi.fn(),512      drawImage: vi.fn(),513      measureText: vi.fn(() => ({ width: 0 })),514      setTransform: vi.fn(),515      fillRect: vi.fn(),516      strokeRect: vi.fn(),517    } as unknown as CanvasRenderingContext2D;518    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);519520    const onRecoveryComplete = vi.fn(async () => undefined);521    const container = mount(522      <RecoveryWordEntryView523        onCancel={vi.fn()}524        onRecoveryComplete={onRecoveryComplete}525      />526    );527528    const recoveryWords = WORD_LIST.slice(0, 12);529    for (let i = 0; i < recoveryWords.length; i++) {530      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;531      inputText(input, recoveryWords[i]!);532    }533    await flushView();534535    findButton(container, "verify words").click();536    await flushView();537538    expect(container.textContent).toContain("new master password");539540    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;541    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;542    inputText(newPassword, "fresh-recovery-password");543    inputText(confirmPassword, "fresh-recovery-password");544    await flushView();545546    findButton(container, "recover vault").click();547    await flushView();548549    expect(sendMessageMock).toHaveBeenCalledWith({550      type: "RECOVER_WITH_WORDS",551      payload: {552        words: recoveryWords,553        newPassword: "fresh-recovery-password",554      },555    });556    expect(onRecoveryComplete).toHaveBeenCalled();557  });558559  it("Export menu surfaces both backup formats", async () => {560    const container = mount(<ExportView onBack={vi.fn()} />);561    await flushView();562563    expect(container.textContent).toContain("PeachScript");564    expect(container.textContent).toContain("CSV");565  });566567  it("Lock can open directly on backup restore from onboarding", async () => {568    const container = mount(() => (569      <LockView570        hasVault={false}571        ready={true}572        onUnlock={vi.fn()}573        initialRestore={true}574      />575    ));576    await flushView();577578    expect(container.textContent).toContain("PeachScript");579    expect(container.textContent).toContain("Codex");580    expect(container.textContent).toContain("CSV");581    expect(container.querySelector("#master-password")).toBeNull();582  });583584  it("Lock restore back returns to onboarding, not the create-vault screen", async () => {585    const onBackToOnboarding = vi.fn();586    const container = mount(() => (587      <LockView588        hasVault={false}589        ready={true}590        onUnlock={vi.fn()}591        initialRestore={true}592        onBackToOnboarding={onBackToOnboarding}593      />594    ));595    await flushView();596597    (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();598    await flushView();599    expect(onBackToOnboarding).toHaveBeenCalledTimes(1);600  });601602  it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {603    const container = mount(() => (604      <LockView605        hasVault={false}606        ready={true}607        onUnlock={vi.fn()}608        initialRestore={true}609        initialRestoreSource="symbols"610      />611    ));612    await flushView();613614    expect(container.querySelector(".peach-restore-script")).toBeTruthy();615    expect(container.querySelector("#restore-backup-pass")).toBeTruthy();616    expect(container.querySelector(".peach-restore-sources")).toBeNull();617  });618619  it("Lock restore resumes on the picked CSV source, not the picker", async () => {620    const container = mount(() => (621      <LockView622        hasVault={false}623        ready={true}624        onUnlock={vi.fn()}625        initialRestore={true}626        initialRestoreSource="csv"627      />628    ));629    await flushView();630631    expect(container.querySelector("#restore-csv-pass")).toBeTruthy();632    expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();633    expect(container.querySelector(".peach-restore-sources")).toBeNull();634  });635636  it("Lock restore PeachScript form loads a dropped .peach file", async () => {637    const container = mount(() => (638      <LockView639        hasVault={false}640        ready={true}641        onUnlock={vi.fn()}642        initialRestore={true}643        initialRestoreSource="symbols"644      />645    ));646    await flushView();647648    const form = container.querySelector(".peach-restore-form") as HTMLElement;649    expect(form).toBeTruthy();650651    // The OS file picker dismisses the action popup, so drag-and-drop is the652    // file path that survives; a drop must land the file in the script field.653    const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });654    const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {655      dataTransfer?: { files: File[] };656    };657    drop.dataTransfer = { files: [file] };658    form.dispatchEvent(drop);659    await flushView();660661    const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;662    expect(script.value).toBe("dropped-peach-backup");663  });664665  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {666    const device = {667      deviceId: "phone-1",668      deviceName: "June's Phone",669      deviceType: "android" as const,670      longTermPublicKeyHex: "a".repeat(64),671      signingPublicKeyHex: "b".repeat(64),672      pairedAt: 1,673      trustSource: "direct" as const,674    };675    sendMessageMock.mockImplementation(async (msg: unknown) => {676      const m = msg as { type: string };677      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };678      return { ok: true, data: null };679    });680681    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);682    for (let i = 0; i < 30; i++) await Promise.resolve();683684    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>685      /sync/i.test(b.textContent ?? ""),686    );687    openSync?.click();688    for (let i = 0; i < 30; i++) await Promise.resolve();689690    const remove = Array.from(container.querySelectorAll("button")).find(691      (b) => (b.textContent ?? "").trim() === "remove",692    );693    expect(remove).toBeTruthy();694695    // Guard against a vacuous pass: the disclosure must not already be on696    // screen before the user asks to remove anything.697    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();698699    remove!.click();700    for (let i = 0; i < 10; i++) await Promise.resolve();701702    // Revocation must never be presented as securing the vault against the703    // removed device — recovery words survive it and restore everything.704    const note = container.querySelector(".peach-unpair-confirm-note");705    expect(note).toBeTruthy();706    const text = note!.textContent ?? "";707    expect(text).toContain("master password");708    expect(text).toContain("recovery words");709    expect(text).toContain("future changes");710  });711712  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {713    const device = {714      deviceId: "phone-1",715      deviceName: "June's Phone",716      deviceType: "android" as const,717      longTermPublicKeyHex: "a".repeat(64),718      signingPublicKeyHex: "b".repeat(64),719      pairedAt: 1,720      trustSource: "direct" as const,721    };722    let paired: typeof device[] = [device];723    sendMessageMock.mockImplementation(async (msg: any) => {724      if (msg?.type === "LIST_PAIRED_DEVICES") {725        return { ok: true, data: { devices: [...paired] } };726      }727      if (msg?.type === "UNPAIR_DEVICE") {728        // Mirror the real handler: UNPAIR_DEVICE drops the row from the729        // registry. The next LIST_PAIRED_DEVICES must not see it.730        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);731        return { ok: true };732      }733      if (msg?.type === "RENAME_PAIRED_DEVICE") {734        paired = paired.map((d) =>735          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,736        );737        return { ok: true };738      }739      return { ok: true, data: null };740    });741742    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);743    for (let i = 0; i < 30; i++) await Promise.resolve();744    Array.from(container.querySelectorAll("button"))745      .find((b) => /sync/i.test(b.textContent ?? ""))746      ?.click();747    for (let i = 0; i < 30; i++) await Promise.resolve();748749    // The list itself: extracting the relay selector must not take the hub with it.750    expect(container.textContent).toContain("PAIRED DEVICES");751    expect(container.textContent).toContain("June's Phone");752    expect(container.textContent).toContain("Android");753    Array.from(container.querySelectorAll("button"))754      .find((b) => (b.textContent ?? "").trim() === "rename")!755      .click();756    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;757    renameInput.value = "Travel phone";758    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));759    Array.from(container.querySelectorAll("button"))760      .find((b) => (b.textContent ?? "").trim() === "save")!761      .click();762    for (let i = 0; i < 30; i++) await Promise.resolve();763    expect(sendMessageMock).toHaveBeenCalledWith({764      type: "RENAME_PAIRED_DEVICE",765      payload: { deviceId: "phone-1", name: "Travel phone" },766    });767    expect(container.textContent).toContain("Travel phone");768    const syncNowButton = container.querySelector<HTMLButtonElement>(769      'button[aria-label="Sync this vault now"]',770    )!;771    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));772    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;773    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;774    expect(775      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,776    ).toBeTruthy();777    expect(778      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,779    ).toBeTruthy();780781    Array.from(container.querySelectorAll("button"))782      .find((b) => (b.textContent ?? "").trim() === "remove")!783      .click();784    for (let i = 0; i < 10; i++) await Promise.resolve();785    const confirm = Array.from(786      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),787    ).find((b) => (b.textContent ?? "").trim() === "remove");788    confirm!.click();789    for (let i = 0; i < 30; i++) await Promise.resolve();790791    expect(sendMessageMock).toHaveBeenCalledWith({792      type: "UNPAIR_DEVICE",793      payload: { deviceId: "phone-1" },794    });795    // The list must be refreshed and the device must be gone. The regression796    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but797    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.798    expect(container.textContent).not.toContain("June's Phone");799    expect(container.textContent).not.toContain("PAIRED DEVICES");800  });801802  it("Settings keeps connection and recovery details collapsed until requested", async () => {803    sendMessageMock.mockImplementation(async (msg: any) => {804      if (msg?.type === "LIST_PAIRED_DEVICES") {805        return { ok: true, data: { devices: [] } };806      }807      return { ok: true, data: null };808    });809810    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);811    for (let i = 0; i < 30; i++) await Promise.resolve();812    Array.from(container.querySelectorAll("button"))813      .find((button) => /sync/i.test(button.textContent ?? ""))814      ?.click();815    for (let i = 0; i < 20; i++) await Promise.resolve();816817    expect(container.textContent).toContain("SYNC STATUS");818    expect(container.textContent).toContain("ADD A DEVICE");819    expect(container.textContent).not.toContain("Public relay");820    expect(container.textContent).not.toContain("Rotate sync identity");821822    const connection = Array.from(container.querySelectorAll("button"))823      .find((button) => button.textContent?.includes("CONNECTION"));824    connection?.click();825    await Promise.resolve();826    expect(container.textContent).toContain("Public relay");827828    const recovery = Array.from(container.querySelectorAll("button"))829      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));830    recovery?.click();831    await Promise.resolve();832    expect(container.textContent).toContain("Rotate sync identity");833  });834835  it("Settings requires current, new, and confirm passwords before enabling save", async () => {836    sendMessageMock.mockResolvedValue({ ok: true, data: null });837    const container = mount(838      <SettingsView839        entriesCount={1}840        breachMonitoring={true}841        lockOnClose={true}842        autoLockMinutes={5}843        trashRetentionDays={30}844        theme="dark"845        accentColor="peach"846        textSize="medium"847        reduceMotion={false}848        onBreachMonitoringChange={vi.fn()}849        onLockOnCloseChange={vi.fn()}850        onAutoLockMinutesChange={vi.fn()}851        onTrashRetentionChange={vi.fn()}852        onThemeChange={vi.fn()}853        onAccentChange={vi.fn()}854        onTextSizeChange={vi.fn()}855        onReduceMotionChange={vi.fn()}856        shortcuts={DEFAULT_BINDINGS}857        onShortcutsChange={vi.fn()}858        onBack={vi.fn()}859        onImport={vi.fn()}860        onExport={vi.fn()}861        onBreachCheckDone={vi.fn()}862      />863    );864    await flushView();865866    expect(container.querySelectorAll(".stk-card").length).toBe(7);867    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]868    // "open settings to card I (Peach Pro) instead of card II (Protection)".869    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");870871    const changeButton = Array.from(container.querySelectorAll("button")).find(872      (candidate) => candidate.textContent?.trim() === "change"873    ) as HTMLButtonElement;874    changeButton.click();875    await flushView();876877    const saveButton = Array.from(container.querySelectorAll("button")).find(878      (candidate) => candidate.textContent?.trim() === "save new password"879    ) as HTMLButtonElement;880    expect(saveButton.disabled).toBe(true);881882    const inputs = container.querySelectorAll('input[type="password"]');883    inputText(inputs[0] as HTMLInputElement, "current-pass");884    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");885    await flushView();886    expect(saveButton.disabled).toBe(true);887888    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");889    await flushView();890    expect(saveButton.disabled).toBe(false);891  });892893  it("Settings feedback highlights the selected category and hides the form after submit", async () => {894    sendMessageMock.mockResolvedValue({ ok: true, data: null });895    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({896      ok: true,897      json: async () => ({ success: true }),898    }));899    vi.stubGlobal("fetch", fetchMock);900901    const container = mount(902      <SettingsView903        entriesCount={1}904        breachMonitoring={true}905        lockOnClose={true}906        autoLockMinutes={5}907        trashRetentionDays={30}908        theme="dark"909        accentColor="peach"910        textSize="medium"911        reduceMotion={false}912        initialSection="feedback"913        onBreachMonitoringChange={vi.fn()}914        onLockOnCloseChange={vi.fn()}915        onAutoLockMinutesChange={vi.fn()}916        onTrashRetentionChange={vi.fn()}917        onThemeChange={vi.fn()}918        onAccentChange={vi.fn()}919        onTextSizeChange={vi.fn()}920        onReduceMotionChange={vi.fn()}921        shortcuts={DEFAULT_BINDINGS}922        onShortcutsChange={vi.fn()}923        onBack={vi.fn()}924        onImport={vi.fn()}925        onExport={vi.fn()}926        onBreachCheckDone={vi.fn()}927      />928    );929    await flushView();930931    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;932    expect(feedbackCard.classList.contains("is-active")).toBe(true);933934    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];935    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;936    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;937    expect(bugButton.getAttribute("aria-pressed")).toBe("true");938    expect(featureButton.getAttribute("aria-pressed")).toBe("false");939940    featureButton.click();941    await flushView();942943    expect(bugButton.getAttribute("aria-pressed")).toBe("false");944    expect(featureButton.getAttribute("aria-pressed")).toBe("true");945    expect(featureButton.classList.contains("is-active")).toBe(true);946947    inputText(948      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,949      '<script>alert("xss")</script> Please make this easier to see & safer.'950    );951    const sendButton = Array.from(container.querySelectorAll("button")).find(952      (candidate) => candidate.textContent?.trim() === "Send"953    ) as HTMLButtonElement;954    sendButton.click();955956    await vi.waitFor(() => {957      expect(container.textContent).toContain("Thank you");958    });959960    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));961    expect(requestBody.subject).toContain("Feature request");962    expect(requestBody.message).toBe(963      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."964    );965    expect(requestBody.message).not.toContain("<script>");966    expect(container.textContent).toContain("Someone will process it soon");967    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();968    expect(container.querySelector(".peach-feedback-submit")).toBeNull();969  });970971  it("Settings keeps license activation on the full-screen celebration path", async () => {972    const onLicenseChange = vi.fn(async () => undefined);973    sendMessageMock.mockImplementation(async (msg: unknown) => {974      const type = (msg as { type?: string }).type;975      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };976      if (type === "ACTIVATE_LICENSE") {977        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };978      }979      if (type === "GET_ENTRIES") return { ok: true, data: [] };980      return { ok: true, data: null };981    });982983    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);984    await flushView();985986    inputText(987      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,988      "PEACH-TEST-KEY",989    );990    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;991    apply.click();992993    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));994    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);995    expect(container.textContent).not.toContain("thank you ✦");996  });997998  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {999    sendMessageMock.mockImplementation(async (msg: unknown) => {1000      const type = (msg as { type?: string }).type;1001      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1002      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1003      if (type === "GET_ENTRIES") return { ok: true, data: [] };1004      return { ok: true, data: null };1005    });10061007    const onLicenseChangeFromStar = vi.fn();1008    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1009    await flushView();1010    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1011    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1012    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();10131014    const onLicenseChangeFromPeach = vi.fn();1015    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1016    await flushView();1017    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1018    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1019    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1020  });10211022  it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1023    sendMessageMock.mockResolvedValue({ ok: true, data: null });1024    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1025      ok: true,1026      json: async () => ({ success: true }),1027    }));1028    vi.stubGlobal("fetch", fetchMock);1029    // Firefox-style permissions API reporting the optional consent as absent.1030    // The old code gated send behind this and silently returned to idle, which1031    // read as a dead Send button. The gate is gone: Send must always send.1032    vi.stubGlobal("browser", {1033      permissions: {1034        getAll: vi.fn(async () => ({ data_collection: [] })),1035        request: vi.fn(async () => false),1036      },1037    });10381039    const container = mount(1040      <SettingsView1041        entriesCount={1}1042        breachMonitoring={true}1043        lockOnClose={true}1044        autoLockMinutes={5}1045        trashRetentionDays={30}1046        theme="dark"1047        accentColor="peach"1048        textSize="medium"1049        reduceMotion={false}1050        initialSection="feedback"1051        onBreachMonitoringChange={vi.fn()}1052        onLockOnCloseChange={vi.fn()}1053        onAutoLockMinutesChange={vi.fn()}1054        onTrashRetentionChange={vi.fn()}1055        onThemeChange={vi.fn()}1056        onAccentChange={vi.fn()}1057        onTextSizeChange={vi.fn()}1058        onReduceMotionChange={vi.fn()}1059        shortcuts={DEFAULT_BINDINGS}1060        onShortcutsChange={vi.fn()}1061        onBack={vi.fn()}1062        onImport={vi.fn()}1063        onExport={vi.fn()}1064        onBreachCheckDone={vi.fn()}1065      />1066    );1067    await flushView();10681069    inputText(1070      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1071      "Please improve the feedback flow."1072    );1073    const sendButton = Array.from(container.querySelectorAll("button")).find(1074      (candidate) => candidate.textContent?.trim() === "Send"1075    ) as HTMLButtonElement;1076    sendButton.click();10771078    await vi.waitFor(() => {1079      expect(fetchMock).toHaveBeenCalledTimes(1);1080    });1081    await vi.waitFor(() => {1082      expect(container.textContent).toContain("Thank you");1083    });1084    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1085  });10861087  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1088    sendMessageMock.mockImplementation(async (msg: unknown) => {1089      const m = msg as { type: string };1090      if (m.type === "GET_RECOVERY_WORDS") {1091        return {1092          ok: true,1093          data: [1094            "abandon", "ability", "able", "about", "above", "absent",1095            "absorb", "abstract", "absurd", "abuse", "access", "accident",1096          ],1097        };1098      }1099      return { ok: true, data: null };1100    });1101    const onAutoLockMinutesChange = vi.fn();1102    const onThemeChange = vi.fn();1103    const container = mount(1104      <SettingsView1105        entriesCount={1}1106        breachMonitoring={true}1107        lockOnClose={true}1108        autoLockMinutes={5}1109        trashRetentionDays={30}1110        theme="dark"1111        accentColor="peach"1112        textSize="medium"1113        reduceMotion={false}1114        onBreachMonitoringChange={vi.fn()}1115        onLockOnCloseChange={vi.fn()}1116        onAutoLockMinutesChange={onAutoLockMinutesChange}1117        onTrashRetentionChange={vi.fn()}1118        onThemeChange={onThemeChange}1119        onAccentChange={vi.fn()}1120        onTextSizeChange={vi.fn()}1121        onReduceMotionChange={vi.fn()}1122        shortcuts={DEFAULT_BINDINGS}1123        onShortcutsChange={vi.fn()}1124        onBack={vi.fn()}1125        onImport={vi.fn()}1126        onExport={vi.fn()}1127        onBreachCheckDone={vi.fn()}1128      />1129    );1130    await flushView();1131    sendMessageMock.mockClear();11321133    // ── Auto-lock + theme changes (before password change) ─────────────1134    const selects = container.querySelectorAll("select");1135    changeSelect(selects[0] as HTMLSelectElement, "15");1136    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);11371138    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1139    appearanceCard.click();1140    await flushView();1141    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1142    const themeButton = Array.from(container.querySelectorAll("button")).find(1143      (candidate) => candidate.textContent?.trim() === "Switch to light"1144    ) as HTMLButtonElement;1145    themeButton.click();1146    expect(onThemeChange).toHaveBeenCalledWith("light");11471148    // ── Password change → recovery words display ────────────────────────1149    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1150    protectionCard.click();1151    await flushView();11521153    const changeButton = Array.from(container.querySelectorAll("button")).find(1154      (candidate) => candidate.textContent?.trim() === "change"1155    ) as HTMLButtonElement;1156    changeButton.click();1157    await flushView();11581159    const inputs = container.querySelectorAll('input[type="password"]');1160    inputText(inputs[0] as HTMLInputElement, "current-pass");1161    inputText(inputs[1] as HTMLInputElement, "new-password-123");1162    inputText(inputs[2] as HTMLInputElement, "new-password-123");1163    await flushView();11641165    const saveButton = Array.from(container.querySelectorAll("button")).find(1166      (candidate) => candidate.textContent?.trim() === "save new password"1167    ) as HTMLButtonElement;1168    saveButton.click();1169    await flushView();11701171    expect(sendMessageMock).toHaveBeenCalledWith({1172      type: "CHANGE_PASSWORD",1173      payload: {1174        currentPassword: "current-pass",1175        newPassword: "new-password-123",1176      },1177    });11781179    // After a successful password change the recovery word display replaces settings.1180    expect(container.textContent).toContain("save your recovery words");1181    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1182  });11831184  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1185    sendMessageMock.mockResolvedValue({ ok: true });1186    const container = mount(1187      <EntryView1188        entry={sampleEntry()}1189        onDelete={vi.fn()}1190        onFieldSaved={vi.fn(async () => undefined)}1191      />1192    );11931194    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1195    passwordButton.click();1196    await flushView();11971198    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1199      type: "COPY",1200      payload: { text: "secret123", label: "password", alreadyWritten: false },1201    });1202    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1203      type: "FIRE_CANARY_PING",1204      payload: {1205        webhookUrl: "https://example.com/hook",1206        entryTitle: "GitHub",1207        trigger: "password_copied",1208      },1209    });12101211    sendMessageMock.mockClear();1212    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1213    usernameButton.click();1214    await flushView();12151216    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1217      type: "COPY",1218      payload: { text: "june", label: "username", alreadyWritten: false },1219    });1220    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1221      type: "FIRE_CANARY_PING",1222      payload: {1223        webhookUrl: "https://example.com/hook",1224        entryTitle: "GitHub",1225        trigger: "username_copied",1226      },1227    });1228  });12291230  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1231    const container = mount(1232      <EntryView1233        entry={sampleEntry({1234          type: "passkey",1235          username: undefined,1236          password: undefined,1237          totp: undefined,1238          canary: undefined,1239          passkey: {1240            rpId: "vercel.com",1241            rpName: "Vercel",1242            credentialId: "cred-1",1243            publicKeyCose: "cose",1244            userHandle: "handle",1245            userName: "june",1246            userDisplayName: "June",1247            algorithm: -7,1248            signCount: 0,1249            aaguid: "",1250            backupEligible: false,1251            backupState: false,1252            createdAt: 1,1253          },1254        })}1255        onDelete={vi.fn()}1256        onFieldSaved={vi.fn(async () => undefined)}1257      />1258    );12591260    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1261      (button) => button.textContent?.includes("Advanced")1262    ) as HTMLButtonElement | undefined;1263    expect(advanced).toBeDefined();1264    expect(advanced?.textContent).toContain("+ Advanced");12651266    advanced!.click();1267    await flushView();12681269    expect(container.textContent).toContain("passkey · vercel.com");1270  });12711272  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1273    sendMessageMock.mockResolvedValue({ ok: true });1274    const onFieldSaved = vi.fn(async () => undefined);1275    const container = mount(1276      <EntryView1277        entry={sampleEntry()}1278        onDelete={vi.fn()}1279        onFieldSaved={onFieldSaved}1280      />1281    );12821283    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1284    passwordValue.click();1285    await flushView();12861287    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1288    inputText(passwordInput, "");1289    await flushView();12901291    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1292    saveButton.click();1293    await flushView();12941295    expect(sendMessageMock).not.toHaveBeenCalledWith(1296      expect.objectContaining({ type: "UPDATE_ENTRY" })1297    );1298    expect(onFieldSaved).not.toHaveBeenCalled();12991300    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1301    totpMain.click();13021303    await vi.waitFor(() => {1304      expect(sendMessageMock).toHaveBeenLastCalledWith({1305        type: "COPY",1306        payload: { text: "123456", label: "totp", alreadyWritten: false },1307      });1308    }, { timeout: 500 });1309  });13101311  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1312    sendMessageMock.mockResolvedValue({ ok: true });1313    const container = mount(1314      <EntryView1315        entry={sampleEntry()}1316        onDelete={vi.fn()}1317        onFieldSaved={vi.fn(async () => undefined)}1318      />1319    );13201321    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1322    usernameValue.click();1323    await flushView();13241325    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1326    usernameInput.blur();1327    await flushView();13281329    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1330    expect(sendMessageMock).not.toHaveBeenCalledWith(1331      expect.objectContaining({ type: "UPDATE_ENTRY" })1332    );1333  });13341335  it("Entry keeps the full used-email list available while editing an email", async () => {1336    const container = mount(1337      <EntryView1338        entry={sampleEntry({ email: "[email protected]" })}1339        entries={[1340          sampleEntry({ email: "[email protected]" }),1341          sampleEntry({ id: "entry-2", email: "[email protected]" }),1342          sampleEntry({ id: "entry-3", username: "duckuccino" }),1343        ]}1344        onDelete={vi.fn()}1345        onFieldSaved={vi.fn(async () => undefined)}1346      />1347    );13481349    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1350      (card) => card.querySelector(".peach-label")?.textContent === "email"1351    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1352    emailValue.click();1353    await flushView();13541355    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1356      .map((element) => element.textContent);1357    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1358  });13591360  it("Entry lets TOTP secrets be edited inline", async () => {1361    sendMessageMock.mockResolvedValue({ ok: true });1362    const onFieldSaved = vi.fn(async () => undefined);1363    const container = mount(1364      <EntryView1365        entry={sampleEntry()}1366        onDelete={vi.fn()}1367        onFieldSaved={onFieldSaved}1368      />1369    );13701371    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1372    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1373    await flushView();13741375    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1376    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1377    await flushView();13781379    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1380    saveButton.click();1381    await flushView();13821383    expect(sendMessageMock).toHaveBeenCalledWith({1384      type: "UPDATE_ENTRY",1385      payload: {1386        id: "entry-1",1387        patch: { totp: "NB2W45DFOIZA====" },1388      },1389    });1390    expect(onFieldSaved).toHaveBeenCalled();1391  });13921393  it("Vault seeds and focuses search when a search seed is provided", async () => {1394    const container = mount(1395      <VaultView1396        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1397        sortOrder="url-first"1398        searchSeed="g"1399        searchSeedNonce={1}1400        onSelectEntry={vi.fn()}1401        onSortOrderChange={vi.fn()}1402        onAdd={vi.fn()}1403        onClose={vi.fn()}1404      />1405    );14061407    await flushView();14081409    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1410    expect(search.value).toBe("g");1411    expect(document.activeElement).toBe(search);1412  });14131414  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1415    parseImportSourceMock.mockResolvedValue(1416      sampleImportResult({1417        entries: [1418          {1419            type: "login",1420            title: "Imported site",1421            username: "imported-user",1422            password: "imported-pass",1423            url: "https://imported.test",1424          },1425        ],1426      })1427    );1428    sendMessageMock.mockImplementation(async (msg: unknown) => {1429      const type = (msg as { type?: string }).type;1430      if (type === "GET_ENTRIES") {1431        return {1432          ok: true,1433          data: [1434            sampleEntry({1435              id: "existing-1",1436              title: "Imported site",1437              username: "imported-user",1438              url: "https://imported.test",1439            }),1440          ],1441        };1442      }1443      if (type === "BATCH_ADD_ENTRIES") {1444        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1445      }1446      return { ok: true };1447    });1448    const onImport = vi.fn(async () => undefined);1449    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1450    await flushView();1451    sendMessageMock.mockClear();14521453    await selectImportFile(container);1454    findButton(container, "Import 1").click();1455    await flushView();14561457    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1458    expect(sendMessageMock).toHaveBeenCalledWith({1459      type: "BATCH_ADD_ENTRIES",1460      payload: {1461        entries: [1462          expect.objectContaining({1463            title: "Imported site",1464            username: "imported-user",1465            password: "imported-pass",1466            url: "https://imported.test",1467          }),1468        ],1469      },1470    });1471    expect(onImport).toHaveBeenCalled();1472    expect(container.textContent).toContain("1 entry appears to be duplicates");1473  });14741475  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1476    parseImportSourceMock.mockResolvedValue(sampleImportResult());1477    sendMessageMock.mockImplementation(async (msg: unknown) => {1478      const type = (msg as { type?: string }).type;1479      if (type === "GET_ENTRIES") {1480        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1481      }1482      if (type === "CLEAR_VAULT_ENTRIES") {1483        return { ok: true };1484      }1485      if (type === "BATCH_ADD_ENTRIES") {1486        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1487      }1488      return { ok: true };1489    });1490    const onImport = vi.fn(async () => undefined);1491    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1492    await flushView();1493    sendMessageMock.mockClear();14941495    await selectImportFile(container);1496    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1497    await flushView();14981499    findButton(container, "Replace vault with 1").click();1500    await flushView();15011502    expect(sendMessageMock).not.toHaveBeenCalledWith(1503      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1504    );1505    expect(container.textContent).toContain("Replace forever? This cannot be undone.");15061507    findButton(container, "Yes, replace forever").click();1508    await flushView();15091510    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1511    expect(sendMessageMock).toHaveBeenCalledWith({1512      type: "BATCH_ADD_ENTRIES",1513      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1514    });1515    expect(onImport).toHaveBeenCalled();1516  });15171518  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1519    parseImportSourceMock.mockResolvedValue(sampleImportResult());1520    sendMessageMock.mockImplementation(async (msg: unknown) => {1521      const type = (msg as { type?: string }).type;1522      if (type === "CREATE_VAULT") return { ok: true };1523      if (type === "BATCH_ADD_ENTRIES") {1524        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1525      }1526      return { ok: true };1527    });1528    const onImport = vi.fn(async () => undefined);1529    const container = mount(1530      <ImportView1531        mode="onboarding"1532        replacesExistingVault={true}1533        onBack={vi.fn()}1534        onImport={onImport}1535      />1536    );15371538    await selectImportFile(container);15391540    const createButton = findButton(container, "Create vault + import 1");1541    expect(createButton.disabled).toBe(true);1542    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1543    expect(container.textContent).toContain("Use at least 8 characters");15441545    const masterInput = container.querySelector(1546      'input[placeholder="new master password"]'1547    ) as HTMLInputElement;1548    const confirmInput = container.querySelector(1549      'input[placeholder="confirm master password"]'1550    ) as HTMLInputElement;1551    const revealButton = container.querySelector(1552      'button[aria-label="Show passwords"]'1553    ) as HTMLButtonElement;1554    expect(revealButton).toBeTruthy();1555    revealButton.click();1556    await flushView();1557    expect(masterInput.type).toBe("text");1558    expect(confirmInput.type).toBe("text");15591560    inputText(masterInput, "fresh-master-password");1561    inputText(confirmInput, "fresh-master-password");1562    await flushView();15631564    expect(createButton.disabled).toBe(false);1565    createButton.click();1566    await flushView();15671568    expect(sendMessageMock).toHaveBeenCalledWith({1569      type: "CREATE_VAULT",1570      payload: { password: "fresh-master-password" },1571    });1572    expect(sendMessageMock).toHaveBeenCalledWith({1573      type: "BATCH_ADD_ENTRIES",1574      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1575    });1576    expect(onImport).toHaveBeenCalled();1577  });15781579});