contracts.test.tsx

65.4 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { AddView } from "./Add";16import { NotesEditor } from "./NotesEditor";17import { RecoveryWordEntryView } from "./RecoveryWordEntry";18import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";19import { VaultView } from "./Vault";20import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2122const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();23const triggerRingMock = vi.fn();24const triggerPipPulseMock = vi.fn();25const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();26const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2728vi.mock("../App", () => ({29  sendMessage: (msg: unknown) => sendMessageMock(msg),30  triggerRing: () => triggerRingMock(),31  triggerPipPulse: () => triggerPipPulseMock(),32}));3334vi.mock("../../shared/bridge", () => ({35  sendMessage: (msg: unknown) => sendMessageMock(msg),36}));3738vi.mock("../../shared/import", () => ({39  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),40}));4142vi.mock("../../shared/favicon", async (importOriginal) => {43  const actual = await importOriginal<typeof import("../../shared/favicon")>();44  return { ...actual, faviconUrl: () => null };45});4647vi.mock("../../crypto/totp", () => ({48  generateTOTP: () => ({ code: "123 456", remaining: 24 }),49  parseOtpauthURI: (value: string) =>50    value.startsWith("otpauth://")51      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }52      : null,53}));5455vi.mock("../components/GeneratorDrawer", () => ({56  GeneratorDrawer: () => null,57}));5859vi.mock("../components/RuneBackground", () => ({60  RuneBackground: () => null,61}));6263vi.mock("@zxcvbn-ts/core", () => ({64  zxcvbn: (value: string) => zxcvbnMock(value),65  zxcvbnOptions: { setOptions: vi.fn() },66}));67vi.mock("@zxcvbn-ts/language-common", () => ({68  adjacencyGraphs: {},69  dictionary: {},70}));71vi.mock("@zxcvbn-ts/language-en", () => ({72  translations: {},73  dictionary: {},74}));7576describe("popup contract tests", () => {77  const disposers: Array<() => void> = [];7879  beforeEach(() => {80    document.body.innerHTML = "";81    sendMessageMock.mockReset();82    triggerRingMock.mockReset();83    triggerPipPulseMock.mockReset();84    parseImportSourceMock.mockReset();85    zxcvbnMock.mockReset();86    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));87    parseImportSourceMock.mockResolvedValue({88      format: "unknown",89      label: "Unknown format",90      entries: [],91      totalItems: 0,92      skippedItems: 0,93    });9495    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {96      return window.setTimeout(() => cb(0), 0) as unknown as number;97    });98    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));99    vi.stubGlobal("URL", {100      createObjectURL: vi.fn(() => "blob:peach-test"),101      revokeObjectURL: vi.fn(),102    });103    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});104105    (globalThis as { chrome?: typeof chrome }).chrome = {106      runtime: {107        getManifest: () => ({ version: "1.4.47" }),108        onMessage: { addListener: vi.fn() },109      },110    } as unknown as typeof chrome;111  });112113  afterEach(() => {114    while (disposers.length > 0) {115      disposers.pop()?.();116    }117    vi.restoreAllMocks();118    vi.unstubAllGlobals();119  });120121  function mount(view: JSX.Element | (() => JSX.Element)) {122    const mounted = renderView(view);123    disposers.push(mounted.dispose);124    return mounted.container;125  }126127  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {128    return {129      id: "entry-1",130      type: "login",131      title: "GitHub",132      username: "june",133      password: "secret123",134      url: "https://github.com",135      totp: "JBSWY3DPEHPK3PXP",136      canary: {137        webhookUrl: "https://example.com/hook",138      },139      createdAt: 1,140      modifiedAt: 1,141      ...overrides,142    };143  }144145  function sampleImportResult(146    overrides: Partial<ImportParseResult> = {}147  ): ImportParseResult {148    return {149      format: "csv",150      label: "CSV import",151      entries: [152        {153          type: "login",154          title: "Imported site",155          username: "imported-user",156          password: "imported-pass",157          url: "https://imported.test",158        },159      ],160      totalItems: 1,161      skippedItems: 0,162      ...overrides,163    };164  }165166  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {167    return {168      entriesCount: 1,169      breachMonitoring: true,170      lockOnClose: true,171      autoLockMinutes: 5,172      lockedSyncRequiresUnlock: false,173      trashRetentionDays: 30,174      theme: "dark" as const,175      accentColor: "peach" as const,176      textSize: "medium" as const,177      reduceMotion: false,178      onBreachMonitoringChange: vi.fn(),179      onLockOnCloseChange: vi.fn(),180      onAutoLockMinutesChange: vi.fn(),181      onTrashRetentionChange: vi.fn(),182      onLockedSyncRequiresUnlockChange: 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      const type = (msg as { type: string }).type;246      if (type === "GET_PIN_STATUS") {247        return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };248      }249      return type === "GET_SETTINGS"250        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }251        : { ok: true };252    });253    const onUnlock = vi.fn(async () => undefined);254    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);255    await flushView();256257    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");258    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();259    await flushView();260261    expect(sendMessageMock).toHaveBeenCalledWith({262      type: "UNLOCK_WITH_PIN",263      payload: { pin: "1234" },264    });265    expect(onUnlock).toHaveBeenCalled();266    expect(container.textContent).toContain("use master password");267  });268269  it("places the PIN method switch below the unlock button", async () => {270    sendMessageMock.mockImplementation(async (msg: unknown) => {271      const type = (msg as { type: string }).type;272      if (type === "GET_PIN_STATUS") {273        return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };274      }275      return type === "GET_SETTINGS"276        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }277        : { ok: true };278    });279    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);280    await flushView();281282    const buttons = Array.from(container.querySelectorAll("button"));283    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");284    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");285    expect(pinIndex).toBeGreaterThan(unlockIndex);286  });287288  it("keeps standalone note creation in Notes mode", async () => {289    const chromeApi = globalThis.chrome as unknown as {290      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };291    };292    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };293294    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);295    await flushView();296297    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))298      .toEqual(["login", "card"]);299    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();300  });301302  it("gives Notes mode the full note-formatting toolbar", async () => {303    const container = mount(304      <NotesEditor305        note={null}306        onBack={vi.fn()}307        onSaved={vi.fn()}308        onDelete={vi.fn()}309      />310    );311    await flushView();312313    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();314    expect(container.querySelector('[title="Divider"]')).toBeTruthy();315    expect(container.querySelector('[title="Quote"]')).toBeTruthy();316    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);317    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);318    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");319  });320321  it("saves formatting applied by the Notes toolbar before leaving", async () => {322    const savedNote = {323      id: "note-1",324      type: "note" as const,325      title: "Untitled",326      notes: "<b>formatted</b>",327      createdAt: 1,328      modifiedAt: 2,329    };330    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });331    const originalExecCommand = document.execCommand;332    const execCommandMock = vi.fn(() => {333      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");334      if (body) body.innerHTML = "<b>formatted</b>";335      return true;336    });337    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });338339    try {340      const container = mount(341        <NotesEditor342          note={null}343          onBack={vi.fn()}344          onSaved={vi.fn()}345          onDelete={vi.fn()}346        />347      );348      await flushView();349      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(350        new MouseEvent("mousedown", { bubbles: true, cancelable: true })351      );352      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);353      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();354      await flushView();355356      expect(sendMessageMock).toHaveBeenCalledWith({357        type: "SAVE_NOTE",358        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },359      });360    } finally {361      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });362    }363  });364365  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {366    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);367    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;368    const password = container.querySelector("#master-password") as HTMLInputElement;369    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;370371    inputText(password, "short");372    inputText(confirm, "short");373    await flushView();374    expect(createButton.disabled).toBe(true);375376    zxcvbnMock.mockImplementation(() => ({ score: 1 }));377    inputText(password, "weakweakweak");378    inputText(confirm, "weakweakweak");379    await flushView();380    expect(createButton.disabled).toBe(true);381  });382383  it("Lock create-vault header pins the wordmark left with back on the right", async () => {384    const container = mount(385      <LockView386        hasVault={false}387        ready={true}388        onUnlock={vi.fn()}389        onBackToOnboarding={() => {}}390      />391    );392    const header = container.querySelector(".peach-newvault-header") as HTMLElement;393    const wordmark = header.querySelector(".peach-wordmark-stack");394    const back = header.querySelector(".peach-newvault-back");395    expect(wordmark).toBeTruthy();396    expect(back).toBeTruthy();397    // Wordmark first so it never shifts between onboarding screens; back398    // takes the opposite corner instead of displacing it.399    expect(header.firstElementChild).toBe(wordmark);400    expect(header.lastElementChild).toBe(back);401    await flushView();402  });403404  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {405    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock406    sendMessageMock.mockImplementation(407      async (msg: unknown) => {408        const m = msg as { type: string; payload?: { password?: string } };409        if (m.type === "CREATE_VAULT") return { ok: true };410        if (m.type === "GET_RECOVERY_WORDS") {411          // Return 12 fake words — must pass findInvalidWords412          return {413            ok: true,414            data: [415              "abandon", "ability", "able", "about", "above", "absent",416              "absorb", "abstract", "absurd", "abuse", "access", "accident",417            ],418          };419        }420        return { ok: true };421      }422    );423    zxcvbnMock.mockImplementation(() => ({ score: 3 }));424    const onUnlock = vi.fn(async () => undefined);425    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);426427    const password = container.querySelector("#master-password") as HTMLInputElement;428    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;429    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;430431    inputText(password, "correct horse battery staple");432    inputText(confirm, "correct horse battery staple");433    await flushView();434    expect(createButton.disabled).toBe(false);435436    pressEnter(confirm);437    await flushView();438439    expect(sendMessageMock).toHaveBeenCalledWith({440      type: "CREATE_VAULT",441      payload: { password: "correct horse battery staple" },442    });443    // After CREATE_VAULT success, the recovery word display is shown.444    // onUnlock is NOT called yet — user must first confirm their recovery words.445    expect(onUnlock).not.toHaveBeenCalled();446  });447448  it("recovery display asks for three distinct randomized word positions and confirms", async () => {449    const words12 = [450      "abandon", "ability", "able", "about", "above", "absent",451      "absorb", "abstract", "absurd", "abuse", "access", "accident",452    ];453    sendMessageMock.mockImplementation(async (msg: unknown) => {454      const type = (msg as { type?: string }).type;455      if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };456      return { ok: true };457    });458459    vi.useFakeTimers();460    const onDone = vi.fn();461    try {462      const container = mount(<RecoveryWordDisplayView onDone={onDone} />);463      // onMount fires GET_RECOVERY_WORDS; let the resolved promise + render settle464      await vi.advanceTimersByTimeAsync(0);465466      const prompts = Array.from(467        container.querySelectorAll(".peach-recovery-confirm-num")468      ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));469      expect(prompts).toHaveLength(3);470      expect(new Set(prompts).size).toBe(3);471      for (const p of prompts) {472        expect(p).toBeGreaterThanOrEqual(1);473        expect(p).toBeLessThanOrEqual(12);474      }475476      // Inputs stay disabled until the 5s countdown expires477      const inputs = Array.from(478        container.querySelectorAll(".peach-recovery-confirm-input")479      ) as HTMLInputElement[];480      expect(inputs).toHaveLength(3);481      expect(inputs[0]!.disabled).toBe(true);482      await vi.advanceTimersByTimeAsync(5000);483      expect(inputs[0]!.disabled).toBe(false);484485      prompts.forEach((pos, i) => {486        inputText(inputs[i]!, words12[pos - 1]!);487      });488489      const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;490      expect(confirmBtn.disabled).toBe(false);491      confirmBtn.click();492      await vi.advanceTimersByTimeAsync(700); // success pause before onDone493494      expect(onDone).toHaveBeenCalledTimes(1);495    } finally {496      vi.useRealTimers();497    }498  });499500  it("Recovery words flow submits the new master password and completes", async () => {501    sendMessageMock.mockImplementation(async (msg: unknown) => {502      const type = (msg as { type?: string }).type;503      if (type === "VALIDATE_RECOVERY_WORDS") {504        return { ok: true, data: { valid: true, invalid: [] } };505      }506      if (type === "RECOVER_WITH_WORDS") return { ok: true };507      return { ok: true };508    });509    const canvasContext = {510      clearRect: vi.fn(),511      fillText: vi.fn(),512      beginPath: vi.fn(),513      closePath: vi.fn(),514      arc: vi.fn(),515      fill: vi.fn(),516      stroke: vi.fn(),517      save: vi.fn(),518      restore: vi.fn(),519      translate: vi.fn(),520      rotate: vi.fn(),521      scale: vi.fn(),522      drawImage: vi.fn(),523      measureText: vi.fn(() => ({ width: 0 })),524      setTransform: vi.fn(),525      fillRect: vi.fn(),526      strokeRect: vi.fn(),527    } as unknown as CanvasRenderingContext2D;528    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);529530    const onRecoveryComplete = vi.fn(async () => undefined);531    const container = mount(532      <RecoveryWordEntryView533        onCancel={vi.fn()}534        onRecoveryComplete={onRecoveryComplete}535      />536    );537538    const recoveryWords = WORD_LIST.slice(0, 12);539    for (let i = 0; i < recoveryWords.length; i++) {540      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;541      inputText(input, recoveryWords[i]!);542    }543    await flushView();544545    findButton(container, "verify words").click();546    await flushView();547548    expect(container.textContent).toContain("new master password");549550    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;551    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;552    inputText(newPassword, "fresh-recovery-password");553    inputText(confirmPassword, "fresh-recovery-password");554    await flushView();555556    findButton(container, "recover vault").click();557    await flushView();558559    expect(sendMessageMock).toHaveBeenCalledWith({560      type: "RECOVER_WITH_WORDS",561      payload: {562        words: recoveryWords,563        newPassword: "fresh-recovery-password",564      },565    });566    expect(onRecoveryComplete).toHaveBeenCalled();567  });568569  it("Export menu surfaces both backup formats", async () => {570    const container = mount(<ExportView onBack={vi.fn()} />);571    await flushView();572573    expect(container.textContent).toContain("PeachScript");574    expect(container.textContent).toContain("CSV");575  });576577  it("Lock can open directly on backup restore from onboarding", async () => {578    const container = mount(() => (579      <LockView580        hasVault={false}581        ready={true}582        onUnlock={vi.fn()}583        initialRestore={true}584      />585    ));586    await flushView();587588    expect(container.textContent).toContain("PeachScript");589    expect(container.textContent).toContain("Codex");590    expect(container.textContent).toContain("CSV");591    expect(container.querySelector("#master-password")).toBeNull();592  });593594  it("Lock restore back returns to onboarding, not the create-vault screen", async () => {595    const onBackToOnboarding = vi.fn();596    const container = mount(() => (597      <LockView598        hasVault={false}599        ready={true}600        onUnlock={vi.fn()}601        initialRestore={true}602        onBackToOnboarding={onBackToOnboarding}603      />604    ));605    await flushView();606607    (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();608    await flushView();609    expect(onBackToOnboarding).toHaveBeenCalledTimes(1);610  });611612  it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {613    const container = mount(() => (614      <LockView615        hasVault={false}616        ready={true}617        onUnlock={vi.fn()}618        initialRestore={true}619        initialRestoreSource="symbols"620      />621    ));622    await flushView();623624    expect(container.querySelector(".peach-restore-script")).toBeTruthy();625    expect(container.querySelector("#restore-backup-pass")).toBeTruthy();626    expect(container.querySelector(".peach-restore-sources")).toBeNull();627  });628629  it("Lock restore resumes on the picked CSV source, not the picker", async () => {630    const container = mount(() => (631      <LockView632        hasVault={false}633        ready={true}634        onUnlock={vi.fn()}635        initialRestore={true}636        initialRestoreSource="csv"637      />638    ));639    await flushView();640641    expect(container.querySelector("#restore-csv-pass")).toBeTruthy();642    expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();643    expect(container.querySelector(".peach-restore-sources")).toBeNull();644  });645646  it("Lock restore PeachScript form loads a dropped .peach file", async () => {647    const container = mount(() => (648      <LockView649        hasVault={false}650        ready={true}651        onUnlock={vi.fn()}652        initialRestore={true}653        initialRestoreSource="symbols"654      />655    ));656    await flushView();657658    const form = container.querySelector(".peach-restore-form") as HTMLElement;659    expect(form).toBeTruthy();660661    // The OS file picker dismisses the action popup, so drag-and-drop is the662    // file path that survives; a drop must land the file in the script field.663    const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });664    const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {665      dataTransfer?: { files: File[] };666    };667    drop.dataTransfer = { files: [file] };668    form.dispatchEvent(drop);669    await flushView();670671    const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;672    expect(script.value).toBe("dropped-peach-backup");673  });674675  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {676    const device = {677      deviceId: "phone-1",678      deviceName: "June's Phone",679      deviceType: "android" as const,680      longTermPublicKeyHex: "a".repeat(64),681      signingPublicKeyHex: "b".repeat(64),682      pairedAt: 1,683      trustSource: "direct" as const,684    };685    sendMessageMock.mockImplementation(async (msg: unknown) => {686      const m = msg as { type: string };687      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };688      return { ok: true, data: null };689    });690691    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);692    for (let i = 0; i < 30; i++) await Promise.resolve();693694    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>695      /sync/i.test(b.textContent ?? ""),696    );697    openSync?.click();698    for (let i = 0; i < 30; i++) await Promise.resolve();699700    const remove = Array.from(container.querySelectorAll("button")).find(701      (b) => (b.textContent ?? "").trim() === "remove",702    );703    expect(remove).toBeTruthy();704705    // Guard against a vacuous pass: the disclosure must not already be on706    // screen before the user asks to remove anything.707    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();708709    remove!.click();710    for (let i = 0; i < 10; i++) await Promise.resolve();711712    // Revocation must never be presented as securing the vault against the713    // removed device — recovery words survive it and restore everything.714    const note = container.querySelector(".peach-unpair-confirm-note");715    expect(note).toBeTruthy();716    const text = note!.textContent ?? "";717    expect(text).toContain("master password");718    expect(text).toContain("recovery words");719    expect(text).toContain("future changes");720  });721722  it("PB-26-709: pending device approval identifies the key, time, provenance, and vault scope", async () => {723    sendMessageMock.mockImplementation(async (msg: unknown) => {724      const m = msg as { type: string };725      if (m.type === "GET_PENDING_VOUCHED_DEVICES") {726        return {727          ok: true,728          data: [{729            deviceId: "phone-source-id",730            deviceName: "June's Phone",731            deviceType: "android",732            longTermPublicKeyHex: "a1b2c3d4e5f6" + "0".repeat(52),733            signingPublicKeyHex: "b".repeat(64),734            pairedAt: Date.UTC(2026, 8, 5),735            receivedAt: Date.UTC(2026, 8, 6, 20, 15),736            vouchedByDeviceId: "trusted-browser-id",737          }],738        };739      }740      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [] } };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((button) => /sync/i.test(button.textContent ?? ""))748      ?.click();749    for (let i = 0; i < 30; i++) await Promise.resolve();750751    expect(container.textContent).toContain("a1b2 c3d4 e5f6");752    expect(container.textContent).toContain("trusted-browser-id");753    expect(container.textContent).toContain("2026-09-06");754    expect(container.textContent).toContain("existing vault contents");755  });756757  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {758    const device = {759      deviceId: "phone-1",760      deviceName: "June's Phone",761      deviceType: "android" as const,762      longTermPublicKeyHex: "a".repeat(64),763      signingPublicKeyHex: "b".repeat(64),764      pairedAt: 1,765      trustSource: "direct" as const,766    };767    let paired: typeof device[] = [device];768    sendMessageMock.mockImplementation(async (msg: any) => {769      if (msg?.type === "LIST_PAIRED_DEVICES") {770        return { ok: true, data: { devices: [...paired] } };771      }772      if (msg?.type === "UNPAIR_DEVICE") {773        // Mirror the real handler: UNPAIR_DEVICE drops the row from the774        // registry. The next LIST_PAIRED_DEVICES must not see it.775        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);776        return { ok: true };777      }778      if (msg?.type === "RENAME_PAIRED_DEVICE") {779        paired = paired.map((d) =>780          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,781        );782        return { ok: true };783      }784      return { ok: true, data: null };785    });786787    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);788    for (let i = 0; i < 30; i++) await Promise.resolve();789    Array.from(container.querySelectorAll("button"))790      .find((b) => /sync/i.test(b.textContent ?? ""))791      ?.click();792    for (let i = 0; i < 30; i++) await Promise.resolve();793794    // The list itself: extracting the relay selector must not take the hub with it.795    expect(container.textContent).toContain("PAIRED DEVICES");796    expect(container.textContent).toContain("June's Phone");797    expect(container.textContent).toContain("Android");798    Array.from(container.querySelectorAll("button"))799      .find((b) => (b.textContent ?? "").trim() === "rename")!800      .click();801    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;802    renameInput.value = "Travel phone";803    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));804    Array.from(container.querySelectorAll("button"))805      .find((b) => (b.textContent ?? "").trim() === "save")!806      .click();807    for (let i = 0; i < 30; i++) await Promise.resolve();808    expect(sendMessageMock).toHaveBeenCalledWith({809      type: "RENAME_PAIRED_DEVICE",810      payload: { deviceId: "phone-1", name: "Travel phone" },811    });812    expect(container.textContent).toContain("Travel phone");813    const syncNowButton = container.querySelector<HTMLButtonElement>(814      'button[aria-label="Sync this vault now"]',815    )!;816    const syncStatusRow = syncNowButton.closest(".peach-settings-row--meta");817    const syncCard = syncNowButton.closest('[data-settings-card-id="sync"]');818    expect(syncStatusRow).not.toBeNull();819    expect(syncStatusRow?.contains(syncNowButton)).toBe(true);820    expect(syncCard?.querySelectorAll(".peach-settings-row--meta")).toHaveLength(1);821    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));822    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;823    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;824    expect(825      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,826    ).toBeTruthy();827    expect(828      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,829    ).toBeTruthy();830831    Array.from(container.querySelectorAll("button"))832      .find((b) => (b.textContent ?? "").trim() === "remove")!833      .click();834    for (let i = 0; i < 10; i++) await Promise.resolve();835    const confirm = Array.from(836      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),837    ).find((b) => (b.textContent ?? "").trim() === "remove");838    confirm!.click();839    for (let i = 0; i < 30; i++) await Promise.resolve();840841    expect(sendMessageMock).toHaveBeenCalledWith({842      type: "UNPAIR_DEVICE",843      payload: { deviceId: "phone-1" },844    });845    // The list must be refreshed and the device must be gone. The regression846    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but847    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.848    expect(container.textContent).not.toContain("June's Phone");849    expect(container.textContent).not.toContain("PAIRED DEVICES");850  });851  it("Settings keeps the confirmation and shows revoke failures", async () => {852    const device = {853      deviceId: "phone-1",854      deviceName: "June's Phone",855      deviceType: "android" as const,856      longTermPublicKeyHex: "a".repeat(64),857      signingPublicKeyHex: "b".repeat(64),858      pairedAt: 1,859      trustSource: "direct" as const,860    };861    sendMessageMock.mockImplementation(async (msg: unknown) => {862      const message = msg as { type?: string; payload?: { deviceId?: string } };863      if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };864      if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" };865      return { ok: true, data: null };866    });867868    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);869    for (let i = 0; i < 30; i++) await Promise.resolve();870    for (let i = 0; i < 30; i++) await Promise.resolve();871    Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();872    for (let i = 0; i < 30; i++) await Promise.resolve();873    Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();874    for (let i = 0; i < 10; i++) await Promise.resolve();875    Array.from(container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();876    for (let i = 0; i < 30; i++) await Promise.resolve();877878    expect(container.textContent).toContain("trust_data_corrupted");879    expect(container.textContent).toContain("June's Phone");880  });881882  it("Settings drops the secure-reset panel once the device is on the settled generation", async () => {883    const openSyncCard = async (state: "V2_ACTIVE" | "V2_ANCHOR" | "LEGACY_ACTIVE") => {884      sendMessageMock.mockImplementation(async (msg: unknown) => {885        const message = msg as { type?: string };886        if (message.type === "GET_SYNC_GENERATION") {887          return { ok: true, data: { schemaVersion: 1, generation: 2, state, createdAt: 1 } };888        }889        return { ok: true, data: null };890      });891      const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);892      for (let i = 0; i < 30; i++) await Promise.resolve();893      Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();894      for (let i = 0; i < 30; i++) await Promise.resolve();895      return container;896    };897898    // Settled: the migration is over, so the panel must not linger as a label.899    const settled = await openSyncCard("V2_ACTIVE");900    expect(settled.textContent).not.toContain("SECURE SYNC RESET");901    expect(settled.textContent).toContain("ADD A DEVICE");902903    // Still actionable states keep the panel and its explanation.904    const anchored = await openSyncCard("V2_ANCHOR");905    expect(anchored.textContent).toContain("SECURE SYNC RESET");906    expect(anchored.textContent).toContain("Not re-paired");907908    const legacy = await openSyncCard("LEGACY_ACTIVE");909    expect(legacy.textContent).toContain("SECURE SYNC RESET");910    expect(legacy.textContent).toContain("Legacy Sync generation");911  });912913  it("Settings keeps connection and recovery details collapsed until requested", async () => {914    sendMessageMock.mockImplementation(async (msg: any) => {915      if (msg?.type === "LIST_PAIRED_DEVICES") {916        return { ok: true, data: { devices: [] } };917      }918      return { ok: true, data: null };919    });920921    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);922    for (let i = 0; i < 30; i++) await Promise.resolve();923    Array.from(container.querySelectorAll("button"))924      .find((button) => /sync/i.test(button.textContent ?? ""))925      ?.click();926    for (let i = 0; i < 20; i++) await Promise.resolve();927928    expect(container.textContent).toContain("SYNC STATUS");929    expect(container.textContent).toContain("ADD A DEVICE");930    expect(container.textContent).not.toContain("Public relay");931    expect(container.textContent).not.toContain("Rotate sync identity");932933    const connection = Array.from(container.querySelectorAll("button"))934      .find((button) => button.textContent?.includes("CONNECTION"));935    connection?.click();936    await Promise.resolve();937    expect(container.textContent).toContain("Public relay");938939    const recovery = Array.from(container.querySelectorAll("button"))940      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));941    recovery?.click();942    await Promise.resolve();943    expect(container.textContent).toContain("Rotate sync identity");944  });945946  it("Settings requires current, new, and confirm passwords before enabling save", async () => {947    sendMessageMock.mockResolvedValue({ ok: true, data: null });948    const container = mount(949      <SettingsView950        entriesCount={1}951        breachMonitoring={true}952        lockOnClose={true}953        lockedSyncRequiresUnlock={false}954        autoLockMinutes={5}955        trashRetentionDays={30}956        theme="dark"957        accentColor="peach"958        textSize="medium"959        reduceMotion={false}960        onBreachMonitoringChange={vi.fn()}961        onLockedSyncRequiresUnlockChange={vi.fn()}962        onLockOnCloseChange={vi.fn()}963        onAutoLockMinutesChange={vi.fn()}964        onTrashRetentionChange={vi.fn()}965        onThemeChange={vi.fn()}966        onAccentChange={vi.fn()}967        onTextSizeChange={vi.fn()}968        onReduceMotionChange={vi.fn()}969        shortcuts={DEFAULT_BINDINGS}970        onShortcutsChange={vi.fn()}971        onBack={vi.fn()}972        onImport={vi.fn()}973        onExport={vi.fn()}974        onBreachCheckDone={vi.fn()}975      />976    );977    await flushView();978979    expect(container.querySelectorAll(".stk-card").length).toBe(7);980    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]981    // "open settings to card I (Peach Pro) instead of card II (Protection)".982    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");983984    const changeButton = Array.from(container.querySelectorAll("button")).find(985      (candidate) => candidate.textContent?.trim() === "change"986    ) as HTMLButtonElement;987    changeButton.click();988    await flushView();989990    const saveButton = Array.from(container.querySelectorAll("button")).find(991      (candidate) => candidate.textContent?.trim() === "save new password"992    ) as HTMLButtonElement;993    expect(saveButton.disabled).toBe(true);994995    const inputs = container.querySelectorAll('input[type="password"]');996    inputText(inputs[0] as HTMLInputElement, "current-pass");997    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");998    await flushView();999    expect(saveButton.disabled).toBe(true);10001001    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");1002    await flushView();1003    expect(saveButton.disabled).toBe(false);1004  });10051006  it("Settings feedback highlights the selected category and hides the form after submit", async () => {1007    sendMessageMock.mockResolvedValue({ ok: true, data: null });1008    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1009      ok: true,1010      json: async () => ({ success: true }),1011    }));1012    vi.stubGlobal("fetch", fetchMock);10131014    const container = mount(1015      <SettingsView1016        entriesCount={1}1017        breachMonitoring={true}1018        lockOnClose={true}1019        lockedSyncRequiresUnlock={false}1020        autoLockMinutes={5}1021        trashRetentionDays={30}1022        theme="dark"1023        accentColor="peach"1024        textSize="medium"1025        reduceMotion={false}1026        initialSection="feedback"1027        onLockedSyncRequiresUnlockChange={vi.fn()}1028        onBreachMonitoringChange={vi.fn()}1029        onLockOnCloseChange={vi.fn()}1030        onAutoLockMinutesChange={vi.fn()}1031        onTrashRetentionChange={vi.fn()}1032        onThemeChange={vi.fn()}1033        onAccentChange={vi.fn()}1034        onTextSizeChange={vi.fn()}1035        onReduceMotionChange={vi.fn()}1036        shortcuts={DEFAULT_BINDINGS}1037        onShortcutsChange={vi.fn()}1038        onBack={vi.fn()}1039        onImport={vi.fn()}1040        onExport={vi.fn()}1041        onBreachCheckDone={vi.fn()}1042      />1043    );1044    await flushView();10451046    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;1047    expect(feedbackCard.classList.contains("is-active")).toBe(true);10481049    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];1050    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;1051    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;1052    expect(bugButton.getAttribute("aria-pressed")).toBe("true");1053    expect(featureButton.getAttribute("aria-pressed")).toBe("false");10541055    featureButton.click();1056    await flushView();10571058    expect(bugButton.getAttribute("aria-pressed")).toBe("false");1059    expect(featureButton.getAttribute("aria-pressed")).toBe("true");1060    expect(featureButton.classList.contains("is-active")).toBe(true);10611062    inputText(1063      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1064      '<script>alert("xss")</script> Please make this easier to see & safer.'1065    );1066    const sendButton = Array.from(container.querySelectorAll("button")).find(1067      (candidate) => candidate.textContent?.trim() === "Send"1068    ) as HTMLButtonElement;1069    sendButton.click();10701071    await vi.waitFor(() => {1072      expect(container.textContent).toContain("Thank you");1073    });10741075    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));1076    expect(requestBody.subject).toContain("Feature request");1077    expect(requestBody.message).toBe(1078      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."1079    );1080    expect(requestBody.message).not.toContain("<script>");1081    expect(container.textContent).toContain("Someone will process it soon");1082    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1083    expect(container.querySelector(".peach-feedback-submit")).toBeNull();1084  });10851086  it("Settings keeps license activation on the full-screen celebration path", async () => {1087    const onLicenseChange = vi.fn(async () => undefined);1088    sendMessageMock.mockImplementation(async (msg: unknown) => {1089      const type = (msg as { type?: string }).type;1090      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };1091      if (type === "ACTIVATE_LICENSE") {1092        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1093      }1094      if (type === "GET_ENTRIES") return { ok: true, data: [] };1095      return { ok: true, data: null };1096    });10971098    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);1099    await flushView();11001101    inputText(1102      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,1103      "PEACH-TEST-KEY",1104    );1105    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;1106    apply.click();11071108    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1109    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1110    expect(container.textContent).not.toContain("thank you ✦");1111  });11121113  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1114    sendMessageMock.mockImplementation(async (msg: unknown) => {1115      const type = (msg as { type?: string }).type;1116      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1117      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1118      if (type === "GET_ENTRIES") return { ok: true, data: [] };1119      return { ok: true, data: null };1120    });11211122    const onLicenseChangeFromStar = vi.fn();1123    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1124    await flushView();1125    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1126    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1127    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();11281129    const onLicenseChangeFromPeach = vi.fn();1130    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1131    await flushView();1132    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1133    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1134    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1135  });11361137  it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1138    sendMessageMock.mockResolvedValue({ ok: true, data: null });1139    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1140      ok: true,1141      json: async () => ({ success: true }),1142    }));1143    vi.stubGlobal("fetch", fetchMock);1144    // Firefox-style permissions API reporting the optional consent as absent.1145    // The old code gated send behind this and silently returned to idle, which1146    // read as a dead Send button. The gate is gone: Send must always send.1147    vi.stubGlobal("browser", {1148      permissions: {1149        getAll: vi.fn(async () => ({ data_collection: [] })),1150        request: vi.fn(async () => false),1151      },1152    });11531154    const container = mount(1155      <SettingsView1156        entriesCount={1}1157        breachMonitoring={true}1158        lockOnClose={true}1159        lockedSyncRequiresUnlock={false}1160        autoLockMinutes={5}1161        trashRetentionDays={30}1162        theme="dark"1163        accentColor="peach"1164        textSize="medium"1165        reduceMotion={false}1166        initialSection="feedback"1167        onLockedSyncRequiresUnlockChange={vi.fn()}1168        onBreachMonitoringChange={vi.fn()}1169        onLockOnCloseChange={vi.fn()}1170        onAutoLockMinutesChange={vi.fn()}1171        onTrashRetentionChange={vi.fn()}1172        onThemeChange={vi.fn()}1173        onAccentChange={vi.fn()}1174        onTextSizeChange={vi.fn()}1175        onReduceMotionChange={vi.fn()}1176        shortcuts={DEFAULT_BINDINGS}1177        onShortcutsChange={vi.fn()}1178        onBack={vi.fn()}1179        onImport={vi.fn()}1180        onExport={vi.fn()}1181        onBreachCheckDone={vi.fn()}1182      />1183    );1184    await flushView();11851186    inputText(1187      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1188      "Please improve the feedback flow."1189    );1190    const sendButton = Array.from(container.querySelectorAll("button")).find(1191      (candidate) => candidate.textContent?.trim() === "Send"1192    ) as HTMLButtonElement;1193    sendButton.click();11941195    await vi.waitFor(() => {1196      expect(fetchMock).toHaveBeenCalledTimes(1);1197    });1198    await vi.waitFor(() => {1199      expect(container.textContent).toContain("Thank you");1200    });1201    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1202  });12031204  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1205    sendMessageMock.mockImplementation(async (msg: unknown) => {1206      const m = msg as { type: string };1207      if (m.type === "GET_RECOVERY_WORDS") {1208        return {1209          ok: true,1210          data: [1211            "abandon", "ability", "able", "about", "above", "absent",1212            "absorb", "abstract", "absurd", "abuse", "access", "accident",1213          ],1214        };1215      }1216      return { ok: true, data: null };1217    });1218    const onAutoLockMinutesChange = vi.fn();1219    const onThemeChange = vi.fn();1220    const container = mount(1221      <SettingsView1222        entriesCount={1}1223        breachMonitoring={true}1224        lockOnClose={true}1225        lockedSyncRequiresUnlock={false}1226        autoLockMinutes={5}1227        trashRetentionDays={30}1228        theme="dark"1229        accentColor="peach"1230        textSize="medium"1231        reduceMotion={false}1232        onBreachMonitoringChange={vi.fn()}1233        onLockedSyncRequiresUnlockChange={vi.fn()}1234        onLockOnCloseChange={vi.fn()}1235        onAutoLockMinutesChange={onAutoLockMinutesChange}1236        onTrashRetentionChange={vi.fn()}1237        onThemeChange={onThemeChange}1238        onAccentChange={vi.fn()}1239        onTextSizeChange={vi.fn()}1240        onReduceMotionChange={vi.fn()}1241        shortcuts={DEFAULT_BINDINGS}1242        onShortcutsChange={vi.fn()}1243        onBack={vi.fn()}1244        onImport={vi.fn()}1245        onExport={vi.fn()}1246        onBreachCheckDone={vi.fn()}1247      />1248    );1249    await flushView();1250    sendMessageMock.mockClear();12511252    // ── Auto-lock + theme changes (before password change) ─────────────1253    const selects = container.querySelectorAll("select");1254    changeSelect(selects[0] as HTMLSelectElement, "15");1255    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);12561257    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1258    appearanceCard.click();1259    await flushView();1260    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1261    const themeButton = Array.from(container.querySelectorAll("button")).find(1262      (candidate) => candidate.textContent?.trim() === "Switch to light"1263    ) as HTMLButtonElement;1264    themeButton.click();1265    expect(onThemeChange).toHaveBeenCalledWith("light");12661267    // ── Password change → recovery words display ────────────────────────1268    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1269    protectionCard.click();1270    await flushView();12711272    const changeButton = Array.from(container.querySelectorAll("button")).find(1273      (candidate) => candidate.textContent?.trim() === "change"1274    ) as HTMLButtonElement;1275    changeButton.click();1276    await flushView();12771278    const inputs = container.querySelectorAll('input[type="password"]');1279    inputText(inputs[0] as HTMLInputElement, "current-pass");1280    inputText(inputs[1] as HTMLInputElement, "new-password-123");1281    inputText(inputs[2] as HTMLInputElement, "new-password-123");1282    await flushView();12831284    const saveButton = Array.from(container.querySelectorAll("button")).find(1285      (candidate) => candidate.textContent?.trim() === "save new password"1286    ) as HTMLButtonElement;1287    saveButton.click();1288    await flushView();12891290    expect(sendMessageMock).toHaveBeenCalledWith({1291      type: "CHANGE_PASSWORD",1292      payload: {1293        currentPassword: "current-pass",1294        newPassword: "new-password-123",1295      },1296    });12971298    // After a successful password change the recovery word display replaces settings.1299    expect(container.textContent).toContain("save your recovery words");1300    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1301  });13021303  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1304    sendMessageMock.mockResolvedValue({ ok: true });1305    const container = mount(1306      <EntryView1307        entry={sampleEntry()}1308        onDelete={vi.fn()}1309        onFieldSaved={vi.fn(async () => undefined)}1310      />1311    );13121313    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1314    passwordButton.click();1315    await flushView();13161317    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1318      type: "COPY",1319      payload: { text: "secret123", label: "password", alreadyWritten: false },1320    });1321    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1322      type: "FIRE_CANARY_PING",1323      payload: {1324        webhookUrl: "https://example.com/hook",1325        entryTitle: "GitHub",1326        trigger: "password_copied",1327      },1328    });13291330    sendMessageMock.mockClear();1331    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1332    usernameButton.click();1333    await flushView();13341335    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1336      type: "COPY",1337      payload: { text: "june", label: "username", alreadyWritten: false },1338    });1339    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1340      type: "FIRE_CANARY_PING",1341      payload: {1342        webhookUrl: "https://example.com/hook",1343        entryTitle: "GitHub",1344        trigger: "username_copied",1345      },1346    });1347  });13481349  it("Entry keyboard password copy reports its canary after COPY", async () => {1350    sendMessageMock.mockResolvedValue({ ok: true });1351    mount(1352      <EntryView1353        entry={sampleEntry()}1354        onDelete={vi.fn()}1355        onFieldSaved={vi.fn(async () => undefined)}1356      />1357    );13581359    window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));1360    await flushView();13611362    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1363      type: "COPY",1364      payload: { text: "secret123", label: "password", alreadyWritten: false },1365    });1366    await flushView();1367    expect(sendMessageMock.mock.calls).toContainEqual([{1368      type: "FIRE_CANARY_PING",1369      payload: {1370        webhookUrl: "https://example.com/hook",1371        entryTitle: "GitHub",1372        trigger: "password_copied",1373      },1374    }]);1375  });1376  it("Entry Cmd+C leaves the native clipboard shortcut untouched", async () => {1377    sendMessageMock.mockResolvedValue({ ok: true });1378    mount(1379      <EntryView1380        entry={sampleEntry()}1381        onDelete={vi.fn()}1382        onFieldSaved={vi.fn(async () => undefined)}1383      />1384    );13851386    window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", metaKey: true, bubbles: true }));1387    await flushView();13881389    expect(sendMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: "COPY" }));1390  });13911392  it("Vault password swipe copy reports its canary after COPY", async () => {1393    sendMessageMock.mockResolvedValue({ ok: true });1394    const container = mount(1395      <VaultView1396        entries={[sampleEntry()]}1397        sortOrder="url-first"1398        onSelectEntry={vi.fn()}1399        onSortOrderChange={vi.fn()}1400        onAdd={vi.fn()}1401        onClose={vi.fn()}1402      />1403    );1404    const row = container.querySelector(".peach-entry-row") as HTMLButtonElement;1405    row.dispatchEvent(new TouchEvent("touchstart", {1406      bubbles: true,1407      touches: [{ clientX: 0, clientY: 0 } as Touch],1408    }));1409    row.dispatchEvent(new TouchEvent("touchend", {1410      bubbles: true,1411      changedTouches: [{ clientX: 100, clientY: 0 } as Touch],1412    }));1413    await flushView();14141415    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1416      type: "COPY",1417      payload: { text: "secret123", label: "password", alreadyWritten: false },1418    });1419    await flushView();1420    expect(sendMessageMock.mock.calls).toContainEqual([{1421      type: "FIRE_CANARY_PING",1422      payload: {1423        webhookUrl: "https://example.com/hook",1424        entryTitle: "GitHub",1425        trigger: "password_copied",1426      },1427    }]);1428  });14291430  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1431    const container = mount(1432      <EntryView1433        entry={sampleEntry({1434          type: "passkey",1435          username: undefined,1436          password: undefined,1437          totp: undefined,1438          canary: undefined,1439          passkey: {1440            rpId: "vercel.com",1441            rpName: "Vercel",1442            credentialId: "cred-1",1443            publicKeyCose: "cose",1444            userHandle: "handle",1445            userName: "june",1446            userDisplayName: "June",1447            algorithm: -7,1448            signCount: 0,1449            aaguid: "",1450            backupEligible: false,1451            backupState: false,1452            createdAt: 1,1453          },1454        })}1455        onDelete={vi.fn()}1456        onFieldSaved={vi.fn(async () => undefined)}1457      />1458    );14591460    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1461      (button) => button.textContent?.includes("Advanced")1462    ) as HTMLButtonElement | undefined;1463    expect(advanced).toBeDefined();1464    expect(advanced?.textContent).toContain("+ Advanced");14651466    advanced!.click();1467    await flushView();14681469    expect(container.textContent).toContain("passkey · vercel.com");1470  });14711472  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1473    sendMessageMock.mockResolvedValue({ ok: true });1474    const onFieldSaved = vi.fn(async () => undefined);1475    const container = mount(1476      <EntryView1477        entry={sampleEntry()}1478        onDelete={vi.fn()}1479        onFieldSaved={onFieldSaved}1480      />1481    );14821483    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1484    passwordValue.click();1485    await flushView();14861487    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1488    inputText(passwordInput, "");1489    await flushView();14901491    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1492    saveButton.click();1493    await flushView();14941495    expect(sendMessageMock).not.toHaveBeenCalledWith(1496      expect.objectContaining({ type: "UPDATE_ENTRY" })1497    );1498    expect(onFieldSaved).not.toHaveBeenCalled();14991500    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1501    totpMain.click();15021503    await vi.waitFor(() => {1504      expect(sendMessageMock).toHaveBeenLastCalledWith({1505        type: "COPY",1506        payload: { text: "123456", label: "totp", alreadyWritten: false },1507      });1508    }, { timeout: 500 });1509  });15101511  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1512    sendMessageMock.mockResolvedValue({ ok: true });1513    const container = mount(1514      <EntryView1515        entry={sampleEntry()}1516        onDelete={vi.fn()}1517        onFieldSaved={vi.fn(async () => undefined)}1518      />1519    );15201521    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1522    usernameValue.click();1523    await flushView();15241525    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1526    usernameInput.blur();1527    await flushView();15281529    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1530    expect(sendMessageMock).not.toHaveBeenCalledWith(1531      expect.objectContaining({ type: "UPDATE_ENTRY" })1532    );1533  });15341535  it("Entry keeps the full used-email list available while editing an email", async () => {1536    const container = mount(1537      <EntryView1538        entry={sampleEntry({ email: "[email protected]" })}1539        entries={[1540          sampleEntry({ email: "[email protected]" }),1541          sampleEntry({ id: "entry-2", email: "[email protected]" }),1542          sampleEntry({ id: "entry-3", username: "duckuccino" }),1543        ]}1544        onDelete={vi.fn()}1545        onFieldSaved={vi.fn(async () => undefined)}1546      />1547    );15481549    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1550      (card) => card.querySelector(".peach-label")?.textContent === "email"1551    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1552    emailValue.click();1553    await flushView();15541555    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1556      .map((element) => element.textContent);1557    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1558  });15591560  it("Entry lets TOTP secrets be edited inline", async () => {1561    sendMessageMock.mockResolvedValue({ ok: true });1562    const onFieldSaved = vi.fn(async () => undefined);1563    const container = mount(1564      <EntryView1565        entry={sampleEntry()}1566        onDelete={vi.fn()}1567        onFieldSaved={onFieldSaved}1568      />1569    );15701571    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1572    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1573    await flushView();15741575    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1576    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1577    await flushView();15781579    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1580    saveButton.click();1581    await flushView();15821583    expect(sendMessageMock).toHaveBeenCalledWith({1584      type: "UPDATE_ENTRY",1585      payload: {1586        id: "entry-1",1587        patch: { totp: "NB2W45DFOIZA====" },1588      },1589    });1590    expect(onFieldSaved).toHaveBeenCalled();1591  });15921593  it("Vault seeds and focuses search when a search seed is provided", async () => {1594    const container = mount(1595      <VaultView1596        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1597        sortOrder="url-first"1598        searchSeed="g"1599        searchSeedNonce={1}1600        onSelectEntry={vi.fn()}1601        onSortOrderChange={vi.fn()}1602        onAdd={vi.fn()}1603        onClose={vi.fn()}1604      />1605    );16061607    await flushView();16081609    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1610    expect(search.value).toBe("g");1611    expect(document.activeElement).toBe(search);1612  });16131614  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1615    parseImportSourceMock.mockResolvedValue(1616      sampleImportResult({1617        entries: [1618          {1619            type: "login",1620            title: "Imported site",1621            username: "imported-user",1622            password: "imported-pass",1623            url: "https://imported.test",1624          },1625        ],1626      })1627    );1628    sendMessageMock.mockImplementation(async (msg: unknown) => {1629      const type = (msg as { type?: string }).type;1630      if (type === "GET_ENTRIES") {1631        return {1632          ok: true,1633          data: [1634            sampleEntry({1635              id: "existing-1",1636              title: "Imported site",1637              username: "imported-user",1638              url: "https://imported.test",1639            }),1640          ],1641        };1642      }1643      if (type === "BATCH_ADD_ENTRIES") {1644        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1645      }1646      return { ok: true };1647    });1648    const onImport = vi.fn(async () => undefined);1649    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1650    await flushView();1651    sendMessageMock.mockClear();16521653    await selectImportFile(container);1654    findButton(container, "Import 1").click();1655    await flushView();16561657    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1658    expect(sendMessageMock).toHaveBeenCalledWith({1659      type: "BATCH_ADD_ENTRIES",1660      payload: {1661        entries: [1662          expect.objectContaining({1663            title: "Imported site",1664            username: "imported-user",1665            password: "imported-pass",1666            url: "https://imported.test",1667          }),1668        ],1669      },1670    });1671    expect(onImport).toHaveBeenCalled();1672    expect(container.textContent).toContain("1 entry appears to be duplicates");1673  });16741675  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1676    parseImportSourceMock.mockResolvedValue(sampleImportResult());1677    sendMessageMock.mockImplementation(async (msg: unknown) => {1678      const type = (msg as { type?: string }).type;1679      if (type === "GET_ENTRIES") {1680        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1681      }1682      if (type === "CLEAR_VAULT_ENTRIES") {1683        return { ok: true };1684      }1685      if (type === "BATCH_ADD_ENTRIES") {1686        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1687      }1688      return { ok: true };1689    });1690    const onImport = vi.fn(async () => undefined);1691    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1692    await flushView();1693    sendMessageMock.mockClear();16941695    await selectImportFile(container);1696    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1697    await flushView();16981699    findButton(container, "Replace vault with 1").click();1700    await flushView();17011702    expect(sendMessageMock).not.toHaveBeenCalledWith(1703      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1704    );1705    expect(container.textContent).toContain("Replace forever? This cannot be undone.");17061707    findButton(container, "Yes, replace forever").click();1708    await flushView();17091710    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1711    expect(sendMessageMock).toHaveBeenCalledWith({1712      type: "BATCH_ADD_ENTRIES",1713      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1714    });1715    expect(onImport).toHaveBeenCalled();1716  });17171718  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1719    parseImportSourceMock.mockResolvedValue(sampleImportResult());1720    sendMessageMock.mockImplementation(async (msg: unknown) => {1721      const type = (msg as { type?: string }).type;1722      if (type === "CREATE_VAULT") return { ok: true };1723      if (type === "BATCH_ADD_ENTRIES") {1724        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1725      }1726      return { ok: true };1727    });1728    const onImport = vi.fn(async () => undefined);1729    const container = mount(1730      <ImportView1731        mode="onboarding"1732        replacesExistingVault={true}1733        onBack={vi.fn()}1734        onImport={onImport}1735      />1736    );17371738    await selectImportFile(container);17391740    const createButton = findButton(container, "Create vault + import 1");1741    expect(createButton.disabled).toBe(true);1742    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1743    expect(container.textContent).toContain("Use at least 8 characters");17441745    const masterInput = container.querySelector(1746      'input[placeholder="new master password"]'1747    ) as HTMLInputElement;1748    const confirmInput = container.querySelector(1749      'input[placeholder="confirm master password"]'1750    ) as HTMLInputElement;1751    const revealButton = container.querySelector(1752      'button[aria-label="Show passwords"]'1753    ) as HTMLButtonElement;1754    expect(revealButton).toBeTruthy();1755    revealButton.click();1756    await flushView();1757    expect(masterInput.type).toBe("text");1758    expect(confirmInput.type).toBe("text");17591760    inputText(masterInput, "fresh-master-password");1761    inputText(confirmInput, "fresh-master-password");1762    await flushView();17631764    expect(createButton.disabled).toBe(false);1765    createButton.click();1766    await flushView();17671768    expect(sendMessageMock).toHaveBeenCalledWith({1769      type: "CREATE_VAULT",1770      payload: { password: "fresh-master-password" },1771    });1772    expect(sendMessageMock).toHaveBeenCalledWith({1773      type: "BATCH_ADD_ENTRIES",1774      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1775    });1776    expect(onImport).toHaveBeenCalled();1777  });17781779});