contracts.test.tsx

59.1 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 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-restore-back") as HTMLButtonElement).click();600    await flushView();601    expect(onBackToOnboarding).toHaveBeenCalledTimes(1);602  });603604  it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {605    const container = mount(() => (606      <LockView607        hasVault={false}608        ready={true}609        onUnlock={vi.fn()}610        initialRestore={true}611        initialRestoreSource="symbols"612      />613    ));614    await flushView();615616    expect(container.querySelector(".peach-restore-script")).toBeTruthy();617    expect(container.querySelector("#restore-backup-pass")).toBeTruthy();618    expect(container.querySelector(".peach-restore-sources")).toBeNull();619  });620621  it("Lock restore resumes on the picked CSV source, not the picker", async () => {622    const container = mount(() => (623      <LockView624        hasVault={false}625        ready={true}626        onUnlock={vi.fn()}627        initialRestore={true}628        initialRestoreSource="csv"629      />630    ));631    await flushView();632633    expect(container.querySelector("#restore-csv-pass")).toBeTruthy();634    expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();635    expect(container.querySelector(".peach-restore-sources")).toBeNull();636  });637638  it("Lock restore PeachScript form loads a dropped .peach file", async () => {639    const container = mount(() => (640      <LockView641        hasVault={false}642        ready={true}643        onUnlock={vi.fn()}644        initialRestore={true}645        initialRestoreSource="symbols"646      />647    ));648    await flushView();649650    const form = container.querySelector(".peach-restore-form") as HTMLElement;651    expect(form).toBeTruthy();652653    // The OS file picker dismisses the action popup, so drag-and-drop is the654    // file path that survives; a drop must land the file in the script field.655    const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });656    const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {657      dataTransfer?: { files: File[] };658    };659    drop.dataTransfer = { files: [file] };660    form.dispatchEvent(drop);661    await flushView();662663    const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;664    expect(script.value).toBe("dropped-peach-backup");665  });666667  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {668    const device = {669      deviceId: "phone-1",670      deviceName: "June's Phone",671      deviceType: "android" as const,672      longTermPublicKeyHex: "a".repeat(64),673      signingPublicKeyHex: "b".repeat(64),674      pairedAt: 1,675      trustSource: "direct" as const,676    };677    sendMessageMock.mockImplementation(async (msg: unknown) => {678      const m = msg as { type: string };679      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };680      return { ok: true, data: null };681    });682683    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);684    for (let i = 0; i < 30; i++) await Promise.resolve();685686    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>687      /sync/i.test(b.textContent ?? ""),688    );689    openSync?.click();690    for (let i = 0; i < 30; i++) await Promise.resolve();691692    const remove = Array.from(container.querySelectorAll("button")).find(693      (b) => (b.textContent ?? "").trim() === "remove",694    );695    expect(remove).toBeTruthy();696697    // Guard against a vacuous pass: the disclosure must not already be on698    // screen before the user asks to remove anything.699    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();700701    remove!.click();702    for (let i = 0; i < 10; i++) await Promise.resolve();703704    // Revocation must never be presented as securing the vault against the705    // removed device — recovery words survive it and restore everything.706    const note = container.querySelector(".peach-unpair-confirm-note");707    expect(note).toBeTruthy();708    const text = note!.textContent ?? "";709    expect(text).toContain("master password");710    expect(text).toContain("recovery words");711    expect(text).toContain("future changes");712  });713714  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {715    const device = {716      deviceId: "phone-1",717      deviceName: "June's Phone",718      deviceType: "android" as const,719      longTermPublicKeyHex: "a".repeat(64),720      signingPublicKeyHex: "b".repeat(64),721      pairedAt: 1,722      trustSource: "direct" as const,723    };724    let paired: typeof device[] = [device];725    sendMessageMock.mockImplementation(async (msg: any) => {726      if (msg?.type === "LIST_PAIRED_DEVICES") {727        return { ok: true, data: { devices: [...paired] } };728      }729      if (msg?.type === "UNPAIR_DEVICE") {730        // Mirror the real handler: UNPAIR_DEVICE drops the row from the731        // registry. The next LIST_PAIRED_DEVICES must not see it.732        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);733        return { ok: true };734      }735      if (msg?.type === "RENAME_PAIRED_DEVICE") {736        paired = paired.map((d) =>737          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,738        );739        return { ok: true };740      }741      return { ok: true, data: null };742    });743744    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);745    for (let i = 0; i < 30; i++) await Promise.resolve();746    Array.from(container.querySelectorAll("button"))747      .find((b) => /sync/i.test(b.textContent ?? ""))748      ?.click();749    for (let i = 0; i < 30; i++) await Promise.resolve();750751    // The list itself: extracting the relay selector must not take the hub with it.752    expect(container.textContent).toContain("PAIRED DEVICES");753    expect(container.textContent).toContain("June's Phone");754    expect(container.textContent).toContain("Android");755    Array.from(container.querySelectorAll("button"))756      .find((b) => (b.textContent ?? "").trim() === "rename")!757      .click();758    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;759    renameInput.value = "Travel phone";760    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));761    Array.from(container.querySelectorAll("button"))762      .find((b) => (b.textContent ?? "").trim() === "save")!763      .click();764    for (let i = 0; i < 30; i++) await Promise.resolve();765    expect(sendMessageMock).toHaveBeenCalledWith({766      type: "RENAME_PAIRED_DEVICE",767      payload: { deviceId: "phone-1", name: "Travel phone" },768    });769    expect(container.textContent).toContain("Travel phone");770    const syncNowButton = container.querySelector<HTMLButtonElement>(771      'button[aria-label="Sync this vault now"]',772    )!;773    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));774    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;775    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;776    expect(777      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,778    ).toBeTruthy();779    expect(780      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,781    ).toBeTruthy();782783    Array.from(container.querySelectorAll("button"))784      .find((b) => (b.textContent ?? "").trim() === "remove")!785      .click();786    for (let i = 0; i < 10; i++) await Promise.resolve();787    const confirm = Array.from(788      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),789    ).find((b) => (b.textContent ?? "").trim() === "remove");790    confirm!.click();791    for (let i = 0; i < 30; i++) await Promise.resolve();792793    expect(sendMessageMock).toHaveBeenCalledWith({794      type: "UNPAIR_DEVICE",795      payload: { deviceId: "phone-1" },796    });797    // The list must be refreshed and the device must be gone. The regression798    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but799    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.800    expect(container.textContent).not.toContain("June's Phone");801    expect(container.textContent).not.toContain("PAIRED DEVICES");802  });803804  it("Settings keeps connection and recovery details collapsed until requested", async () => {805    sendMessageMock.mockImplementation(async (msg: any) => {806      if (msg?.type === "LIST_PAIRED_DEVICES") {807        return { ok: true, data: { devices: [] } };808      }809      return { ok: true, data: null };810    });811812    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);813    for (let i = 0; i < 30; i++) await Promise.resolve();814    Array.from(container.querySelectorAll("button"))815      .find((button) => /sync/i.test(button.textContent ?? ""))816      ?.click();817    for (let i = 0; i < 20; i++) await Promise.resolve();818819    expect(container.textContent).toContain("SYNC STATUS");820    expect(container.textContent).toContain("ADD A DEVICE");821    expect(container.textContent).not.toContain("Public relay");822    expect(container.textContent).not.toContain("Rotate sync identity");823824    const connection = Array.from(container.querySelectorAll("button"))825      .find((button) => button.textContent?.includes("CONNECTION"));826    connection?.click();827    await Promise.resolve();828    expect(container.textContent).toContain("Public relay");829830    const recovery = Array.from(container.querySelectorAll("button"))831      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));832    recovery?.click();833    await Promise.resolve();834    expect(container.textContent).toContain("Rotate sync identity");835  });836837  it("Settings requires current, new, and confirm passwords before enabling save", async () => {838    sendMessageMock.mockResolvedValue({ ok: true, data: null });839    const container = mount(840      <SettingsView841        entriesCount={1}842        breachMonitoring={true}843        lockOnClose={true}844        autoLockMinutes={5}845        trashRetentionDays={30}846        theme="dark"847        accentColor="peach"848        textSize="medium"849        reduceMotion={false}850        onBreachMonitoringChange={vi.fn()}851        onLockOnCloseChange={vi.fn()}852        onAutoLockMinutesChange={vi.fn()}853        onTrashRetentionChange={vi.fn()}854        onThemeChange={vi.fn()}855        onAccentChange={vi.fn()}856        onTextSizeChange={vi.fn()}857        onReduceMotionChange={vi.fn()}858        shortcuts={DEFAULT_BINDINGS}859        onShortcutsChange={vi.fn()}860        onBack={vi.fn()}861        onImport={vi.fn()}862        onExport={vi.fn()}863        onBreachCheckDone={vi.fn()}864      />865    );866    await flushView();867868    expect(container.querySelectorAll(".stk-card").length).toBe(7);869    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]870    // "open settings to card I (Peach Pro) instead of card II (Protection)".871    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");872873    const changeButton = Array.from(container.querySelectorAll("button")).find(874      (candidate) => candidate.textContent?.trim() === "change"875    ) as HTMLButtonElement;876    changeButton.click();877    await flushView();878879    const saveButton = Array.from(container.querySelectorAll("button")).find(880      (candidate) => candidate.textContent?.trim() === "save new password"881    ) as HTMLButtonElement;882    expect(saveButton.disabled).toBe(true);883884    const inputs = container.querySelectorAll('input[type="password"]');885    inputText(inputs[0] as HTMLInputElement, "current-pass");886    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");887    await flushView();888    expect(saveButton.disabled).toBe(true);889890    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");891    await flushView();892    expect(saveButton.disabled).toBe(false);893  });894895  it("Settings feedback highlights the selected category and hides the form after submit", async () => {896    sendMessageMock.mockResolvedValue({ ok: true, data: null });897    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({898      ok: true,899      json: async () => ({ success: true }),900    }));901    vi.stubGlobal("fetch", fetchMock);902903    const container = mount(904      <SettingsView905        entriesCount={1}906        breachMonitoring={true}907        lockOnClose={true}908        autoLockMinutes={5}909        trashRetentionDays={30}910        theme="dark"911        accentColor="peach"912        textSize="medium"913        reduceMotion={false}914        initialSection="feedback"915        onBreachMonitoringChange={vi.fn()}916        onLockOnCloseChange={vi.fn()}917        onAutoLockMinutesChange={vi.fn()}918        onTrashRetentionChange={vi.fn()}919        onThemeChange={vi.fn()}920        onAccentChange={vi.fn()}921        onTextSizeChange={vi.fn()}922        onReduceMotionChange={vi.fn()}923        shortcuts={DEFAULT_BINDINGS}924        onShortcutsChange={vi.fn()}925        onBack={vi.fn()}926        onImport={vi.fn()}927        onExport={vi.fn()}928        onBreachCheckDone={vi.fn()}929      />930    );931    await flushView();932933    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;934    expect(feedbackCard.classList.contains("is-active")).toBe(true);935936    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];937    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;938    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;939    expect(bugButton.getAttribute("aria-pressed")).toBe("true");940    expect(featureButton.getAttribute("aria-pressed")).toBe("false");941942    featureButton.click();943    await flushView();944945    expect(bugButton.getAttribute("aria-pressed")).toBe("false");946    expect(featureButton.getAttribute("aria-pressed")).toBe("true");947    expect(featureButton.classList.contains("is-active")).toBe(true);948949    inputText(950      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,951      '<script>alert("xss")</script> Please make this easier to see & safer.'952    );953    const sendButton = Array.from(container.querySelectorAll("button")).find(954      (candidate) => candidate.textContent?.trim() === "Send"955    ) as HTMLButtonElement;956    sendButton.click();957958    await vi.waitFor(() => {959      expect(container.textContent).toContain("Thank you");960    });961962    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));963    expect(requestBody.subject).toContain("Feature request");964    expect(requestBody.message).toBe(965      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."966    );967    expect(requestBody.message).not.toContain("<script>");968    expect(container.textContent).toContain("Someone will process it soon");969    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();970    expect(container.querySelector(".peach-feedback-submit")).toBeNull();971  });972973  it("Settings keeps license activation on the full-screen celebration path", async () => {974    const onLicenseChange = vi.fn(async () => undefined);975    sendMessageMock.mockImplementation(async (msg: unknown) => {976      const type = (msg as { type?: string }).type;977      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };978      if (type === "ACTIVATE_LICENSE") {979        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };980      }981      if (type === "GET_ENTRIES") return { ok: true, data: [] };982      return { ok: true, data: null };983    });984985    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);986    await flushView();987988    inputText(989      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,990      "PEACH-TEST-KEY",991    );992    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;993    apply.click();994995    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));996    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);997    expect(container.textContent).not.toContain("thank you ✦");998  });9991000  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1001    sendMessageMock.mockImplementation(async (msg: unknown) => {1002      const type = (msg as { type?: string }).type;1003      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1004      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1005      if (type === "GET_ENTRIES") return { ok: true, data: [] };1006      return { ok: true, data: null };1007    });10081009    const onLicenseChangeFromStar = vi.fn();1010    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1011    await flushView();1012    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1013    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1014    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();10151016    const onLicenseChangeFromPeach = vi.fn();1017    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1018    await flushView();1019    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1020    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1021    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1022  });10231024  it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1025    sendMessageMock.mockResolvedValue({ ok: true, data: null });1026    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1027      ok: true,1028      json: async () => ({ success: true }),1029    }));1030    vi.stubGlobal("fetch", fetchMock);1031    // Firefox-style permissions API reporting the optional consent as absent.1032    // The old code gated send behind this and silently returned to idle, which1033    // read as a dead Send button. The gate is gone: Send must always send.1034    vi.stubGlobal("browser", {1035      permissions: {1036        getAll: vi.fn(async () => ({ data_collection: [] })),1037        request: vi.fn(async () => false),1038      },1039    });10401041    const container = mount(1042      <SettingsView1043        entriesCount={1}1044        breachMonitoring={true}1045        lockOnClose={true}1046        autoLockMinutes={5}1047        trashRetentionDays={30}1048        theme="dark"1049        accentColor="peach"1050        textSize="medium"1051        reduceMotion={false}1052        initialSection="feedback"1053        onBreachMonitoringChange={vi.fn()}1054        onLockOnCloseChange={vi.fn()}1055        onAutoLockMinutesChange={vi.fn()}1056        onTrashRetentionChange={vi.fn()}1057        onThemeChange={vi.fn()}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();10701071    inputText(1072      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1073      "Please improve the feedback flow."1074    );1075    const sendButton = Array.from(container.querySelectorAll("button")).find(1076      (candidate) => candidate.textContent?.trim() === "Send"1077    ) as HTMLButtonElement;1078    sendButton.click();10791080    await vi.waitFor(() => {1081      expect(fetchMock).toHaveBeenCalledTimes(1);1082    });1083    await vi.waitFor(() => {1084      expect(container.textContent).toContain("Thank you");1085    });1086    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1087  });10881089  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1090    sendMessageMock.mockImplementation(async (msg: unknown) => {1091      const m = msg as { type: string };1092      if (m.type === "GET_RECOVERY_WORDS") {1093        return {1094          ok: true,1095          data: [1096            "abandon", "ability", "able", "about", "above", "absent",1097            "absorb", "abstract", "absurd", "abuse", "access", "accident",1098          ],1099        };1100      }1101      return { ok: true, data: null };1102    });1103    const onAutoLockMinutesChange = vi.fn();1104    const onThemeChange = vi.fn();1105    const container = mount(1106      <SettingsView1107        entriesCount={1}1108        breachMonitoring={true}1109        lockOnClose={true}1110        autoLockMinutes={5}1111        trashRetentionDays={30}1112        theme="dark"1113        accentColor="peach"1114        textSize="medium"1115        reduceMotion={false}1116        onBreachMonitoringChange={vi.fn()}1117        onLockOnCloseChange={vi.fn()}1118        onAutoLockMinutesChange={onAutoLockMinutesChange}1119        onTrashRetentionChange={vi.fn()}1120        onThemeChange={onThemeChange}1121        onAccentChange={vi.fn()}1122        onTextSizeChange={vi.fn()}1123        onReduceMotionChange={vi.fn()}1124        shortcuts={DEFAULT_BINDINGS}1125        onShortcutsChange={vi.fn()}1126        onBack={vi.fn()}1127        onImport={vi.fn()}1128        onExport={vi.fn()}1129        onBreachCheckDone={vi.fn()}1130      />1131    );1132    await flushView();1133    sendMessageMock.mockClear();11341135    // ── Auto-lock + theme changes (before password change) ─────────────1136    const selects = container.querySelectorAll("select");1137    changeSelect(selects[0] as HTMLSelectElement, "15");1138    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);11391140    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1141    appearanceCard.click();1142    await flushView();1143    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1144    const themeButton = Array.from(container.querySelectorAll("button")).find(1145      (candidate) => candidate.textContent?.trim() === "Switch to light"1146    ) as HTMLButtonElement;1147    themeButton.click();1148    expect(onThemeChange).toHaveBeenCalledWith("light");11491150    // ── Password change → recovery words display ────────────────────────1151    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1152    protectionCard.click();1153    await flushView();11541155    const changeButton = Array.from(container.querySelectorAll("button")).find(1156      (candidate) => candidate.textContent?.trim() === "change"1157    ) as HTMLButtonElement;1158    changeButton.click();1159    await flushView();11601161    const inputs = container.querySelectorAll('input[type="password"]');1162    inputText(inputs[0] as HTMLInputElement, "current-pass");1163    inputText(inputs[1] as HTMLInputElement, "new-password-123");1164    inputText(inputs[2] as HTMLInputElement, "new-password-123");1165    await flushView();11661167    const saveButton = Array.from(container.querySelectorAll("button")).find(1168      (candidate) => candidate.textContent?.trim() === "save new password"1169    ) as HTMLButtonElement;1170    saveButton.click();1171    await flushView();11721173    expect(sendMessageMock).toHaveBeenCalledWith({1174      type: "CHANGE_PASSWORD",1175      payload: {1176        currentPassword: "current-pass",1177        newPassword: "new-password-123",1178      },1179    });11801181    // After a successful password change the recovery word display replaces settings.1182    expect(container.textContent).toContain("save your recovery words");1183    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1184  });11851186  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1187    sendMessageMock.mockResolvedValue({ ok: true });1188    const container = mount(1189      <EntryView1190        entry={sampleEntry()}1191        onDelete={vi.fn()}1192        onFieldSaved={vi.fn(async () => undefined)}1193      />1194    );11951196    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1197    passwordButton.click();1198    await flushView();11991200    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1201      type: "COPY",1202      payload: { text: "secret123", label: "password", alreadyWritten: false },1203    });1204    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1205      type: "FIRE_CANARY_PING",1206      payload: {1207        webhookUrl: "https://example.com/hook",1208        entryTitle: "GitHub",1209        trigger: "password_copied",1210      },1211    });12121213    sendMessageMock.mockClear();1214    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1215    usernameButton.click();1216    await flushView();12171218    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1219      type: "COPY",1220      payload: { text: "june", label: "username", alreadyWritten: false },1221    });1222    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1223      type: "FIRE_CANARY_PING",1224      payload: {1225        webhookUrl: "https://example.com/hook",1226        entryTitle: "GitHub",1227        trigger: "username_copied",1228      },1229    });1230  });12311232  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1233    const container = mount(1234      <EntryView1235        entry={sampleEntry({1236          type: "passkey",1237          username: undefined,1238          password: undefined,1239          totp: undefined,1240          canary: undefined,1241          passkey: {1242            rpId: "vercel.com",1243            rpName: "Vercel",1244            credentialId: "cred-1",1245            publicKeyCose: "cose",1246            userHandle: "handle",1247            userName: "june",1248            userDisplayName: "June",1249            algorithm: -7,1250            signCount: 0,1251            aaguid: "",1252            backupEligible: false,1253            backupState: false,1254            createdAt: 1,1255          },1256        })}1257        onDelete={vi.fn()}1258        onFieldSaved={vi.fn(async () => undefined)}1259      />1260    );12611262    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1263      (button) => button.textContent?.includes("Advanced")1264    ) as HTMLButtonElement | undefined;1265    expect(advanced).toBeDefined();1266    expect(advanced?.textContent).toContain("+ Advanced");12671268    advanced!.click();1269    await flushView();12701271    expect(container.textContent).toContain("passkey · vercel.com");1272  });12731274  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1275    sendMessageMock.mockResolvedValue({ ok: true });1276    const onFieldSaved = vi.fn(async () => undefined);1277    const container = mount(1278      <EntryView1279        entry={sampleEntry()}1280        onDelete={vi.fn()}1281        onFieldSaved={onFieldSaved}1282      />1283    );12841285    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1286    passwordValue.click();1287    await flushView();12881289    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1290    inputText(passwordInput, "");1291    await flushView();12921293    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1294    saveButton.click();1295    await flushView();12961297    expect(sendMessageMock).not.toHaveBeenCalledWith(1298      expect.objectContaining({ type: "UPDATE_ENTRY" })1299    );1300    expect(onFieldSaved).not.toHaveBeenCalled();13011302    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1303    totpMain.click();13041305    await vi.waitFor(() => {1306      expect(sendMessageMock).toHaveBeenLastCalledWith({1307        type: "COPY",1308        payload: { text: "123456", label: "totp", alreadyWritten: false },1309      });1310    }, { timeout: 500 });1311  });13121313  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1314    sendMessageMock.mockResolvedValue({ ok: true });1315    const container = mount(1316      <EntryView1317        entry={sampleEntry()}1318        onDelete={vi.fn()}1319        onFieldSaved={vi.fn(async () => undefined)}1320      />1321    );13221323    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1324    usernameValue.click();1325    await flushView();13261327    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1328    usernameInput.blur();1329    await flushView();13301331    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1332    expect(sendMessageMock).not.toHaveBeenCalledWith(1333      expect.objectContaining({ type: "UPDATE_ENTRY" })1334    );1335  });13361337  it("Entry keeps the full used-email list available while editing an email", async () => {1338    const container = mount(1339      <EntryView1340        entry={sampleEntry({ email: "[email protected]" })}1341        entries={[1342          sampleEntry({ email: "[email protected]" }),1343          sampleEntry({ id: "entry-2", email: "[email protected]" }),1344          sampleEntry({ id: "entry-3", username: "duckuccino" }),1345        ]}1346        onDelete={vi.fn()}1347        onFieldSaved={vi.fn(async () => undefined)}1348      />1349    );13501351    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1352      (card) => card.querySelector(".peach-label")?.textContent === "email"1353    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1354    emailValue.click();1355    await flushView();13561357    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1358      .map((element) => element.textContent);1359    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1360  });13611362  it("Entry lets TOTP secrets be edited inline", async () => {1363    sendMessageMock.mockResolvedValue({ ok: true });1364    const onFieldSaved = vi.fn(async () => undefined);1365    const container = mount(1366      <EntryView1367        entry={sampleEntry()}1368        onDelete={vi.fn()}1369        onFieldSaved={onFieldSaved}1370      />1371    );13721373    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1374    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1375    await flushView();13761377    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1378    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1379    await flushView();13801381    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1382    saveButton.click();1383    await flushView();13841385    expect(sendMessageMock).toHaveBeenCalledWith({1386      type: "UPDATE_ENTRY",1387      payload: {1388        id: "entry-1",1389        patch: { totp: "NB2W45DFOIZA====" },1390      },1391    });1392    expect(onFieldSaved).toHaveBeenCalled();1393  });13941395  it("Vault seeds and focuses search when a search seed is provided", async () => {1396    const container = mount(1397      <VaultView1398        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1399        sortOrder="url-first"1400        searchSeed="g"1401        searchSeedNonce={1}1402        onSelectEntry={vi.fn()}1403        onSortOrderChange={vi.fn()}1404        onAdd={vi.fn()}1405        onClose={vi.fn()}1406      />1407    );14081409    await flushView();14101411    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1412    expect(search.value).toBe("g");1413    expect(document.activeElement).toBe(search);1414  });14151416  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1417    parseImportSourceMock.mockResolvedValue(1418      sampleImportResult({1419        entries: [1420          {1421            type: "login",1422            title: "Imported site",1423            username: "imported-user",1424            password: "imported-pass",1425            url: "https://imported.test",1426          },1427        ],1428      })1429    );1430    sendMessageMock.mockImplementation(async (msg: unknown) => {1431      const type = (msg as { type?: string }).type;1432      if (type === "GET_ENTRIES") {1433        return {1434          ok: true,1435          data: [1436            sampleEntry({1437              id: "existing-1",1438              title: "Imported site",1439              username: "imported-user",1440              url: "https://imported.test",1441            }),1442          ],1443        };1444      }1445      if (type === "BATCH_ADD_ENTRIES") {1446        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1447      }1448      return { ok: true };1449    });1450    const onImport = vi.fn(async () => undefined);1451    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1452    await flushView();1453    sendMessageMock.mockClear();14541455    await selectImportFile(container);1456    findButton(container, "Import 1").click();1457    await flushView();14581459    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1460    expect(sendMessageMock).toHaveBeenCalledWith({1461      type: "BATCH_ADD_ENTRIES",1462      payload: {1463        entries: [1464          expect.objectContaining({1465            title: "Imported site",1466            username: "imported-user",1467            password: "imported-pass",1468            url: "https://imported.test",1469          }),1470        ],1471      },1472    });1473    expect(onImport).toHaveBeenCalled();1474    expect(container.textContent).toContain("1 entry appears to be duplicates");1475  });14761477  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1478    parseImportSourceMock.mockResolvedValue(sampleImportResult());1479    sendMessageMock.mockImplementation(async (msg: unknown) => {1480      const type = (msg as { type?: string }).type;1481      if (type === "GET_ENTRIES") {1482        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1483      }1484      if (type === "CLEAR_VAULT_ENTRIES") {1485        return { ok: true };1486      }1487      if (type === "BATCH_ADD_ENTRIES") {1488        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1489      }1490      return { ok: true };1491    });1492    const onImport = vi.fn(async () => undefined);1493    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1494    await flushView();1495    sendMessageMock.mockClear();14961497    await selectImportFile(container);1498    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1499    await flushView();15001501    findButton(container, "Replace vault with 1").click();1502    await flushView();15031504    expect(sendMessageMock).not.toHaveBeenCalledWith(1505      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1506    );1507    expect(container.textContent).toContain("Replace forever? This cannot be undone.");15081509    findButton(container, "Yes, replace forever").click();1510    await flushView();15111512    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1513    expect(sendMessageMock).toHaveBeenCalledWith({1514      type: "BATCH_ADD_ENTRIES",1515      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1516    });1517    expect(onImport).toHaveBeenCalled();1518  });15191520  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1521    parseImportSourceMock.mockResolvedValue(sampleImportResult());1522    sendMessageMock.mockImplementation(async (msg: unknown) => {1523      const type = (msg as { type?: string }).type;1524      if (type === "CREATE_VAULT") return { ok: true };1525      if (type === "BATCH_ADD_ENTRIES") {1526        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1527      }1528      return { ok: true };1529    });1530    const onImport = vi.fn(async () => undefined);1531    const container = mount(1532      <ImportView1533        mode="onboarding"1534        replacesExistingVault={true}1535        onBack={vi.fn()}1536        onImport={onImport}1537      />1538    );15391540    await selectImportFile(container);15411542    const createButton = findButton(container, "Create vault + import 1");1543    expect(createButton.disabled).toBe(true);1544    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1545    expect(container.textContent).toContain("Use at least 8 characters");15461547    const masterInput = container.querySelector(1548      'input[placeholder="new master password"]'1549    ) as HTMLInputElement;1550    const confirmInput = container.querySelector(1551      'input[placeholder="confirm master password"]'1552    ) as HTMLInputElement;1553    const revealButton = container.querySelector(1554      'button[aria-label="Show passwords"]'1555    ) as HTMLButtonElement;1556    expect(revealButton).toBeTruthy();1557    revealButton.click();1558    await flushView();1559    expect(masterInput.type).toBe("text");1560    expect(confirmInput.type).toBe("text");15611562    inputText(masterInput, "fresh-master-password");1563    inputText(confirmInput, "fresh-master-password");1564    await flushView();15651566    expect(createButton.disabled).toBe(false);1567    createButton.click();1568    await flushView();15691570    expect(sendMessageMock).toHaveBeenCalledWith({1571      type: "CREATE_VAULT",1572      payload: { password: "fresh-master-password" },1573    });1574    expect(sendMessageMock).toHaveBeenCalledWith({1575      type: "BATCH_ADD_ENTRIES",1576      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1577    });1578    expect(onImport).toHaveBeenCalled();1579  });15801581  it("Import encrypted backups reparse with the backup password before import", async () => {1582    parseImportSourceMock1583      .mockResolvedValueOnce(1584        sampleImportResult({1585          format: "peach-json",1586          label: "Peach encrypted backup",1587          entries: [],1588          totalItems: 0,1589          requiresPassword: true,1590        })1591      )1592      .mockResolvedValueOnce(1593        sampleImportResult({1594          format: "peach-json",1595          label: "Peach encrypted backup",1596        })1597      );1598    sendMessageMock.mockImplementation(async (msg: unknown) => {1599      const type = (msg as { type?: string }).type;1600      if (type === "GET_ENTRIES") return { ok: true, data: [] };1601      if (type === "BATCH_ADD_ENTRIES") {1602        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1603      }1604      return { ok: true };1605    });1606    const onImport = vi.fn(async () => undefined);1607    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1608    await flushView();1609    sendMessageMock.mockClear();16101611    await selectImportFile(container, '{"type":"peach-export"}');1612    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1613    inputText(password, "backup-passphrase");1614    pressEnter(password);1615    await flushView();1616    await flushView();16171618    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1619    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");16201621    findButton(container, "Import 1").click();1622    await flushView();16231624    expect(sendMessageMock).toHaveBeenCalledWith({1625      type: "BATCH_ADD_ENTRIES",1626      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1627    });1628    expect(onImport).toHaveBeenCalled();1629  });1630});