contracts.test.tsx

65.0 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { AddView } from "./Add";16import { NotesEditor } from "./NotesEditor";17import { RecoveryWordEntryView } from "./RecoveryWordEntry";18import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";19import { VaultView } from "./Vault";20import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2122const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();23const triggerRingMock = vi.fn();24const triggerPipPulseMock = vi.fn();25const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();26const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2728vi.mock("../App", () => ({29  sendMessage: (msg: unknown) => sendMessageMock(msg),30  triggerRing: () => triggerRingMock(),31  triggerPipPulse: () => triggerPipPulseMock(),32}));3334vi.mock("../../shared/bridge", () => ({35  sendMessage: (msg: unknown) => sendMessageMock(msg),36}));3738vi.mock("../../shared/import", () => ({39  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),40}));4142vi.mock("../../shared/favicon", async (importOriginal) => {43  const actual = await importOriginal<typeof import("../../shared/favicon")>();44  return { ...actual, faviconUrl: () => null };45});4647vi.mock("../../crypto/totp", () => ({48  generateTOTP: () => ({ code: "123 456", remaining: 24 }),49  parseOtpauthURI: (value: string) =>50    value.startsWith("otpauth://")51      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }52      : null,53}));5455vi.mock("../components/GeneratorDrawer", () => ({56  GeneratorDrawer: () => null,57}));5859vi.mock("../components/RuneBackground", () => ({60  RuneBackground: () => null,61}));6263vi.mock("@zxcvbn-ts/core", () => ({64  zxcvbn: (value: string) => zxcvbnMock(value),65  zxcvbnOptions: { setOptions: vi.fn() },66}));67vi.mock("@zxcvbn-ts/language-common", () => ({68  adjacencyGraphs: {},69  dictionary: {},70}));71vi.mock("@zxcvbn-ts/language-en", () => ({72  translations: {},73  dictionary: {},74}));7576describe("popup contract tests", () => {77  const disposers: Array<() => void> = [];7879  beforeEach(() => {80    document.body.innerHTML = "";81    sendMessageMock.mockReset();82    triggerRingMock.mockReset();83    triggerPipPulseMock.mockReset();84    parseImportSourceMock.mockReset();85    zxcvbnMock.mockReset();86    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));87    parseImportSourceMock.mockResolvedValue({88      format: "unknown",89      label: "Unknown format",90      entries: [],91      totalItems: 0,92      skippedItems: 0,93    });9495    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {96      return window.setTimeout(() => cb(0), 0) as unknown as number;97    });98    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));99    vi.stubGlobal("URL", {100      createObjectURL: vi.fn(() => "blob:peach-test"),101      revokeObjectURL: vi.fn(),102    });103    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});104105    (globalThis as { chrome?: typeof chrome }).chrome = {106      runtime: {107        getManifest: () => ({ version: "1.4.47" }),108        onMessage: { addListener: vi.fn() },109      },110    } as unknown as typeof chrome;111  });112113  afterEach(() => {114    while (disposers.length > 0) {115      disposers.pop()?.();116    }117    vi.restoreAllMocks();118    vi.unstubAllGlobals();119  });120121  function mount(view: JSX.Element | (() => JSX.Element)) {122    const mounted = renderView(view);123    disposers.push(mounted.dispose);124    return mounted.container;125  }126127  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {128    return {129      id: "entry-1",130      type: "login",131      title: "GitHub",132      username: "june",133      password: "secret123",134      url: "https://github.com",135      totp: "JBSWY3DPEHPK3PXP",136      canary: {137        webhookUrl: "https://example.com/hook",138      },139      createdAt: 1,140      modifiedAt: 1,141      ...overrides,142    };143  }144145  function sampleImportResult(146    overrides: Partial<ImportParseResult> = {}147  ): ImportParseResult {148    return {149      format: "csv",150      label: "CSV import",151      entries: [152        {153          type: "login",154          title: "Imported site",155          username: "imported-user",156          password: "imported-pass",157          url: "https://imported.test",158        },159      ],160      totalItems: 1,161      skippedItems: 0,162      ...overrides,163    };164  }165166  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {167    return {168      entriesCount: 1,169      breachMonitoring: true,170      lockOnClose: true,171      autoLockMinutes: 5,172      trashRetentionDays: 30,173      theme: "dark" as const,174      accentColor: "peach" as const,175      textSize: "medium" as const,176      reduceMotion: false,177      onBreachMonitoringChange: vi.fn(),178      onLockOnCloseChange: vi.fn(),179      onAutoLockMinutesChange: vi.fn(),180      onTrashRetentionChange: vi.fn(),181      onThemeChange: vi.fn(),182      onAccentChange: vi.fn(),183      onTextSizeChange: vi.fn(),184      onReduceMotionChange: vi.fn(),185      shortcuts: DEFAULT_BINDINGS,186      onShortcutsChange: vi.fn(),187      onBack: vi.fn(),188      onImport: vi.fn(),189      onExport: vi.fn(),190      onBreachCheckDone: vi.fn(),191      ...overrides,192    };193  }194195  async function selectImportFile(196    container: HTMLElement,197    contents = '{"export":"test"}'198  ) {199    // The Import view now opens onto a menu — click into the file subview200    // before locating the file input. Idempotent: a second click won't re-fire201    // because the menu unmounts once the subview renders.202    const fileCard = findButton(container, "Choose file →");203    if (fileCard) {204      fileCard.click();205      await flushView();206    }207    const input = container.querySelector('input[type="file"]') as HTMLInputElement;208    const file = new File([contents], "import.json", { type: "application/json" });209    Object.defineProperty(input, "files", {210      configurable: true,211      value: [file],212    });213    input.dispatchEvent(new Event("change", { bubbles: true }));214    await flushView();215    await flushView();216  }217218  function findButton(container: HTMLElement, text: string): HTMLButtonElement {219    return Array.from(container.querySelectorAll("button")).find(220      (candidate) => candidate.textContent?.trim() === text221    ) as HTMLButtonElement;222  }223224  it("Lock sends UNLOCK with the entered passphrase", async () => {225    sendMessageMock.mockResolvedValue({ ok: true });226    const onUnlock = vi.fn(async () => undefined);227    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);228229    const password = container.querySelector("#unlock-password") as HTMLInputElement;230    inputText(password, "correct horse battery staple");231    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();232    await flushView();233234    expect(sendMessageMock).toHaveBeenCalledWith({235      type: "UNLOCK",236      payload: { password: "correct horse battery staple" },237    });238    expect(onUnlock).toHaveBeenCalled();239  });240241  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {242    sendMessageMock.mockImplementation(async (msg: unknown) => {243      const type = (msg as { type: string }).type;244      if (type === "GET_PIN_STATUS") {245        return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };246      }247      return type === "GET_SETTINGS"248        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }249        : { ok: true };250    });251    const onUnlock = vi.fn(async () => undefined);252    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);253    await flushView();254255    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");256    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();257    await flushView();258259    expect(sendMessageMock).toHaveBeenCalledWith({260      type: "UNLOCK_WITH_PIN",261      payload: { pin: "1234" },262    });263    expect(onUnlock).toHaveBeenCalled();264    expect(container.textContent).toContain("use master password");265  });266267  it("places the PIN method switch below the unlock button", async () => {268    sendMessageMock.mockImplementation(async (msg: unknown) => {269      const type = (msg as { type: string }).type;270      if (type === "GET_PIN_STATUS") {271        return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };272      }273      return type === "GET_SETTINGS"274        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }275        : { ok: true };276    });277    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);278    await flushView();279280    const buttons = Array.from(container.querySelectorAll("button"));281    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");282    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");283    expect(pinIndex).toBeGreaterThan(unlockIndex);284  });285286  it("keeps standalone note creation in Notes mode", async () => {287    const chromeApi = globalThis.chrome as unknown as {288      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };289    };290    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };291292    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);293    await flushView();294295    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))296      .toEqual(["login", "card"]);297    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();298  });299300  it("gives Notes mode the full note-formatting toolbar", async () => {301    const container = mount(302      <NotesEditor303        note={null}304        onBack={vi.fn()}305        onSaved={vi.fn()}306        onDelete={vi.fn()}307      />308    );309    await flushView();310311    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();312    expect(container.querySelector('[title="Divider"]')).toBeTruthy();313    expect(container.querySelector('[title="Quote"]')).toBeTruthy();314    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);315    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);316    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");317  });318319  it("saves formatting applied by the Notes toolbar before leaving", async () => {320    const savedNote = {321      id: "note-1",322      type: "note" as const,323      title: "Untitled",324      notes: "<b>formatted</b>",325      createdAt: 1,326      modifiedAt: 2,327    };328    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });329    const originalExecCommand = document.execCommand;330    const execCommandMock = vi.fn(() => {331      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");332      if (body) body.innerHTML = "<b>formatted</b>";333      return true;334    });335    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });336337    try {338      const container = mount(339        <NotesEditor340          note={null}341          onBack={vi.fn()}342          onSaved={vi.fn()}343          onDelete={vi.fn()}344        />345      );346      await flushView();347      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(348        new MouseEvent("mousedown", { bubbles: true, cancelable: true })349      );350      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);351      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();352      await flushView();353354      expect(sendMessageMock).toHaveBeenCalledWith({355        type: "SAVE_NOTE",356        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },357      });358    } finally {359      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });360    }361  });362363  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {364    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);365    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;366    const password = container.querySelector("#master-password") as HTMLInputElement;367    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;368369    inputText(password, "short");370    inputText(confirm, "short");371    await flushView();372    expect(createButton.disabled).toBe(true);373374    zxcvbnMock.mockImplementation(() => ({ score: 1 }));375    inputText(password, "weakweakweak");376    inputText(confirm, "weakweakweak");377    await flushView();378    expect(createButton.disabled).toBe(true);379  });380381  it("Lock create-vault header pins the wordmark left with back on the right", async () => {382    const container = mount(383      <LockView384        hasVault={false}385        ready={true}386        onUnlock={vi.fn()}387        onBackToOnboarding={() => {}}388      />389    );390    const header = container.querySelector(".peach-newvault-header") as HTMLElement;391    const wordmark = header.querySelector(".peach-wordmark-stack");392    const back = header.querySelector(".peach-newvault-back");393    expect(wordmark).toBeTruthy();394    expect(back).toBeTruthy();395    // Wordmark first so it never shifts between onboarding screens; back396    // takes the opposite corner instead of displacing it.397    expect(header.firstElementChild).toBe(wordmark);398    expect(header.lastElementChild).toBe(back);399    await flushView();400  });401402  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {403    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock404    sendMessageMock.mockImplementation(405      async (msg: unknown) => {406        const m = msg as { type: string; payload?: { password?: string } };407        if (m.type === "CREATE_VAULT") return { ok: true };408        if (m.type === "GET_RECOVERY_WORDS") {409          // Return 12 fake words — must pass findInvalidWords410          return {411            ok: true,412            data: [413              "abandon", "ability", "able", "about", "above", "absent",414              "absorb", "abstract", "absurd", "abuse", "access", "accident",415            ],416          };417        }418        return { ok: true };419      }420    );421    zxcvbnMock.mockImplementation(() => ({ score: 3 }));422    const onUnlock = vi.fn(async () => undefined);423    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);424425    const password = container.querySelector("#master-password") as HTMLInputElement;426    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;427    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;428429    inputText(password, "correct horse battery staple");430    inputText(confirm, "correct horse battery staple");431    await flushView();432    expect(createButton.disabled).toBe(false);433434    pressEnter(confirm);435    await flushView();436437    expect(sendMessageMock).toHaveBeenCalledWith({438      type: "CREATE_VAULT",439      payload: { password: "correct horse battery staple" },440    });441    // After CREATE_VAULT success, the recovery word display is shown.442    // onUnlock is NOT called yet — user must first confirm their recovery words.443    expect(onUnlock).not.toHaveBeenCalled();444  });445446  it("recovery display asks for three distinct randomized word positions and confirms", async () => {447    const words12 = [448      "abandon", "ability", "able", "about", "above", "absent",449      "absorb", "abstract", "absurd", "abuse", "access", "accident",450    ];451    sendMessageMock.mockImplementation(async (msg: unknown) => {452      const type = (msg as { type?: string }).type;453      if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };454      return { ok: true };455    });456457    vi.useFakeTimers();458    const onDone = vi.fn();459    try {460      const container = mount(<RecoveryWordDisplayView onDone={onDone} />);461      // onMount fires GET_RECOVERY_WORDS; let the resolved promise + render settle462      await vi.advanceTimersByTimeAsync(0);463464      const prompts = Array.from(465        container.querySelectorAll(".peach-recovery-confirm-num")466      ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));467      expect(prompts).toHaveLength(3);468      expect(new Set(prompts).size).toBe(3);469      for (const p of prompts) {470        expect(p).toBeGreaterThanOrEqual(1);471        expect(p).toBeLessThanOrEqual(12);472      }473474      // Inputs stay disabled until the 5s countdown expires475      const inputs = Array.from(476        container.querySelectorAll(".peach-recovery-confirm-input")477      ) as HTMLInputElement[];478      expect(inputs).toHaveLength(3);479      expect(inputs[0]!.disabled).toBe(true);480      await vi.advanceTimersByTimeAsync(5000);481      expect(inputs[0]!.disabled).toBe(false);482483      prompts.forEach((pos, i) => {484        inputText(inputs[i]!, words12[pos - 1]!);485      });486487      const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;488      expect(confirmBtn.disabled).toBe(false);489      confirmBtn.click();490      await vi.advanceTimersByTimeAsync(700); // success pause before onDone491492      expect(onDone).toHaveBeenCalledTimes(1);493    } finally {494      vi.useRealTimers();495    }496  });497498  it("Recovery words flow submits the new master password and completes", async () => {499    sendMessageMock.mockImplementation(async (msg: unknown) => {500      const type = (msg as { type?: string }).type;501      if (type === "VALIDATE_RECOVERY_WORDS") {502        return { ok: true, data: { valid: true, invalid: [] } };503      }504      if (type === "RECOVER_WITH_WORDS") return { ok: true };505      return { ok: true };506    });507    const canvasContext = {508      clearRect: vi.fn(),509      fillText: vi.fn(),510      beginPath: vi.fn(),511      closePath: vi.fn(),512      arc: vi.fn(),513      fill: vi.fn(),514      stroke: vi.fn(),515      save: vi.fn(),516      restore: vi.fn(),517      translate: vi.fn(),518      rotate: vi.fn(),519      scale: vi.fn(),520      drawImage: vi.fn(),521      measureText: vi.fn(() => ({ width: 0 })),522      setTransform: vi.fn(),523      fillRect: vi.fn(),524      strokeRect: vi.fn(),525    } as unknown as CanvasRenderingContext2D;526    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);527528    const onRecoveryComplete = vi.fn(async () => undefined);529    const container = mount(530      <RecoveryWordEntryView531        onCancel={vi.fn()}532        onRecoveryComplete={onRecoveryComplete}533      />534    );535536    const recoveryWords = WORD_LIST.slice(0, 12);537    for (let i = 0; i < recoveryWords.length; i++) {538      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;539      inputText(input, recoveryWords[i]!);540    }541    await flushView();542543    findButton(container, "verify words").click();544    await flushView();545546    expect(container.textContent).toContain("new master password");547548    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;549    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;550    inputText(newPassword, "fresh-recovery-password");551    inputText(confirmPassword, "fresh-recovery-password");552    await flushView();553554    findButton(container, "recover vault").click();555    await flushView();556557    expect(sendMessageMock).toHaveBeenCalledWith({558      type: "RECOVER_WITH_WORDS",559      payload: {560        words: recoveryWords,561        newPassword: "fresh-recovery-password",562      },563    });564    expect(onRecoveryComplete).toHaveBeenCalled();565  });566567  it("Export menu surfaces both backup formats", async () => {568    const container = mount(<ExportView onBack={vi.fn()} />);569    await flushView();570571    expect(container.textContent).toContain("PeachScript");572    expect(container.textContent).toContain("CSV");573  });574575  it("Lock can open directly on backup restore from onboarding", async () => {576    const container = mount(() => (577      <LockView578        hasVault={false}579        ready={true}580        onUnlock={vi.fn()}581        initialRestore={true}582      />583    ));584    await flushView();585586    expect(container.textContent).toContain("PeachScript");587    expect(container.textContent).toContain("Codex");588    expect(container.textContent).toContain("CSV");589    expect(container.querySelector("#master-password")).toBeNull();590  });591592  it("Lock restore back returns to onboarding, not the create-vault screen", async () => {593    const onBackToOnboarding = vi.fn();594    const container = mount(() => (595      <LockView596        hasVault={false}597        ready={true}598        onUnlock={vi.fn()}599        initialRestore={true}600        onBackToOnboarding={onBackToOnboarding}601      />602    ));603    await flushView();604605    (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();606    await flushView();607    expect(onBackToOnboarding).toHaveBeenCalledTimes(1);608  });609610  it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {611    const container = mount(() => (612      <LockView613        hasVault={false}614        ready={true}615        onUnlock={vi.fn()}616        initialRestore={true}617        initialRestoreSource="symbols"618      />619    ));620    await flushView();621622    expect(container.querySelector(".peach-restore-script")).toBeTruthy();623    expect(container.querySelector("#restore-backup-pass")).toBeTruthy();624    expect(container.querySelector(".peach-restore-sources")).toBeNull();625  });626627  it("Lock restore resumes on the picked CSV source, not the picker", async () => {628    const container = mount(() => (629      <LockView630        hasVault={false}631        ready={true}632        onUnlock={vi.fn()}633        initialRestore={true}634        initialRestoreSource="csv"635      />636    ));637    await flushView();638639    expect(container.querySelector("#restore-csv-pass")).toBeTruthy();640    expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();641    expect(container.querySelector(".peach-restore-sources")).toBeNull();642  });643644  it("Lock restore PeachScript form loads a dropped .peach file", async () => {645    const container = mount(() => (646      <LockView647        hasVault={false}648        ready={true}649        onUnlock={vi.fn()}650        initialRestore={true}651        initialRestoreSource="symbols"652      />653    ));654    await flushView();655656    const form = container.querySelector(".peach-restore-form") as HTMLElement;657    expect(form).toBeTruthy();658659    // The OS file picker dismisses the action popup, so drag-and-drop is the660    // file path that survives; a drop must land the file in the script field.661    const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });662    const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {663      dataTransfer?: { files: File[] };664    };665    drop.dataTransfer = { files: [file] };666    form.dispatchEvent(drop);667    await flushView();668669    const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;670    expect(script.value).toBe("dropped-peach-backup");671  });672673  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {674    const device = {675      deviceId: "phone-1",676      deviceName: "June's Phone",677      deviceType: "android" as const,678      longTermPublicKeyHex: "a".repeat(64),679      signingPublicKeyHex: "b".repeat(64),680      pairedAt: 1,681      trustSource: "direct" as const,682    };683    sendMessageMock.mockImplementation(async (msg: unknown) => {684      const m = msg as { type: string };685      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };686      return { ok: true, data: null };687    });688689    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);690    for (let i = 0; i < 30; i++) await Promise.resolve();691692    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>693      /sync/i.test(b.textContent ?? ""),694    );695    openSync?.click();696    for (let i = 0; i < 30; i++) await Promise.resolve();697698    const remove = Array.from(container.querySelectorAll("button")).find(699      (b) => (b.textContent ?? "").trim() === "remove",700    );701    expect(remove).toBeTruthy();702703    // Guard against a vacuous pass: the disclosure must not already be on704    // screen before the user asks to remove anything.705    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();706707    remove!.click();708    for (let i = 0; i < 10; i++) await Promise.resolve();709710    // Revocation must never be presented as securing the vault against the711    // removed device — recovery words survive it and restore everything.712    const note = container.querySelector(".peach-unpair-confirm-note");713    expect(note).toBeTruthy();714    const text = note!.textContent ?? "";715    expect(text).toContain("master password");716    expect(text).toContain("recovery words");717    expect(text).toContain("future changes");718  });719720  it("PB-26-709: pending device approval identifies the key, time, provenance, and vault scope", async () => {721    sendMessageMock.mockImplementation(async (msg: unknown) => {722      const m = msg as { type: string };723      if (m.type === "GET_PENDING_VOUCHED_DEVICES") {724        return {725          ok: true,726          data: [{727            deviceId: "phone-source-id",728            deviceName: "June's Phone",729            deviceType: "android",730            longTermPublicKeyHex: "a1b2c3d4e5f6" + "0".repeat(52),731            signingPublicKeyHex: "b".repeat(64),732            pairedAt: Date.UTC(2026, 8, 5),733            receivedAt: Date.UTC(2026, 8, 6, 20, 15),734            vouchedByDeviceId: "trusted-browser-id",735          }],736        };737      }738      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [] } };739      return { ok: true, data: null };740    });741742    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);743    for (let i = 0; i < 30; i++) await Promise.resolve();744    Array.from(container.querySelectorAll("button"))745      .find((button) => /sync/i.test(button.textContent ?? ""))746      ?.click();747    for (let i = 0; i < 30; i++) await Promise.resolve();748749    expect(container.textContent).toContain("a1b2 c3d4 e5f6");750    expect(container.textContent).toContain("trusted-browser-id");751    expect(container.textContent).toContain("2026-09-06");752    expect(container.textContent).toContain("existing vault contents");753  });754755  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {756    const device = {757      deviceId: "phone-1",758      deviceName: "June's Phone",759      deviceType: "android" as const,760      longTermPublicKeyHex: "a".repeat(64),761      signingPublicKeyHex: "b".repeat(64),762      pairedAt: 1,763      trustSource: "direct" as const,764    };765    let paired: typeof device[] = [device];766    sendMessageMock.mockImplementation(async (msg: any) => {767      if (msg?.type === "LIST_PAIRED_DEVICES") {768        return { ok: true, data: { devices: [...paired] } };769      }770      if (msg?.type === "UNPAIR_DEVICE") {771        // Mirror the real handler: UNPAIR_DEVICE drops the row from the772        // registry. The next LIST_PAIRED_DEVICES must not see it.773        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);774        return { ok: true };775      }776      if (msg?.type === "RENAME_PAIRED_DEVICE") {777        paired = paired.map((d) =>778          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,779        );780        return { ok: true };781      }782      return { ok: true, data: null };783    });784785    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);786    for (let i = 0; i < 30; i++) await Promise.resolve();787    Array.from(container.querySelectorAll("button"))788      .find((b) => /sync/i.test(b.textContent ?? ""))789      ?.click();790    for (let i = 0; i < 30; i++) await Promise.resolve();791792    // The list itself: extracting the relay selector must not take the hub with it.793    expect(container.textContent).toContain("PAIRED DEVICES");794    expect(container.textContent).toContain("June's Phone");795    expect(container.textContent).toContain("Android");796    Array.from(container.querySelectorAll("button"))797      .find((b) => (b.textContent ?? "").trim() === "rename")!798      .click();799    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;800    renameInput.value = "Travel phone";801    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));802    Array.from(container.querySelectorAll("button"))803      .find((b) => (b.textContent ?? "").trim() === "save")!804      .click();805    for (let i = 0; i < 30; i++) await Promise.resolve();806    expect(sendMessageMock).toHaveBeenCalledWith({807      type: "RENAME_PAIRED_DEVICE",808      payload: { deviceId: "phone-1", name: "Travel phone" },809    });810    expect(container.textContent).toContain("Travel phone");811    const syncNowButton = container.querySelector<HTMLButtonElement>(812      'button[aria-label="Sync this vault now"]',813    )!;814    const syncStatusRow = syncNowButton.closest(".peach-settings-row--meta");815    const syncCard = syncNowButton.closest('[data-settings-card-id="sync"]');816    expect(syncStatusRow).not.toBeNull();817    expect(syncStatusRow?.contains(syncNowButton)).toBe(true);818    expect(syncCard?.querySelectorAll(".peach-settings-row--meta")).toHaveLength(1);819    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));820    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;821    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;822    expect(823      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,824    ).toBeTruthy();825    expect(826      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,827    ).toBeTruthy();828829    Array.from(container.querySelectorAll("button"))830      .find((b) => (b.textContent ?? "").trim() === "remove")!831      .click();832    for (let i = 0; i < 10; i++) await Promise.resolve();833    const confirm = Array.from(834      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),835    ).find((b) => (b.textContent ?? "").trim() === "remove");836    confirm!.click();837    for (let i = 0; i < 30; i++) await Promise.resolve();838839    expect(sendMessageMock).toHaveBeenCalledWith({840      type: "UNPAIR_DEVICE",841      payload: { deviceId: "phone-1" },842    });843    // The list must be refreshed and the device must be gone. The regression844    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but845    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.846    expect(container.textContent).not.toContain("June's Phone");847    expect(container.textContent).not.toContain("PAIRED DEVICES");848  });849  it("Settings keeps the confirmation and shows revoke failures", async () => {850    const device = {851      deviceId: "phone-1",852      deviceName: "June's Phone",853      deviceType: "android" as const,854      longTermPublicKeyHex: "a".repeat(64),855      signingPublicKeyHex: "b".repeat(64),856      pairedAt: 1,857      trustSource: "direct" as const,858    };859    sendMessageMock.mockImplementation(async (msg: unknown) => {860      const message = msg as { type?: string; payload?: { deviceId?: string } };861      if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };862      if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" };863      return { ok: true, data: null };864    });865866    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);867    for (let i = 0; i < 30; i++) await Promise.resolve();868    for (let i = 0; i < 30; i++) await Promise.resolve();869    Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();870    for (let i = 0; i < 30; i++) await Promise.resolve();871    Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();872    for (let i = 0; i < 10; i++) await Promise.resolve();873    Array.from(container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();874    for (let i = 0; i < 30; i++) await Promise.resolve();875876    expect(container.textContent).toContain("trust_data_corrupted");877    expect(container.textContent).toContain("June's Phone");878  });879880  it("Settings drops the secure-reset panel once the device is on the settled generation", async () => {881    const openSyncCard = async (state: "V2_ACTIVE" | "V2_ANCHOR" | "LEGACY_ACTIVE") => {882      sendMessageMock.mockImplementation(async (msg: unknown) => {883        const message = msg as { type?: string };884        if (message.type === "GET_SYNC_GENERATION") {885          return { ok: true, data: { schemaVersion: 1, generation: 2, state, createdAt: 1 } };886        }887        return { ok: true, data: null };888      });889      const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);890      for (let i = 0; i < 30; i++) await Promise.resolve();891      Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();892      for (let i = 0; i < 30; i++) await Promise.resolve();893      return container;894    };895896    // Settled: the migration is over, so the panel must not linger as a label.897    const settled = await openSyncCard("V2_ACTIVE");898    expect(settled.textContent).not.toContain("SECURE SYNC RESET");899    expect(settled.textContent).toContain("ADD A DEVICE");900901    // Still actionable states keep the panel and its explanation.902    const anchored = await openSyncCard("V2_ANCHOR");903    expect(anchored.textContent).toContain("SECURE SYNC RESET");904    expect(anchored.textContent).toContain("Not re-paired");905906    const legacy = await openSyncCard("LEGACY_ACTIVE");907    expect(legacy.textContent).toContain("SECURE SYNC RESET");908    expect(legacy.textContent).toContain("Legacy Sync generation");909  });910911  it("Settings keeps connection and recovery details collapsed until requested", async () => {912    sendMessageMock.mockImplementation(async (msg: any) => {913      if (msg?.type === "LIST_PAIRED_DEVICES") {914        return { ok: true, data: { devices: [] } };915      }916      return { ok: true, data: null };917    });918919    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);920    for (let i = 0; i < 30; i++) await Promise.resolve();921    Array.from(container.querySelectorAll("button"))922      .find((button) => /sync/i.test(button.textContent ?? ""))923      ?.click();924    for (let i = 0; i < 20; i++) await Promise.resolve();925926    expect(container.textContent).toContain("SYNC STATUS");927    expect(container.textContent).toContain("ADD A DEVICE");928    expect(container.textContent).not.toContain("Public relay");929    expect(container.textContent).not.toContain("Rotate sync identity");930931    const connection = Array.from(container.querySelectorAll("button"))932      .find((button) => button.textContent?.includes("CONNECTION"));933    connection?.click();934    await Promise.resolve();935    expect(container.textContent).toContain("Public relay");936937    const recovery = Array.from(container.querySelectorAll("button"))938      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));939    recovery?.click();940    await Promise.resolve();941    expect(container.textContent).toContain("Rotate sync identity");942  });943944  it("Settings requires current, new, and confirm passwords before enabling save", async () => {945    sendMessageMock.mockResolvedValue({ ok: true, data: null });946    const container = mount(947      <SettingsView948        entriesCount={1}949        breachMonitoring={true}950        lockOnClose={true}951        autoLockMinutes={5}952        trashRetentionDays={30}953        theme="dark"954        accentColor="peach"955        textSize="medium"956        reduceMotion={false}957        onBreachMonitoringChange={vi.fn()}958        onLockOnCloseChange={vi.fn()}959        onAutoLockMinutesChange={vi.fn()}960        onTrashRetentionChange={vi.fn()}961        onThemeChange={vi.fn()}962        onAccentChange={vi.fn()}963        onTextSizeChange={vi.fn()}964        onReduceMotionChange={vi.fn()}965        shortcuts={DEFAULT_BINDINGS}966        onShortcutsChange={vi.fn()}967        onBack={vi.fn()}968        onImport={vi.fn()}969        onExport={vi.fn()}970        onBreachCheckDone={vi.fn()}971      />972    );973    await flushView();974975    expect(container.querySelectorAll(".stk-card").length).toBe(7);976    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]977    // "open settings to card I (Peach Pro) instead of card II (Protection)".978    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");979980    const changeButton = Array.from(container.querySelectorAll("button")).find(981      (candidate) => candidate.textContent?.trim() === "change"982    ) as HTMLButtonElement;983    changeButton.click();984    await flushView();985986    const saveButton = Array.from(container.querySelectorAll("button")).find(987      (candidate) => candidate.textContent?.trim() === "save new password"988    ) as HTMLButtonElement;989    expect(saveButton.disabled).toBe(true);990991    const inputs = container.querySelectorAll('input[type="password"]');992    inputText(inputs[0] as HTMLInputElement, "current-pass");993    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");994    await flushView();995    expect(saveButton.disabled).toBe(true);996997    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");998    await flushView();999    expect(saveButton.disabled).toBe(false);1000  });10011002  it("Settings feedback highlights the selected category and hides the form after submit", async () => {1003    sendMessageMock.mockResolvedValue({ ok: true, data: null });1004    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1005      ok: true,1006      json: async () => ({ success: true }),1007    }));1008    vi.stubGlobal("fetch", fetchMock);10091010    const container = mount(1011      <SettingsView1012        entriesCount={1}1013        breachMonitoring={true}1014        lockOnClose={true}1015        autoLockMinutes={5}1016        trashRetentionDays={30}1017        theme="dark"1018        accentColor="peach"1019        textSize="medium"1020        reduceMotion={false}1021        initialSection="feedback"1022        onBreachMonitoringChange={vi.fn()}1023        onLockOnCloseChange={vi.fn()}1024        onAutoLockMinutesChange={vi.fn()}1025        onTrashRetentionChange={vi.fn()}1026        onThemeChange={vi.fn()}1027        onAccentChange={vi.fn()}1028        onTextSizeChange={vi.fn()}1029        onReduceMotionChange={vi.fn()}1030        shortcuts={DEFAULT_BINDINGS}1031        onShortcutsChange={vi.fn()}1032        onBack={vi.fn()}1033        onImport={vi.fn()}1034        onExport={vi.fn()}1035        onBreachCheckDone={vi.fn()}1036      />1037    );1038    await flushView();10391040    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;1041    expect(feedbackCard.classList.contains("is-active")).toBe(true);10421043    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];1044    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;1045    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;1046    expect(bugButton.getAttribute("aria-pressed")).toBe("true");1047    expect(featureButton.getAttribute("aria-pressed")).toBe("false");10481049    featureButton.click();1050    await flushView();10511052    expect(bugButton.getAttribute("aria-pressed")).toBe("false");1053    expect(featureButton.getAttribute("aria-pressed")).toBe("true");1054    expect(featureButton.classList.contains("is-active")).toBe(true);10551056    inputText(1057      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1058      '<script>alert("xss")</script> Please make this easier to see & safer.'1059    );1060    const sendButton = Array.from(container.querySelectorAll("button")).find(1061      (candidate) => candidate.textContent?.trim() === "Send"1062    ) as HTMLButtonElement;1063    sendButton.click();10641065    await vi.waitFor(() => {1066      expect(container.textContent).toContain("Thank you");1067    });10681069    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));1070    expect(requestBody.subject).toContain("Feature request");1071    expect(requestBody.message).toBe(1072      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."1073    );1074    expect(requestBody.message).not.toContain("<script>");1075    expect(container.textContent).toContain("Someone will process it soon");1076    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1077    expect(container.querySelector(".peach-feedback-submit")).toBeNull();1078  });10791080  it("Settings keeps license activation on the full-screen celebration path", async () => {1081    const onLicenseChange = vi.fn(async () => undefined);1082    sendMessageMock.mockImplementation(async (msg: unknown) => {1083      const type = (msg as { type?: string }).type;1084      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };1085      if (type === "ACTIVATE_LICENSE") {1086        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1087      }1088      if (type === "GET_ENTRIES") return { ok: true, data: [] };1089      return { ok: true, data: null };1090    });10911092    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);1093    await flushView();10941095    inputText(1096      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,1097      "PEACH-TEST-KEY",1098    );1099    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;1100    apply.click();11011102    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1103    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1104    expect(container.textContent).not.toContain("thank you ✦");1105  });11061107  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1108    sendMessageMock.mockImplementation(async (msg: unknown) => {1109      const type = (msg as { type?: string }).type;1110      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1111      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1112      if (type === "GET_ENTRIES") return { ok: true, data: [] };1113      return { ok: true, data: null };1114    });11151116    const onLicenseChangeFromStar = vi.fn();1117    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1118    await flushView();1119    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1120    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1121    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();11221123    const onLicenseChangeFromPeach = vi.fn();1124    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1125    await flushView();1126    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1127    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1128    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1129  });11301131  it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1132    sendMessageMock.mockResolvedValue({ ok: true, data: null });1133    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1134      ok: true,1135      json: async () => ({ success: true }),1136    }));1137    vi.stubGlobal("fetch", fetchMock);1138    // Firefox-style permissions API reporting the optional consent as absent.1139    // The old code gated send behind this and silently returned to idle, which1140    // read as a dead Send button. The gate is gone: Send must always send.1141    vi.stubGlobal("browser", {1142      permissions: {1143        getAll: vi.fn(async () => ({ data_collection: [] })),1144        request: vi.fn(async () => false),1145      },1146    });11471148    const container = mount(1149      <SettingsView1150        entriesCount={1}1151        breachMonitoring={true}1152        lockOnClose={true}1153        autoLockMinutes={5}1154        trashRetentionDays={30}1155        theme="dark"1156        accentColor="peach"1157        textSize="medium"1158        reduceMotion={false}1159        initialSection="feedback"1160        onBreachMonitoringChange={vi.fn()}1161        onLockOnCloseChange={vi.fn()}1162        onAutoLockMinutesChange={vi.fn()}1163        onTrashRetentionChange={vi.fn()}1164        onThemeChange={vi.fn()}1165        onAccentChange={vi.fn()}1166        onTextSizeChange={vi.fn()}1167        onReduceMotionChange={vi.fn()}1168        shortcuts={DEFAULT_BINDINGS}1169        onShortcutsChange={vi.fn()}1170        onBack={vi.fn()}1171        onImport={vi.fn()}1172        onExport={vi.fn()}1173        onBreachCheckDone={vi.fn()}1174      />1175    );1176    await flushView();11771178    inputText(1179      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1180      "Please improve the feedback flow."1181    );1182    const sendButton = Array.from(container.querySelectorAll("button")).find(1183      (candidate) => candidate.textContent?.trim() === "Send"1184    ) as HTMLButtonElement;1185    sendButton.click();11861187    await vi.waitFor(() => {1188      expect(fetchMock).toHaveBeenCalledTimes(1);1189    });1190    await vi.waitFor(() => {1191      expect(container.textContent).toContain("Thank you");1192    });1193    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1194  });11951196  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1197    sendMessageMock.mockImplementation(async (msg: unknown) => {1198      const m = msg as { type: string };1199      if (m.type === "GET_RECOVERY_WORDS") {1200        return {1201          ok: true,1202          data: [1203            "abandon", "ability", "able", "about", "above", "absent",1204            "absorb", "abstract", "absurd", "abuse", "access", "accident",1205          ],1206        };1207      }1208      return { ok: true, data: null };1209    });1210    const onAutoLockMinutesChange = vi.fn();1211    const onThemeChange = vi.fn();1212    const container = mount(1213      <SettingsView1214        entriesCount={1}1215        breachMonitoring={true}1216        lockOnClose={true}1217        autoLockMinutes={5}1218        trashRetentionDays={30}1219        theme="dark"1220        accentColor="peach"1221        textSize="medium"1222        reduceMotion={false}1223        onBreachMonitoringChange={vi.fn()}1224        onLockOnCloseChange={vi.fn()}1225        onAutoLockMinutesChange={onAutoLockMinutesChange}1226        onTrashRetentionChange={vi.fn()}1227        onThemeChange={onThemeChange}1228        onAccentChange={vi.fn()}1229        onTextSizeChange={vi.fn()}1230        onReduceMotionChange={vi.fn()}1231        shortcuts={DEFAULT_BINDINGS}1232        onShortcutsChange={vi.fn()}1233        onBack={vi.fn()}1234        onImport={vi.fn()}1235        onExport={vi.fn()}1236        onBreachCheckDone={vi.fn()}1237      />1238    );1239    await flushView();1240    sendMessageMock.mockClear();12411242    // ── Auto-lock + theme changes (before password change) ─────────────1243    const selects = container.querySelectorAll("select");1244    changeSelect(selects[0] as HTMLSelectElement, "15");1245    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);12461247    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1248    appearanceCard.click();1249    await flushView();1250    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1251    const themeButton = Array.from(container.querySelectorAll("button")).find(1252      (candidate) => candidate.textContent?.trim() === "Switch to light"1253    ) as HTMLButtonElement;1254    themeButton.click();1255    expect(onThemeChange).toHaveBeenCalledWith("light");12561257    // ── Password change → recovery words display ────────────────────────1258    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1259    protectionCard.click();1260    await flushView();12611262    const changeButton = Array.from(container.querySelectorAll("button")).find(1263      (candidate) => candidate.textContent?.trim() === "change"1264    ) as HTMLButtonElement;1265    changeButton.click();1266    await flushView();12671268    const inputs = container.querySelectorAll('input[type="password"]');1269    inputText(inputs[0] as HTMLInputElement, "current-pass");1270    inputText(inputs[1] as HTMLInputElement, "new-password-123");1271    inputText(inputs[2] as HTMLInputElement, "new-password-123");1272    await flushView();12731274    const saveButton = Array.from(container.querySelectorAll("button")).find(1275      (candidate) => candidate.textContent?.trim() === "save new password"1276    ) as HTMLButtonElement;1277    saveButton.click();1278    await flushView();12791280    expect(sendMessageMock).toHaveBeenCalledWith({1281      type: "CHANGE_PASSWORD",1282      payload: {1283        currentPassword: "current-pass",1284        newPassword: "new-password-123",1285      },1286    });12871288    // After a successful password change the recovery word display replaces settings.1289    expect(container.textContent).toContain("save your recovery words");1290    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1291  });12921293  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1294    sendMessageMock.mockResolvedValue({ ok: true });1295    const container = mount(1296      <EntryView1297        entry={sampleEntry()}1298        onDelete={vi.fn()}1299        onFieldSaved={vi.fn(async () => undefined)}1300      />1301    );13021303    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1304    passwordButton.click();1305    await flushView();13061307    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1308      type: "COPY",1309      payload: { text: "secret123", label: "password", alreadyWritten: false },1310    });1311    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1312      type: "FIRE_CANARY_PING",1313      payload: {1314        webhookUrl: "https://example.com/hook",1315        entryTitle: "GitHub",1316        trigger: "password_copied",1317      },1318    });13191320    sendMessageMock.mockClear();1321    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1322    usernameButton.click();1323    await flushView();13241325    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1326      type: "COPY",1327      payload: { text: "june", label: "username", alreadyWritten: false },1328    });1329    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1330      type: "FIRE_CANARY_PING",1331      payload: {1332        webhookUrl: "https://example.com/hook",1333        entryTitle: "GitHub",1334        trigger: "username_copied",1335      },1336    });1337  });13381339  it("Entry keyboard password copy reports its canary after COPY", async () => {1340    sendMessageMock.mockResolvedValue({ ok: true });1341    mount(1342      <EntryView1343        entry={sampleEntry()}1344        onDelete={vi.fn()}1345        onFieldSaved={vi.fn(async () => undefined)}1346      />1347    );13481349    window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));1350    await flushView();13511352    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1353      type: "COPY",1354      payload: { text: "secret123", label: "password", alreadyWritten: false },1355    });1356    await flushView();1357    expect(sendMessageMock.mock.calls).toContainEqual([{1358      type: "FIRE_CANARY_PING",1359      payload: {1360        webhookUrl: "https://example.com/hook",1361        entryTitle: "GitHub",1362        trigger: "password_copied",1363      },1364    }]);1365  });1366  it("Entry Cmd+C leaves the native clipboard shortcut untouched", async () => {1367    sendMessageMock.mockResolvedValue({ ok: true });1368    mount(1369      <EntryView1370        entry={sampleEntry()}1371        onDelete={vi.fn()}1372        onFieldSaved={vi.fn(async () => undefined)}1373      />1374    );13751376    window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", metaKey: true, bubbles: true }));1377    await flushView();13781379    expect(sendMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: "COPY" }));1380  });13811382  it("Vault password swipe copy reports its canary after COPY", async () => {1383    sendMessageMock.mockResolvedValue({ ok: true });1384    const container = mount(1385      <VaultView1386        entries={[sampleEntry()]}1387        sortOrder="url-first"1388        onSelectEntry={vi.fn()}1389        onSortOrderChange={vi.fn()}1390        onAdd={vi.fn()}1391        onClose={vi.fn()}1392      />1393    );1394    const row = container.querySelector(".peach-entry-row") as HTMLButtonElement;1395    row.dispatchEvent(new TouchEvent("touchstart", {1396      bubbles: true,1397      touches: [{ clientX: 0, clientY: 0 } as Touch],1398    }));1399    row.dispatchEvent(new TouchEvent("touchend", {1400      bubbles: true,1401      changedTouches: [{ clientX: 100, clientY: 0 } as Touch],1402    }));1403    await flushView();14041405    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1406      type: "COPY",1407      payload: { text: "secret123", label: "password", alreadyWritten: false },1408    });1409    await flushView();1410    expect(sendMessageMock.mock.calls).toContainEqual([{1411      type: "FIRE_CANARY_PING",1412      payload: {1413        webhookUrl: "https://example.com/hook",1414        entryTitle: "GitHub",1415        trigger: "password_copied",1416      },1417    }]);1418  });14191420  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1421    const container = mount(1422      <EntryView1423        entry={sampleEntry({1424          type: "passkey",1425          username: undefined,1426          password: undefined,1427          totp: undefined,1428          canary: undefined,1429          passkey: {1430            rpId: "vercel.com",1431            rpName: "Vercel",1432            credentialId: "cred-1",1433            publicKeyCose: "cose",1434            userHandle: "handle",1435            userName: "june",1436            userDisplayName: "June",1437            algorithm: -7,1438            signCount: 0,1439            aaguid: "",1440            backupEligible: false,1441            backupState: false,1442            createdAt: 1,1443          },1444        })}1445        onDelete={vi.fn()}1446        onFieldSaved={vi.fn(async () => undefined)}1447      />1448    );14491450    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1451      (button) => button.textContent?.includes("Advanced")1452    ) as HTMLButtonElement | undefined;1453    expect(advanced).toBeDefined();1454    expect(advanced?.textContent).toContain("+ Advanced");14551456    advanced!.click();1457    await flushView();14581459    expect(container.textContent).toContain("passkey · vercel.com");1460  });14611462  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1463    sendMessageMock.mockResolvedValue({ ok: true });1464    const onFieldSaved = vi.fn(async () => undefined);1465    const container = mount(1466      <EntryView1467        entry={sampleEntry()}1468        onDelete={vi.fn()}1469        onFieldSaved={onFieldSaved}1470      />1471    );14721473    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1474    passwordValue.click();1475    await flushView();14761477    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1478    inputText(passwordInput, "");1479    await flushView();14801481    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1482    saveButton.click();1483    await flushView();14841485    expect(sendMessageMock).not.toHaveBeenCalledWith(1486      expect.objectContaining({ type: "UPDATE_ENTRY" })1487    );1488    expect(onFieldSaved).not.toHaveBeenCalled();14891490    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1491    totpMain.click();14921493    await vi.waitFor(() => {1494      expect(sendMessageMock).toHaveBeenLastCalledWith({1495        type: "COPY",1496        payload: { text: "123456", label: "totp", alreadyWritten: false },1497      });1498    }, { timeout: 500 });1499  });15001501  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1502    sendMessageMock.mockResolvedValue({ ok: true });1503    const container = mount(1504      <EntryView1505        entry={sampleEntry()}1506        onDelete={vi.fn()}1507        onFieldSaved={vi.fn(async () => undefined)}1508      />1509    );15101511    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1512    usernameValue.click();1513    await flushView();15141515    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1516    usernameInput.blur();1517    await flushView();15181519    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1520    expect(sendMessageMock).not.toHaveBeenCalledWith(1521      expect.objectContaining({ type: "UPDATE_ENTRY" })1522    );1523  });15241525  it("Entry keeps the full used-email list available while editing an email", async () => {1526    const container = mount(1527      <EntryView1528        entry={sampleEntry({ email: "[email protected]" })}1529        entries={[1530          sampleEntry({ email: "[email protected]" }),1531          sampleEntry({ id: "entry-2", email: "[email protected]" }),1532          sampleEntry({ id: "entry-3", username: "duckuccino" }),1533        ]}1534        onDelete={vi.fn()}1535        onFieldSaved={vi.fn(async () => undefined)}1536      />1537    );15381539    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1540      (card) => card.querySelector(".peach-label")?.textContent === "email"1541    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1542    emailValue.click();1543    await flushView();15441545    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1546      .map((element) => element.textContent);1547    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1548  });15491550  it("Entry lets TOTP secrets be edited inline", async () => {1551    sendMessageMock.mockResolvedValue({ ok: true });1552    const onFieldSaved = vi.fn(async () => undefined);1553    const container = mount(1554      <EntryView1555        entry={sampleEntry()}1556        onDelete={vi.fn()}1557        onFieldSaved={onFieldSaved}1558      />1559    );15601561    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1562    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1563    await flushView();15641565    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1566    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1567    await flushView();15681569    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1570    saveButton.click();1571    await flushView();15721573    expect(sendMessageMock).toHaveBeenCalledWith({1574      type: "UPDATE_ENTRY",1575      payload: {1576        id: "entry-1",1577        patch: { totp: "NB2W45DFOIZA====" },1578      },1579    });1580    expect(onFieldSaved).toHaveBeenCalled();1581  });15821583  it("Vault seeds and focuses search when a search seed is provided", async () => {1584    const container = mount(1585      <VaultView1586        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1587        sortOrder="url-first"1588        searchSeed="g"1589        searchSeedNonce={1}1590        onSelectEntry={vi.fn()}1591        onSortOrderChange={vi.fn()}1592        onAdd={vi.fn()}1593        onClose={vi.fn()}1594      />1595    );15961597    await flushView();15981599    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1600    expect(search.value).toBe("g");1601    expect(document.activeElement).toBe(search);1602  });16031604  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1605    parseImportSourceMock.mockResolvedValue(1606      sampleImportResult({1607        entries: [1608          {1609            type: "login",1610            title: "Imported site",1611            username: "imported-user",1612            password: "imported-pass",1613            url: "https://imported.test",1614          },1615        ],1616      })1617    );1618    sendMessageMock.mockImplementation(async (msg: unknown) => {1619      const type = (msg as { type?: string }).type;1620      if (type === "GET_ENTRIES") {1621        return {1622          ok: true,1623          data: [1624            sampleEntry({1625              id: "existing-1",1626              title: "Imported site",1627              username: "imported-user",1628              url: "https://imported.test",1629            }),1630          ],1631        };1632      }1633      if (type === "BATCH_ADD_ENTRIES") {1634        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1635      }1636      return { ok: true };1637    });1638    const onImport = vi.fn(async () => undefined);1639    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1640    await flushView();1641    sendMessageMock.mockClear();16421643    await selectImportFile(container);1644    findButton(container, "Import 1").click();1645    await flushView();16461647    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1648    expect(sendMessageMock).toHaveBeenCalledWith({1649      type: "BATCH_ADD_ENTRIES",1650      payload: {1651        entries: [1652          expect.objectContaining({1653            title: "Imported site",1654            username: "imported-user",1655            password: "imported-pass",1656            url: "https://imported.test",1657          }),1658        ],1659      },1660    });1661    expect(onImport).toHaveBeenCalled();1662    expect(container.textContent).toContain("1 entry appears to be duplicates");1663  });16641665  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1666    parseImportSourceMock.mockResolvedValue(sampleImportResult());1667    sendMessageMock.mockImplementation(async (msg: unknown) => {1668      const type = (msg as { type?: string }).type;1669      if (type === "GET_ENTRIES") {1670        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1671      }1672      if (type === "CLEAR_VAULT_ENTRIES") {1673        return { ok: true };1674      }1675      if (type === "BATCH_ADD_ENTRIES") {1676        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1677      }1678      return { ok: true };1679    });1680    const onImport = vi.fn(async () => undefined);1681    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1682    await flushView();1683    sendMessageMock.mockClear();16841685    await selectImportFile(container);1686    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1687    await flushView();16881689    findButton(container, "Replace vault with 1").click();1690    await flushView();16911692    expect(sendMessageMock).not.toHaveBeenCalledWith(1693      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1694    );1695    expect(container.textContent).toContain("Replace forever? This cannot be undone.");16961697    findButton(container, "Yes, replace forever").click();1698    await flushView();16991700    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1701    expect(sendMessageMock).toHaveBeenCalledWith({1702      type: "BATCH_ADD_ENTRIES",1703      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1704    });1705    expect(onImport).toHaveBeenCalled();1706  });17071708  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1709    parseImportSourceMock.mockResolvedValue(sampleImportResult());1710    sendMessageMock.mockImplementation(async (msg: unknown) => {1711      const type = (msg as { type?: string }).type;1712      if (type === "CREATE_VAULT") return { ok: true };1713      if (type === "BATCH_ADD_ENTRIES") {1714        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1715      }1716      return { ok: true };1717    });1718    const onImport = vi.fn(async () => undefined);1719    const container = mount(1720      <ImportView1721        mode="onboarding"1722        replacesExistingVault={true}1723        onBack={vi.fn()}1724        onImport={onImport}1725      />1726    );17271728    await selectImportFile(container);17291730    const createButton = findButton(container, "Create vault + import 1");1731    expect(createButton.disabled).toBe(true);1732    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1733    expect(container.textContent).toContain("Use at least 8 characters");17341735    const masterInput = container.querySelector(1736      'input[placeholder="new master password"]'1737    ) as HTMLInputElement;1738    const confirmInput = container.querySelector(1739      'input[placeholder="confirm master password"]'1740    ) as HTMLInputElement;1741    const revealButton = container.querySelector(1742      'button[aria-label="Show passwords"]'1743    ) as HTMLButtonElement;1744    expect(revealButton).toBeTruthy();1745    revealButton.click();1746    await flushView();1747    expect(masterInput.type).toBe("text");1748    expect(confirmInput.type).toBe("text");17491750    inputText(masterInput, "fresh-master-password");1751    inputText(confirmInput, "fresh-master-password");1752    await flushView();17531754    expect(createButton.disabled).toBe(false);1755    createButton.click();1756    await flushView();17571758    expect(sendMessageMock).toHaveBeenCalledWith({1759      type: "CREATE_VAULT",1760      payload: { password: "fresh-master-password" },1761    });1762    expect(sendMessageMock).toHaveBeenCalledWith({1763      type: "BATCH_ADD_ENTRIES",1764      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1765    });1766    expect(onImport).toHaveBeenCalled();1767  });17681769});