contracts.test.tsx

57.0 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      requiresPassword: false,94    });9596    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {97      return window.setTimeout(() => cb(0), 0) as unknown as number;98    });99    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));100    vi.stubGlobal("URL", {101      createObjectURL: vi.fn(() => "blob:peach-test"),102      revokeObjectURL: vi.fn(),103    });104    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});105106    (globalThis as { chrome?: typeof chrome }).chrome = {107      runtime: {108        getManifest: () => ({ version: "1.4.47" }),109        onMessage: { addListener: vi.fn() },110      },111    } as unknown as typeof chrome;112  });113114  afterEach(() => {115    while (disposers.length > 0) {116      disposers.pop()?.();117    }118    vi.restoreAllMocks();119    vi.unstubAllGlobals();120  });121122  function mount(view: JSX.Element | (() => JSX.Element)) {123    const mounted = renderView(view);124    disposers.push(mounted.dispose);125    return mounted.container;126  }127128  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {129    return {130      id: "entry-1",131      type: "login",132      title: "GitHub",133      username: "june",134      password: "secret123",135      url: "https://github.com",136      totp: "JBSWY3DPEHPK3PXP",137      canary: {138        webhookUrl: "https://example.com/hook",139      },140      createdAt: 1,141      modifiedAt: 1,142      ...overrides,143    };144  }145146  function sampleImportResult(147    overrides: Partial<ImportParseResult> = {}148  ): ImportParseResult {149    return {150      format: "csv",151      label: "CSV import",152      entries: [153        {154          type: "login",155          title: "Imported site",156          username: "imported-user",157          password: "imported-pass",158          url: "https://imported.test",159        },160      ],161      totalItems: 1,162      skippedItems: 0,163      requiresPassword: false,164      ...overrides,165    };166  }167168  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {169    return {170      entriesCount: 1,171      breachMonitoring: true,172      lockOnClose: true,173      autoLockMinutes: 5,174      trashRetentionDays: 30,175      theme: "dark" as const,176      accentColor: "peach" as const,177      textSize: "medium" as const,178      reduceMotion: false,179      onBreachMonitoringChange: vi.fn(),180      onLockOnCloseChange: vi.fn(),181      onAutoLockMinutesChange: vi.fn(),182      onTrashRetentionChange: vi.fn(),183      onThemeChange: vi.fn(),184      onAccentChange: vi.fn(),185      onTextSizeChange: vi.fn(),186      onReduceMotionChange: vi.fn(),187      shortcuts: DEFAULT_BINDINGS,188      onShortcutsChange: vi.fn(),189      onBack: vi.fn(),190      onImport: vi.fn(),191      onExport: vi.fn(),192      onBreachCheckDone: vi.fn(),193      ...overrides,194    };195  }196197  async function selectImportFile(198    container: HTMLElement,199    contents = '{"export":"test"}'200  ) {201    // The Import view now opens onto a menu — click into the file subview202    // before locating the file input. Idempotent: a second click won't re-fire203    // because the menu unmounts once the subview renders.204    const fileCard = findButton(container, "Choose file →");205    if (fileCard) {206      fileCard.click();207      await flushView();208    }209    const input = container.querySelector('input[type="file"]') as HTMLInputElement;210    const file = new File([contents], "import.json", { type: "application/json" });211    Object.defineProperty(input, "files", {212      configurable: true,213      value: [file],214    });215    input.dispatchEvent(new Event("change", { bubbles: true }));216    await flushView();217    await flushView();218  }219220  function findButton(container: HTMLElement, text: string): HTMLButtonElement {221    return Array.from(container.querySelectorAll("button")).find(222      (candidate) => candidate.textContent?.trim() === text223    ) as HTMLButtonElement;224  }225226  it("Lock sends UNLOCK with the entered passphrase", async () => {227    sendMessageMock.mockResolvedValue({ ok: true });228    const onUnlock = vi.fn(async () => undefined);229    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);230231    const password = container.querySelector("#unlock-password") as HTMLInputElement;232    inputText(password, "correct horse battery staple");233    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();234    await flushView();235236    expect(sendMessageMock).toHaveBeenCalledWith({237      type: "UNLOCK",238      payload: { password: "correct horse battery staple" },239    });240    expect(onUnlock).toHaveBeenCalled();241  });242243  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {244    sendMessageMock.mockImplementation(async (msg: unknown) => {245      return (msg as { type: string }).type === "GET_SETTINGS"246        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }247        : { ok: true };248    });249    const onUnlock = vi.fn(async () => undefined);250    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);251    await flushView();252253    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");254    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();255    await flushView();256257    expect(sendMessageMock).toHaveBeenCalledWith({258      type: "UNLOCK_WITH_PIN",259      payload: { pin: "1234" },260    });261    expect(onUnlock).toHaveBeenCalled();262    expect(container.textContent).toContain("use master password");263  });264265  it("places the PIN method switch below the unlock button", async () => {266    sendMessageMock.mockImplementation(async (msg: unknown) => {267      return (msg as { type: string }).type === "GET_SETTINGS"268        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }269        : { ok: true };270    });271    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);272    await flushView();273274    const buttons = Array.from(container.querySelectorAll("button"));275    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");276    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");277    expect(pinIndex).toBeGreaterThan(unlockIndex);278  });279280  it("keeps standalone note creation in Notes mode", async () => {281    const chromeApi = globalThis.chrome as unknown as {282      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };283    };284    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };285286    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);287    await flushView();288289    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))290      .toEqual(["login", "card"]);291    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();292  });293294  it("gives Notes mode the full note-formatting toolbar", async () => {295    const container = mount(296      <NotesEditor297        note={null}298        onBack={vi.fn()}299        onSaved={vi.fn()}300        onDelete={vi.fn()}301      />302    );303    await flushView();304305    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();306    expect(container.querySelector('[title="Divider"]')).toBeTruthy();307    expect(container.querySelector('[title="Quote"]')).toBeTruthy();308    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);309    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);310    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");311  });312313  it("saves formatting applied by the Notes toolbar before leaving", async () => {314    const savedNote = {315      id: "note-1",316      type: "note" as const,317      title: "Untitled",318      notes: "<b>formatted</b>",319      createdAt: 1,320      modifiedAt: 2,321    };322    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });323    const originalExecCommand = document.execCommand;324    const execCommandMock = vi.fn(() => {325      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");326      if (body) body.innerHTML = "<b>formatted</b>";327      return true;328    });329    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });330331    try {332      const container = mount(333        <NotesEditor334          note={null}335          onBack={vi.fn()}336          onSaved={vi.fn()}337          onDelete={vi.fn()}338        />339      );340      await flushView();341      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(342        new MouseEvent("mousedown", { bubbles: true, cancelable: true })343      );344      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);345      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();346      await flushView();347348      expect(sendMessageMock).toHaveBeenCalledWith({349        type: "SAVE_NOTE",350        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },351      });352    } finally {353      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });354    }355  });356357  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {358    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);359    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;360    const password = container.querySelector("#master-password") as HTMLInputElement;361    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;362363    inputText(password, "short");364    inputText(confirm, "short");365    await flushView();366    expect(createButton.disabled).toBe(true);367368    zxcvbnMock.mockImplementation(() => ({ score: 1 }));369    inputText(password, "weakweakweak");370    inputText(confirm, "weakweakweak");371    await flushView();372    expect(createButton.disabled).toBe(true);373  });374375  it("Lock create-vault header pins the wordmark left with back on the right", async () => {376    const container = mount(377      <LockView378        hasVault={false}379        ready={true}380        onUnlock={vi.fn()}381        onBackToOnboarding={() => {}}382      />383    );384    const header = container.querySelector(".peach-newvault-header") as HTMLElement;385    const wordmark = header.querySelector(".peach-wordmark-stack");386    const back = header.querySelector(".peach-newvault-back");387    expect(wordmark).toBeTruthy();388    expect(back).toBeTruthy();389    // Wordmark first so it never shifts between onboarding screens; back390    // takes the opposite corner instead of displacing it.391    expect(header.firstElementChild).toBe(wordmark);392    expect(header.lastElementChild).toBe(back);393    await flushView();394  });395396  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {397    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock398    sendMessageMock.mockImplementation(399      async (msg: unknown) => {400        const m = msg as { type: string; payload?: { password?: string } };401        if (m.type === "CREATE_VAULT") return { ok: true };402        if (m.type === "GET_RECOVERY_WORDS") {403          // Return 12 fake words — must pass findInvalidWords404          return {405            ok: true,406            data: [407              "abandon", "ability", "able", "about", "above", "absent",408              "absorb", "abstract", "absurd", "abuse", "access", "accident",409            ],410          };411        }412        return { ok: true };413      }414    );415    zxcvbnMock.mockImplementation(() => ({ score: 3 }));416    const onUnlock = vi.fn(async () => undefined);417    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);418419    const password = container.querySelector("#master-password") as HTMLInputElement;420    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;421    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;422423    inputText(password, "correct horse battery staple");424    inputText(confirm, "correct horse battery staple");425    await flushView();426    expect(createButton.disabled).toBe(false);427428    pressEnter(confirm);429    await flushView();430431    expect(sendMessageMock).toHaveBeenCalledWith({432      type: "CREATE_VAULT",433      payload: { password: "correct horse battery staple" },434    });435    // After CREATE_VAULT success, the recovery word display is shown.436    // onUnlock is NOT called yet — user must first confirm their recovery words.437    expect(onUnlock).not.toHaveBeenCalled();438  });439440  it("recovery display asks for three distinct randomized word positions and confirms", async () => {441    const words12 = [442      "abandon", "ability", "able", "about", "above", "absent",443      "absorb", "abstract", "absurd", "abuse", "access", "accident",444    ];445    sendMessageMock.mockImplementation(async (msg: unknown) => {446      const type = (msg as { type?: string }).type;447      if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };448      return { ok: true };449    });450451    vi.useFakeTimers();452    const onDone = vi.fn();453    try {454      const container = mount(<RecoveryWordDisplayView onDone={onDone} />);455      // onMount fires GET_RECOVERY_WORDS; let the resolved promise + render settle456      await vi.advanceTimersByTimeAsync(0);457458      const prompts = Array.from(459        container.querySelectorAll(".peach-recovery-confirm-num")460      ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));461      expect(prompts).toHaveLength(3);462      expect(new Set(prompts).size).toBe(3);463      for (const p of prompts) {464        expect(p).toBeGreaterThanOrEqual(1);465        expect(p).toBeLessThanOrEqual(12);466      }467468      // Inputs stay disabled until the 5s countdown expires469      const inputs = Array.from(470        container.querySelectorAll(".peach-recovery-confirm-input")471      ) as HTMLInputElement[];472      expect(inputs).toHaveLength(3);473      expect(inputs[0]!.disabled).toBe(true);474      await vi.advanceTimersByTimeAsync(5000);475      expect(inputs[0]!.disabled).toBe(false);476477      prompts.forEach((pos, i) => {478        inputText(inputs[i]!, words12[pos - 1]!);479      });480481      const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;482      expect(confirmBtn.disabled).toBe(false);483      confirmBtn.click();484      await vi.advanceTimersByTimeAsync(700); // success pause before onDone485486      expect(onDone).toHaveBeenCalledTimes(1);487    } finally {488      vi.useRealTimers();489    }490  });491492  it("Recovery words flow submits the new master password and completes", async () => {493    sendMessageMock.mockImplementation(async (msg: unknown) => {494      const type = (msg as { type?: string }).type;495      if (type === "VALIDATE_RECOVERY_WORDS") {496        return { ok: true, data: { valid: true, invalid: [] } };497      }498      if (type === "RECOVER_WITH_WORDS") return { ok: true };499      return { ok: true };500    });501    const canvasContext = {502      clearRect: vi.fn(),503      fillText: vi.fn(),504      beginPath: vi.fn(),505      closePath: vi.fn(),506      arc: vi.fn(),507      fill: vi.fn(),508      stroke: vi.fn(),509      save: vi.fn(),510      restore: vi.fn(),511      translate: vi.fn(),512      rotate: vi.fn(),513      scale: vi.fn(),514      drawImage: vi.fn(),515      measureText: vi.fn(() => ({ width: 0 })),516      setTransform: vi.fn(),517      fillRect: vi.fn(),518      strokeRect: vi.fn(),519    } as unknown as CanvasRenderingContext2D;520    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);521522    const onRecoveryComplete = vi.fn(async () => undefined);523    const container = mount(524      <RecoveryWordEntryView525        onCancel={vi.fn()}526        onRecoveryComplete={onRecoveryComplete}527      />528    );529530    const recoveryWords = WORD_LIST.slice(0, 12);531    for (let i = 0; i < recoveryWords.length; i++) {532      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;533      inputText(input, recoveryWords[i]!);534    }535    await flushView();536537    findButton(container, "verify words").click();538    await flushView();539540    expect(container.textContent).toContain("new master password");541542    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;543    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;544    inputText(newPassword, "fresh-recovery-password");545    inputText(confirmPassword, "fresh-recovery-password");546    await flushView();547548    findButton(container, "recover vault").click();549    await flushView();550551    expect(sendMessageMock).toHaveBeenCalledWith({552      type: "RECOVER_WITH_WORDS",553      payload: {554        words: recoveryWords,555        newPassword: "fresh-recovery-password",556      },557    });558    expect(onRecoveryComplete).toHaveBeenCalled();559  });560561  it("Export menu surfaces both backup formats", async () => {562    const container = mount(<ExportView onBack={vi.fn()} />);563    await flushView();564565    expect(container.textContent).toContain("PeachScript");566    expect(container.textContent).toContain("CSV");567  });568569  it("Lock can open directly on backup restore from onboarding", async () => {570    const container = mount(() => (571      <LockView572        hasVault={false}573        ready={true}574        onUnlock={vi.fn()}575        initialRestore={true}576      />577    ));578    await flushView();579580    expect(container.textContent).toContain("PeachScript");581    expect(container.textContent).toContain("Codex");582    expect(container.textContent).toContain("CSV");583    expect(container.querySelector("#master-password")).toBeNull();584  });585586  it("Lock restore-tab back returns to onboarding, not the create-vault screen", async () => {587    const onBackToOnboarding = vi.fn();588    const container = mount(() => (589      <LockView590        hasVault={false}591        ready={true}592        onUnlock={vi.fn()}593        initialRestore={true}594        onBackToOnboarding={onBackToOnboarding}595      />596    ));597    await flushView();598599    (container.querySelector(".peach-lock-restore-link") as HTMLButtonElement).click();600    await flushView();601    expect(onBackToOnboarding).toHaveBeenCalledTimes(1);602  });603604  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {605    const device = {606      deviceId: "phone-1",607      deviceName: "June's Phone",608      deviceType: "android" as const,609      longTermPublicKeyHex: "a".repeat(64),610      signingPublicKeyHex: "b".repeat(64),611      pairedAt: 1,612      trustSource: "direct" as const,613    };614    sendMessageMock.mockImplementation(async (msg: unknown) => {615      const m = msg as { type: string };616      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };617      return { ok: true, data: null };618    });619620    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);621    for (let i = 0; i < 30; i++) await Promise.resolve();622623    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>624      /sync/i.test(b.textContent ?? ""),625    );626    openSync?.click();627    for (let i = 0; i < 30; i++) await Promise.resolve();628629    const remove = Array.from(container.querySelectorAll("button")).find(630      (b) => (b.textContent ?? "").trim() === "remove",631    );632    expect(remove).toBeTruthy();633634    // Guard against a vacuous pass: the disclosure must not already be on635    // screen before the user asks to remove anything.636    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();637638    remove!.click();639    for (let i = 0; i < 10; i++) await Promise.resolve();640641    // Revocation must never be presented as securing the vault against the642    // removed device — recovery words survive it and restore everything.643    const note = container.querySelector(".peach-unpair-confirm-note");644    expect(note).toBeTruthy();645    const text = note!.textContent ?? "";646    expect(text).toContain("master password");647    expect(text).toContain("recovery words");648    expect(text).toContain("future changes");649  });650651  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {652    const device = {653      deviceId: "phone-1",654      deviceName: "June's Phone",655      deviceType: "android" as const,656      longTermPublicKeyHex: "a".repeat(64),657      signingPublicKeyHex: "b".repeat(64),658      pairedAt: 1,659      trustSource: "direct" as const,660    };661    let paired: typeof device[] = [device];662    sendMessageMock.mockImplementation(async (msg: any) => {663      if (msg?.type === "LIST_PAIRED_DEVICES") {664        return { ok: true, data: { devices: [...paired] } };665      }666      if (msg?.type === "UNPAIR_DEVICE") {667        // Mirror the real handler: UNPAIR_DEVICE drops the row from the668        // registry. The next LIST_PAIRED_DEVICES must not see it.669        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);670        return { ok: true };671      }672      if (msg?.type === "RENAME_PAIRED_DEVICE") {673        paired = paired.map((d) =>674          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,675        );676        return { ok: true };677      }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();683    Array.from(container.querySelectorAll("button"))684      .find((b) => /sync/i.test(b.textContent ?? ""))685      ?.click();686    for (let i = 0; i < 30; i++) await Promise.resolve();687688    // The list itself: extracting the relay selector must not take the hub with it.689    expect(container.textContent).toContain("PAIRED DEVICES");690    expect(container.textContent).toContain("June's Phone");691    expect(container.textContent).toContain("Android");692    Array.from(container.querySelectorAll("button"))693      .find((b) => (b.textContent ?? "").trim() === "rename")!694      .click();695    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;696    renameInput.value = "Travel phone";697    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));698    Array.from(container.querySelectorAll("button"))699      .find((b) => (b.textContent ?? "").trim() === "save")!700      .click();701    for (let i = 0; i < 30; i++) await Promise.resolve();702    expect(sendMessageMock).toHaveBeenCalledWith({703      type: "RENAME_PAIRED_DEVICE",704      payload: { deviceId: "phone-1", name: "Travel phone" },705    });706    expect(container.textContent).toContain("Travel phone");707    const syncNowButton = container.querySelector<HTMLButtonElement>(708      'button[aria-label="Sync this vault now"]',709    )!;710    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));711    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;712    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;713    expect(714      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,715    ).toBeTruthy();716    expect(717      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,718    ).toBeTruthy();719720    Array.from(container.querySelectorAll("button"))721      .find((b) => (b.textContent ?? "").trim() === "remove")!722      .click();723    for (let i = 0; i < 10; i++) await Promise.resolve();724    const confirm = Array.from(725      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),726    ).find((b) => (b.textContent ?? "").trim() === "remove");727    confirm!.click();728    for (let i = 0; i < 30; i++) await Promise.resolve();729730    expect(sendMessageMock).toHaveBeenCalledWith({731      type: "UNPAIR_DEVICE",732      payload: { deviceId: "phone-1" },733    });734    // The list must be refreshed and the device must be gone. The regression735    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but736    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.737    expect(container.textContent).not.toContain("June's Phone");738    expect(container.textContent).not.toContain("PAIRED DEVICES");739  });740741  it("Settings keeps connection and recovery details collapsed until requested", async () => {742    sendMessageMock.mockImplementation(async (msg: any) => {743      if (msg?.type === "LIST_PAIRED_DEVICES") {744        return { ok: true, data: { devices: [] } };745      }746      return { ok: true, data: null };747    });748749    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);750    for (let i = 0; i < 30; i++) await Promise.resolve();751    Array.from(container.querySelectorAll("button"))752      .find((button) => /sync/i.test(button.textContent ?? ""))753      ?.click();754    for (let i = 0; i < 20; i++) await Promise.resolve();755756    expect(container.textContent).toContain("SYNC STATUS");757    expect(container.textContent).toContain("ADD A DEVICE");758    expect(container.textContent).not.toContain("Public relay");759    expect(container.textContent).not.toContain("Rotate sync identity");760761    const connection = Array.from(container.querySelectorAll("button"))762      .find((button) => button.textContent?.includes("CONNECTION"));763    connection?.click();764    await Promise.resolve();765    expect(container.textContent).toContain("Public relay");766767    const recovery = Array.from(container.querySelectorAll("button"))768      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));769    recovery?.click();770    await Promise.resolve();771    expect(container.textContent).toContain("Rotate sync identity");772  });773774  it("Settings requires current, new, and confirm passwords before enabling save", async () => {775    sendMessageMock.mockResolvedValue({ ok: true, data: null });776    const container = mount(777      <SettingsView778        entriesCount={1}779        breachMonitoring={true}780        lockOnClose={true}781        autoLockMinutes={5}782        trashRetentionDays={30}783        theme="dark"784        accentColor="peach"785        textSize="medium"786        reduceMotion={false}787        onBreachMonitoringChange={vi.fn()}788        onLockOnCloseChange={vi.fn()}789        onAutoLockMinutesChange={vi.fn()}790        onTrashRetentionChange={vi.fn()}791        onThemeChange={vi.fn()}792        onAccentChange={vi.fn()}793        onTextSizeChange={vi.fn()}794        onReduceMotionChange={vi.fn()}795        shortcuts={DEFAULT_BINDINGS}796        onShortcutsChange={vi.fn()}797        onBack={vi.fn()}798        onImport={vi.fn()}799        onExport={vi.fn()}800        onBreachCheckDone={vi.fn()}801      />802    );803    await flushView();804805    expect(container.querySelectorAll(".stk-card").length).toBe(7);806    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]807    // "open settings to card I (Peach Pro) instead of card II (Protection)".808    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");809810    const changeButton = Array.from(container.querySelectorAll("button")).find(811      (candidate) => candidate.textContent?.trim() === "change"812    ) as HTMLButtonElement;813    changeButton.click();814    await flushView();815816    const saveButton = Array.from(container.querySelectorAll("button")).find(817      (candidate) => candidate.textContent?.trim() === "save new password"818    ) as HTMLButtonElement;819    expect(saveButton.disabled).toBe(true);820821    const inputs = container.querySelectorAll('input[type="password"]');822    inputText(inputs[0] as HTMLInputElement, "current-pass");823    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");824    await flushView();825    expect(saveButton.disabled).toBe(true);826827    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");828    await flushView();829    expect(saveButton.disabled).toBe(false);830  });831832  it("Settings feedback highlights the selected category and hides the form after submit", async () => {833    sendMessageMock.mockResolvedValue({ ok: true, data: null });834    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({835      ok: true,836      json: async () => ({ success: true }),837    }));838    vi.stubGlobal("fetch", fetchMock);839840    const container = mount(841      <SettingsView842        entriesCount={1}843        breachMonitoring={true}844        lockOnClose={true}845        autoLockMinutes={5}846        trashRetentionDays={30}847        theme="dark"848        accentColor="peach"849        textSize="medium"850        reduceMotion={false}851        initialSection="feedback"852        onBreachMonitoringChange={vi.fn()}853        onLockOnCloseChange={vi.fn()}854        onAutoLockMinutesChange={vi.fn()}855        onTrashRetentionChange={vi.fn()}856        onThemeChange={vi.fn()}857        onAccentChange={vi.fn()}858        onTextSizeChange={vi.fn()}859        onReduceMotionChange={vi.fn()}860        shortcuts={DEFAULT_BINDINGS}861        onShortcutsChange={vi.fn()}862        onBack={vi.fn()}863        onImport={vi.fn()}864        onExport={vi.fn()}865        onBreachCheckDone={vi.fn()}866      />867    );868    await flushView();869870    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;871    expect(feedbackCard.classList.contains("is-active")).toBe(true);872873    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];874    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;875    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;876    expect(bugButton.getAttribute("aria-pressed")).toBe("true");877    expect(featureButton.getAttribute("aria-pressed")).toBe("false");878879    featureButton.click();880    await flushView();881882    expect(bugButton.getAttribute("aria-pressed")).toBe("false");883    expect(featureButton.getAttribute("aria-pressed")).toBe("true");884    expect(featureButton.classList.contains("is-active")).toBe(true);885886    inputText(887      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,888      '<script>alert("xss")</script> Please make this easier to see & safer.'889    );890    const sendButton = Array.from(container.querySelectorAll("button")).find(891      (candidate) => candidate.textContent?.trim() === "Send"892    ) as HTMLButtonElement;893    sendButton.click();894895    await vi.waitFor(() => {896      expect(container.textContent).toContain("Thank you");897    });898899    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));900    expect(requestBody.subject).toContain("Feature request");901    expect(requestBody.message).toBe(902      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."903    );904    expect(requestBody.message).not.toContain("<script>");905    expect(container.textContent).toContain("Someone will process it soon");906    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();907    expect(container.querySelector(".peach-feedback-submit")).toBeNull();908  });909910  it("Settings keeps license activation on the full-screen celebration path", async () => {911    const onLicenseChange = vi.fn(async () => undefined);912    sendMessageMock.mockImplementation(async (msg: unknown) => {913      const type = (msg as { type?: string }).type;914      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };915      if (type === "ACTIVATE_LICENSE") {916        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };917      }918      if (type === "GET_ENTRIES") return { ok: true, data: [] };919      return { ok: true, data: null };920    });921922    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);923    await flushView();924925    inputText(926      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,927      "PEACH-TEST-KEY",928    );929    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;930    apply.click();931932    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));933    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);934    expect(container.textContent).not.toContain("thank you ✦");935  });936937  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {938    sendMessageMock.mockImplementation(async (msg: unknown) => {939      const type = (msg as { type?: string }).type;940      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };941      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };942      if (type === "GET_ENTRIES") return { ok: true, data: [] };943      return { ok: true, data: null };944    });945946    const onLicenseChangeFromStar = vi.fn();947    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);948    await flushView();949    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();950    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);951    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();952953    const onLicenseChangeFromPeach = vi.fn();954    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);955    await flushView();956    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();957    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);958    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();959  });960961  it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {962    sendMessageMock.mockResolvedValue({ ok: true, data: null });963    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({964      ok: true,965      json: async () => ({ success: true }),966    }));967    vi.stubGlobal("fetch", fetchMock);968    // Firefox-style permissions API reporting the optional consent as absent.969    // The old code gated send behind this and silently returned to idle, which970    // read as a dead Send button. The gate is gone: Send must always send.971    vi.stubGlobal("browser", {972      permissions: {973        getAll: vi.fn(async () => ({ data_collection: [] })),974        request: vi.fn(async () => false),975      },976    });977978    const container = mount(979      <SettingsView980        entriesCount={1}981        breachMonitoring={true}982        lockOnClose={true}983        autoLockMinutes={5}984        trashRetentionDays={30}985        theme="dark"986        accentColor="peach"987        textSize="medium"988        reduceMotion={false}989        initialSection="feedback"990        onBreachMonitoringChange={vi.fn()}991        onLockOnCloseChange={vi.fn()}992        onAutoLockMinutesChange={vi.fn()}993        onTrashRetentionChange={vi.fn()}994        onThemeChange={vi.fn()}995        onAccentChange={vi.fn()}996        onTextSizeChange={vi.fn()}997        onReduceMotionChange={vi.fn()}998        shortcuts={DEFAULT_BINDINGS}999        onShortcutsChange={vi.fn()}1000        onBack={vi.fn()}1001        onImport={vi.fn()}1002        onExport={vi.fn()}1003        onBreachCheckDone={vi.fn()}1004      />1005    );1006    await flushView();10071008    inputText(1009      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1010      "Please improve the feedback flow."1011    );1012    const sendButton = Array.from(container.querySelectorAll("button")).find(1013      (candidate) => candidate.textContent?.trim() === "Send"1014    ) as HTMLButtonElement;1015    sendButton.click();10161017    await vi.waitFor(() => {1018      expect(fetchMock).toHaveBeenCalledTimes(1);1019    });1020    await vi.waitFor(() => {1021      expect(container.textContent).toContain("Thank you");1022    });1023    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1024  });10251026  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1027    sendMessageMock.mockImplementation(async (msg: unknown) => {1028      const m = msg as { type: string };1029      if (m.type === "GET_RECOVERY_WORDS") {1030        return {1031          ok: true,1032          data: [1033            "abandon", "ability", "able", "about", "above", "absent",1034            "absorb", "abstract", "absurd", "abuse", "access", "accident",1035          ],1036        };1037      }1038      return { ok: true, data: null };1039    });1040    const onAutoLockMinutesChange = vi.fn();1041    const onThemeChange = vi.fn();1042    const container = mount(1043      <SettingsView1044        entriesCount={1}1045        breachMonitoring={true}1046        lockOnClose={true}1047        autoLockMinutes={5}1048        trashRetentionDays={30}1049        theme="dark"1050        accentColor="peach"1051        textSize="medium"1052        reduceMotion={false}1053        onBreachMonitoringChange={vi.fn()}1054        onLockOnCloseChange={vi.fn()}1055        onAutoLockMinutesChange={onAutoLockMinutesChange}1056        onTrashRetentionChange={vi.fn()}1057        onThemeChange={onThemeChange}1058        onAccentChange={vi.fn()}1059        onTextSizeChange={vi.fn()}1060        onReduceMotionChange={vi.fn()}1061        shortcuts={DEFAULT_BINDINGS}1062        onShortcutsChange={vi.fn()}1063        onBack={vi.fn()}1064        onImport={vi.fn()}1065        onExport={vi.fn()}1066        onBreachCheckDone={vi.fn()}1067      />1068    );1069    await flushView();1070    sendMessageMock.mockClear();10711072    // ── Auto-lock + theme changes (before password change) ─────────────1073    const selects = container.querySelectorAll("select");1074    changeSelect(selects[0] as HTMLSelectElement, "15");1075    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);10761077    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1078    appearanceCard.click();1079    await flushView();1080    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1081    const themeButton = Array.from(container.querySelectorAll("button")).find(1082      (candidate) => candidate.textContent?.trim() === "Switch to light"1083    ) as HTMLButtonElement;1084    themeButton.click();1085    expect(onThemeChange).toHaveBeenCalledWith("light");10861087    // ── Password change → recovery words display ────────────────────────1088    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1089    protectionCard.click();1090    await flushView();10911092    const changeButton = Array.from(container.querySelectorAll("button")).find(1093      (candidate) => candidate.textContent?.trim() === "change"1094    ) as HTMLButtonElement;1095    changeButton.click();1096    await flushView();10971098    const inputs = container.querySelectorAll('input[type="password"]');1099    inputText(inputs[0] as HTMLInputElement, "current-pass");1100    inputText(inputs[1] as HTMLInputElement, "new-password-123");1101    inputText(inputs[2] as HTMLInputElement, "new-password-123");1102    await flushView();11031104    const saveButton = Array.from(container.querySelectorAll("button")).find(1105      (candidate) => candidate.textContent?.trim() === "save new password"1106    ) as HTMLButtonElement;1107    saveButton.click();1108    await flushView();11091110    expect(sendMessageMock).toHaveBeenCalledWith({1111      type: "CHANGE_PASSWORD",1112      payload: {1113        currentPassword: "current-pass",1114        newPassword: "new-password-123",1115      },1116    });11171118    // After a successful password change the recovery word display replaces settings.1119    expect(container.textContent).toContain("save your recovery words");1120    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1121  });11221123  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1124    sendMessageMock.mockResolvedValue({ ok: true });1125    const container = mount(1126      <EntryView1127        entry={sampleEntry()}1128        onDelete={vi.fn()}1129        onFieldSaved={vi.fn(async () => undefined)}1130      />1131    );11321133    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1134    passwordButton.click();1135    await flushView();11361137    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1138      type: "COPY",1139      payload: { text: "secret123", label: "password", alreadyWritten: false },1140    });1141    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1142      type: "FIRE_CANARY_PING",1143      payload: {1144        webhookUrl: "https://example.com/hook",1145        entryTitle: "GitHub",1146        trigger: "password_copied",1147      },1148    });11491150    sendMessageMock.mockClear();1151    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1152    usernameButton.click();1153    await flushView();11541155    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1156      type: "COPY",1157      payload: { text: "june", label: "username", alreadyWritten: false },1158    });1159    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1160      type: "FIRE_CANARY_PING",1161      payload: {1162        webhookUrl: "https://example.com/hook",1163        entryTitle: "GitHub",1164        trigger: "username_copied",1165      },1166    });1167  });11681169  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1170    const container = mount(1171      <EntryView1172        entry={sampleEntry({1173          type: "passkey",1174          username: undefined,1175          password: undefined,1176          totp: undefined,1177          canary: undefined,1178          passkey: {1179            rpId: "vercel.com",1180            rpName: "Vercel",1181            credentialId: "cred-1",1182            publicKeyCose: "cose",1183            userHandle: "handle",1184            userName: "june",1185            userDisplayName: "June",1186            algorithm: -7,1187            signCount: 0,1188            aaguid: "",1189            backupEligible: false,1190            backupState: false,1191            createdAt: 1,1192          },1193        })}1194        onDelete={vi.fn()}1195        onFieldSaved={vi.fn(async () => undefined)}1196      />1197    );11981199    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1200      (button) => button.textContent?.includes("Advanced")1201    ) as HTMLButtonElement | undefined;1202    expect(advanced).toBeDefined();1203    expect(advanced?.textContent).toContain("+ Advanced");12041205    advanced!.click();1206    await flushView();12071208    expect(container.textContent).toContain("passkey · vercel.com");1209  });12101211  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1212    sendMessageMock.mockResolvedValue({ ok: true });1213    const onFieldSaved = vi.fn(async () => undefined);1214    const container = mount(1215      <EntryView1216        entry={sampleEntry()}1217        onDelete={vi.fn()}1218        onFieldSaved={onFieldSaved}1219      />1220    );12211222    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1223    passwordValue.click();1224    await flushView();12251226    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1227    inputText(passwordInput, "");1228    await flushView();12291230    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1231    saveButton.click();1232    await flushView();12331234    expect(sendMessageMock).not.toHaveBeenCalledWith(1235      expect.objectContaining({ type: "UPDATE_ENTRY" })1236    );1237    expect(onFieldSaved).not.toHaveBeenCalled();12381239    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1240    totpMain.click();12411242    await vi.waitFor(() => {1243      expect(sendMessageMock).toHaveBeenLastCalledWith({1244        type: "COPY",1245        payload: { text: "123456", label: "totp", alreadyWritten: false },1246      });1247    }, { timeout: 500 });1248  });12491250  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1251    sendMessageMock.mockResolvedValue({ ok: true });1252    const container = mount(1253      <EntryView1254        entry={sampleEntry()}1255        onDelete={vi.fn()}1256        onFieldSaved={vi.fn(async () => undefined)}1257      />1258    );12591260    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1261    usernameValue.click();1262    await flushView();12631264    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1265    usernameInput.blur();1266    await flushView();12671268    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1269    expect(sendMessageMock).not.toHaveBeenCalledWith(1270      expect.objectContaining({ type: "UPDATE_ENTRY" })1271    );1272  });12731274  it("Entry keeps the full used-email list available while editing an email", async () => {1275    const container = mount(1276      <EntryView1277        entry={sampleEntry({ email: "[email protected]" })}1278        entries={[1279          sampleEntry({ email: "[email protected]" }),1280          sampleEntry({ id: "entry-2", email: "[email protected]" }),1281          sampleEntry({ id: "entry-3", username: "duckuccino" }),1282        ]}1283        onDelete={vi.fn()}1284        onFieldSaved={vi.fn(async () => undefined)}1285      />1286    );12871288    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1289      (card) => card.querySelector(".peach-label")?.textContent === "email"1290    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1291    emailValue.click();1292    await flushView();12931294    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1295      .map((element) => element.textContent);1296    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1297  });12981299  it("Entry lets TOTP secrets be edited inline", async () => {1300    sendMessageMock.mockResolvedValue({ ok: true });1301    const onFieldSaved = vi.fn(async () => undefined);1302    const container = mount(1303      <EntryView1304        entry={sampleEntry()}1305        onDelete={vi.fn()}1306        onFieldSaved={onFieldSaved}1307      />1308    );13091310    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1311    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1312    await flushView();13131314    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1315    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1316    await flushView();13171318    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1319    saveButton.click();1320    await flushView();13211322    expect(sendMessageMock).toHaveBeenCalledWith({1323      type: "UPDATE_ENTRY",1324      payload: {1325        id: "entry-1",1326        patch: { totp: "NB2W45DFOIZA====" },1327      },1328    });1329    expect(onFieldSaved).toHaveBeenCalled();1330  });13311332  it("Vault seeds and focuses search when a search seed is provided", async () => {1333    const container = mount(1334      <VaultView1335        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1336        sortOrder="url-first"1337        searchSeed="g"1338        searchSeedNonce={1}1339        onSelectEntry={vi.fn()}1340        onSortOrderChange={vi.fn()}1341        onAdd={vi.fn()}1342        onClose={vi.fn()}1343      />1344    );13451346    await flushView();13471348    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1349    expect(search.value).toBe("g");1350    expect(document.activeElement).toBe(search);1351  });13521353  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1354    parseImportSourceMock.mockResolvedValue(1355      sampleImportResult({1356        entries: [1357          {1358            type: "login",1359            title: "Imported site",1360            username: "imported-user",1361            password: "imported-pass",1362            url: "https://imported.test",1363          },1364        ],1365      })1366    );1367    sendMessageMock.mockImplementation(async (msg: unknown) => {1368      const type = (msg as { type?: string }).type;1369      if (type === "GET_ENTRIES") {1370        return {1371          ok: true,1372          data: [1373            sampleEntry({1374              id: "existing-1",1375              title: "Imported site",1376              username: "imported-user",1377              url: "https://imported.test",1378            }),1379          ],1380        };1381      }1382      if (type === "BATCH_ADD_ENTRIES") {1383        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1384      }1385      return { ok: true };1386    });1387    const onImport = vi.fn(async () => undefined);1388    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1389    await flushView();1390    sendMessageMock.mockClear();13911392    await selectImportFile(container);1393    findButton(container, "Import 1").click();1394    await flushView();13951396    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1397    expect(sendMessageMock).toHaveBeenCalledWith({1398      type: "BATCH_ADD_ENTRIES",1399      payload: {1400        entries: [1401          expect.objectContaining({1402            title: "Imported site",1403            username: "imported-user",1404            password: "imported-pass",1405            url: "https://imported.test",1406          }),1407        ],1408      },1409    });1410    expect(onImport).toHaveBeenCalled();1411    expect(container.textContent).toContain("1 entry appears to be duplicates");1412  });14131414  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1415    parseImportSourceMock.mockResolvedValue(sampleImportResult());1416    sendMessageMock.mockImplementation(async (msg: unknown) => {1417      const type = (msg as { type?: string }).type;1418      if (type === "GET_ENTRIES") {1419        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1420      }1421      if (type === "CLEAR_VAULT_ENTRIES") {1422        return { ok: true };1423      }1424      if (type === "BATCH_ADD_ENTRIES") {1425        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1426      }1427      return { ok: true };1428    });1429    const onImport = vi.fn(async () => undefined);1430    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1431    await flushView();1432    sendMessageMock.mockClear();14331434    await selectImportFile(container);1435    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1436    await flushView();14371438    findButton(container, "Replace vault with 1").click();1439    await flushView();14401441    expect(sendMessageMock).not.toHaveBeenCalledWith(1442      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1443    );1444    expect(container.textContent).toContain("Replace forever? This cannot be undone.");14451446    findButton(container, "Yes, replace forever").click();1447    await flushView();14481449    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1450    expect(sendMessageMock).toHaveBeenCalledWith({1451      type: "BATCH_ADD_ENTRIES",1452      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1453    });1454    expect(onImport).toHaveBeenCalled();1455  });14561457  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1458    parseImportSourceMock.mockResolvedValue(sampleImportResult());1459    sendMessageMock.mockImplementation(async (msg: unknown) => {1460      const type = (msg as { type?: string }).type;1461      if (type === "CREATE_VAULT") return { ok: true };1462      if (type === "BATCH_ADD_ENTRIES") {1463        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1464      }1465      return { ok: true };1466    });1467    const onImport = vi.fn(async () => undefined);1468    const container = mount(1469      <ImportView1470        mode="onboarding"1471        replacesExistingVault={true}1472        onBack={vi.fn()}1473        onImport={onImport}1474      />1475    );14761477    await selectImportFile(container);14781479    const createButton = findButton(container, "Create vault + import 1");1480    expect(createButton.disabled).toBe(true);1481    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1482    expect(container.textContent).toContain("Use at least 8 characters");14831484    const masterInput = container.querySelector(1485      'input[placeholder="new master password"]'1486    ) as HTMLInputElement;1487    const confirmInput = container.querySelector(1488      'input[placeholder="confirm master password"]'1489    ) as HTMLInputElement;1490    const revealButton = container.querySelector(1491      'button[aria-label="Show passwords"]'1492    ) as HTMLButtonElement;1493    expect(revealButton).toBeTruthy();1494    revealButton.click();1495    await flushView();1496    expect(masterInput.type).toBe("text");1497    expect(confirmInput.type).toBe("text");14981499    inputText(masterInput, "fresh-master-password");1500    inputText(confirmInput, "fresh-master-password");1501    await flushView();15021503    expect(createButton.disabled).toBe(false);1504    createButton.click();1505    await flushView();15061507    expect(sendMessageMock).toHaveBeenCalledWith({1508      type: "CREATE_VAULT",1509      payload: { password: "fresh-master-password" },1510    });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 encrypted backups reparse with the backup password before import", async () => {1519    parseImportSourceMock1520      .mockResolvedValueOnce(1521        sampleImportResult({1522          format: "peach-json",1523          label: "Peach encrypted backup",1524          entries: [],1525          totalItems: 0,1526          requiresPassword: true,1527        })1528      )1529      .mockResolvedValueOnce(1530        sampleImportResult({1531          format: "peach-json",1532          label: "Peach encrypted backup",1533        })1534      );1535    sendMessageMock.mockImplementation(async (msg: unknown) => {1536      const type = (msg as { type?: string }).type;1537      if (type === "GET_ENTRIES") return { ok: true, data: [] };1538      if (type === "BATCH_ADD_ENTRIES") {1539        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1540      }1541      return { ok: true };1542    });1543    const onImport = vi.fn(async () => undefined);1544    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1545    await flushView();1546    sendMessageMock.mockClear();15471548    await selectImportFile(container, '{"type":"peach-export"}');1549    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1550    inputText(password, "backup-passphrase");1551    pressEnter(password);1552    await flushView();1553    await flushView();15541555    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1556    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");15571558    findButton(container, "Import 1").click();1559    await flushView();15601561    expect(sendMessageMock).toHaveBeenCalledWith({1562      type: "BATCH_ADD_ENTRIES",1563      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1564    });1565    expect(onImport).toHaveBeenCalled();1566  });1567});