contracts.test.tsx

54.5 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 { VaultView } from "./Vault";19import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2021const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();22const triggerRingMock = vi.fn();23const triggerPipPulseMock = vi.fn();24const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();25const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2627vi.mock("../App", () => ({28  sendMessage: (msg: unknown) => sendMessageMock(msg),29  triggerRing: () => triggerRingMock(),30  triggerPipPulse: () => triggerPipPulseMock(),31}));3233vi.mock("../../shared/bridge", () => ({34  sendMessage: (msg: unknown) => sendMessageMock(msg),35}));3637vi.mock("../../shared/import", () => ({38  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),39}));4041vi.mock("../../shared/favicon", async (importOriginal) => {42  const actual = await importOriginal<typeof import("../../shared/favicon")>();43  return { ...actual, faviconUrl: () => null };44});4546vi.mock("../../crypto/totp", () => ({47  generateTOTP: () => ({ code: "123 456", remaining: 24 }),48  parseOtpauthURI: (value: string) =>49    value.startsWith("otpauth://")50      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }51      : null,52}));5354vi.mock("../components/GeneratorDrawer", () => ({55  GeneratorDrawer: () => null,56}));5758vi.mock("../components/RuneBackground", () => ({59  RuneBackground: () => null,60}));6162vi.mock("@zxcvbn-ts/core", () => ({63  zxcvbn: (value: string) => zxcvbnMock(value),64  zxcvbnOptions: { setOptions: vi.fn() },65}));66vi.mock("@zxcvbn-ts/language-common", () => ({67  adjacencyGraphs: {},68  dictionary: {},69}));70vi.mock("@zxcvbn-ts/language-en", () => ({71  translations: {},72  dictionary: {},73}));7475describe("popup contract tests", () => {76  const disposers: Array<() => void> = [];7778  beforeEach(() => {79    document.body.innerHTML = "";80    sendMessageMock.mockReset();81    triggerRingMock.mockReset();82    triggerPipPulseMock.mockReset();83    parseImportSourceMock.mockReset();84    zxcvbnMock.mockReset();85    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));86    parseImportSourceMock.mockResolvedValue({87      format: "unknown",88      label: "Unknown format",89      entries: [],90      totalItems: 0,91      skippedItems: 0,92      requiresPassword: false,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      requiresPassword: false,163      ...overrides,164    };165  }166167  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {168    return {169      entriesCount: 1,170      breachMonitoring: true,171      lockOnClose: true,172      autoLockMinutes: 5,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      onThemeChange: vi.fn(),183      onAccentChange: vi.fn(),184      onTextSizeChange: vi.fn(),185      onReduceMotionChange: vi.fn(),186      shortcuts: DEFAULT_BINDINGS,187      onShortcutsChange: vi.fn(),188      onBack: vi.fn(),189      onImport: vi.fn(),190      onExport: vi.fn(),191      onBreachCheckDone: vi.fn(),192      ...overrides,193    };194  }195196  async function selectImportFile(197    container: HTMLElement,198    contents = '{"export":"test"}'199  ) {200    // The Import view now opens onto a menu — click into the file subview201    // before locating the file input. Idempotent: a second click won't re-fire202    // because the menu unmounts once the subview renders.203    const fileCard = findButton(container, "Choose file →");204    if (fileCard) {205      fileCard.click();206      await flushView();207    }208    const input = container.querySelector('input[type="file"]') as HTMLInputElement;209    const file = new File([contents], "import.json", { type: "application/json" });210    Object.defineProperty(input, "files", {211      configurable: true,212      value: [file],213    });214    input.dispatchEvent(new Event("change", { bubbles: true }));215    await flushView();216    await flushView();217  }218219  function findButton(container: HTMLElement, text: string): HTMLButtonElement {220    return Array.from(container.querySelectorAll("button")).find(221      (candidate) => candidate.textContent?.trim() === text222    ) as HTMLButtonElement;223  }224225  it("Lock sends UNLOCK with the entered passphrase", async () => {226    sendMessageMock.mockResolvedValue({ ok: true });227    const onUnlock = vi.fn(async () => undefined);228    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);229230    const password = container.querySelector("#unlock-password") as HTMLInputElement;231    inputText(password, "correct horse battery staple");232    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();233    await flushView();234235    expect(sendMessageMock).toHaveBeenCalledWith({236      type: "UNLOCK",237      payload: { password: "correct horse battery staple" },238    });239    expect(onUnlock).toHaveBeenCalled();240  });241242  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {243    sendMessageMock.mockImplementation(async (msg: unknown) => {244      return (msg as { type: string }).type === "GET_SETTINGS"245        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }246        : { ok: true };247    });248    const onUnlock = vi.fn(async () => undefined);249    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);250    await flushView();251252    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");253    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();254    await flushView();255256    expect(sendMessageMock).toHaveBeenCalledWith({257      type: "UNLOCK_WITH_PIN",258      payload: { pin: "1234" },259    });260    expect(onUnlock).toHaveBeenCalled();261    expect(container.textContent).toContain("use master password");262  });263264  it("places the PIN method switch below the unlock button", async () => {265    sendMessageMock.mockImplementation(async (msg: unknown) => {266      return (msg as { type: string }).type === "GET_SETTINGS"267        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }268        : { ok: true };269    });270    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);271    await flushView();272273    const buttons = Array.from(container.querySelectorAll("button"));274    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");275    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");276    expect(pinIndex).toBeGreaterThan(unlockIndex);277  });278279  it("keeps standalone note creation in Notes mode", async () => {280    const chromeApi = globalThis.chrome as unknown as {281      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };282    };283    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };284285    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);286    await flushView();287288    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))289      .toEqual(["login", "card"]);290    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();291  });292293  it("gives Notes mode the full note-formatting toolbar", async () => {294    const container = mount(295      <NotesEditor296        note={null}297        onBack={vi.fn()}298        onSaved={vi.fn()}299        onDelete={vi.fn()}300      />301    );302    await flushView();303304    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();305    expect(container.querySelector('[title="Divider"]')).toBeTruthy();306    expect(container.querySelector('[title="Quote"]')).toBeTruthy();307    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);308    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);309    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");310  });311312  it("saves formatting applied by the Notes toolbar before leaving", async () => {313    const savedNote = {314      id: "note-1",315      type: "note" as const,316      title: "Untitled",317      notes: "<b>formatted</b>",318      createdAt: 1,319      modifiedAt: 2,320    };321    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });322    const originalExecCommand = document.execCommand;323    const execCommandMock = vi.fn(() => {324      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");325      if (body) body.innerHTML = "<b>formatted</b>";326      return true;327    });328    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });329330    try {331      const container = mount(332        <NotesEditor333          note={null}334          onBack={vi.fn()}335          onSaved={vi.fn()}336          onDelete={vi.fn()}337        />338      );339      await flushView();340      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(341        new MouseEvent("mousedown", { bubbles: true, cancelable: true })342      );343      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);344      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();345      await flushView();346347      expect(sendMessageMock).toHaveBeenCalledWith({348        type: "SAVE_NOTE",349        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },350      });351    } finally {352      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });353    }354  });355356  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {357    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);358    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;359    const password = container.querySelector("#master-password") as HTMLInputElement;360    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;361362    inputText(password, "short");363    inputText(confirm, "short");364    await flushView();365    expect(createButton.disabled).toBe(true);366367    zxcvbnMock.mockImplementation(() => ({ score: 1 }));368    inputText(password, "weakweakweak");369    inputText(confirm, "weakweakweak");370    await flushView();371    expect(createButton.disabled).toBe(true);372  });373374  it("Lock create-vault header pins the wordmark left with back on the right", async () => {375    const container = mount(376      <LockView377        hasVault={false}378        ready={true}379        onUnlock={vi.fn()}380        onBackToOnboarding={() => {}}381      />382    );383    const header = container.querySelector(".peach-newvault-header") as HTMLElement;384    const wordmark = header.querySelector(".peach-wordmark-stack");385    const back = header.querySelector(".peach-newvault-back");386    expect(wordmark).toBeTruthy();387    expect(back).toBeTruthy();388    // Wordmark first so it never shifts between onboarding screens; back389    // takes the opposite corner instead of displacing it.390    expect(header.firstElementChild).toBe(wordmark);391    expect(header.lastElementChild).toBe(back);392    await flushView();393  });394395  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {396    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock397    sendMessageMock.mockImplementation(398      async (msg: unknown) => {399        const m = msg as { type: string; payload?: { password?: string } };400        if (m.type === "CREATE_VAULT") return { ok: true };401        if (m.type === "GET_RECOVERY_WORDS") {402          // Return 12 fake words — must pass findInvalidWords403          return {404            ok: true,405            data: [406              "abandon", "ability", "able", "about", "above", "absent",407              "absorb", "abstract", "absurd", "abuse", "access", "accident",408            ],409          };410        }411        return { ok: true };412      }413    );414    zxcvbnMock.mockImplementation(() => ({ score: 3 }));415    const onUnlock = vi.fn(async () => undefined);416    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);417418    const password = container.querySelector("#master-password") as HTMLInputElement;419    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;420    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;421422    inputText(password, "correct horse battery staple");423    inputText(confirm, "correct horse battery staple");424    await flushView();425    expect(createButton.disabled).toBe(false);426427    pressEnter(confirm);428    await flushView();429430    expect(sendMessageMock).toHaveBeenCalledWith({431      type: "CREATE_VAULT",432      payload: { password: "correct horse battery staple" },433    });434    // After CREATE_VAULT success, the recovery word display is shown.435    // onUnlock is NOT called yet — user must first confirm their recovery words.436    expect(onUnlock).not.toHaveBeenCalled();437  });438439  it("Recovery words flow submits the new master password and completes", async () => {440    sendMessageMock.mockImplementation(async (msg: unknown) => {441      const type = (msg as { type?: string }).type;442      if (type === "VALIDATE_RECOVERY_WORDS") {443        return { ok: true, data: { valid: true, invalid: [] } };444      }445      if (type === "RECOVER_WITH_WORDS") return { ok: true };446      return { ok: true };447    });448    const canvasContext = {449      clearRect: vi.fn(),450      fillText: vi.fn(),451      beginPath: vi.fn(),452      closePath: vi.fn(),453      arc: vi.fn(),454      fill: vi.fn(),455      stroke: vi.fn(),456      save: vi.fn(),457      restore: vi.fn(),458      translate: vi.fn(),459      rotate: vi.fn(),460      scale: vi.fn(),461      drawImage: vi.fn(),462      measureText: vi.fn(() => ({ width: 0 })),463      setTransform: vi.fn(),464      fillRect: vi.fn(),465      strokeRect: vi.fn(),466    } as unknown as CanvasRenderingContext2D;467    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);468469    const onRecoveryComplete = vi.fn(async () => undefined);470    const container = mount(471      <RecoveryWordEntryView472        onCancel={vi.fn()}473        onRecoveryComplete={onRecoveryComplete}474      />475    );476477    const recoveryWords = WORD_LIST.slice(0, 12);478    for (let i = 0; i < recoveryWords.length; i++) {479      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;480      inputText(input, recoveryWords[i]!);481    }482    await flushView();483484    findButton(container, "verify words").click();485    await flushView();486487    expect(container.textContent).toContain("new master password");488489    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;490    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;491    inputText(newPassword, "fresh-recovery-password");492    inputText(confirmPassword, "fresh-recovery-password");493    await flushView();494495    findButton(container, "recover vault").click();496    await flushView();497498    expect(sendMessageMock).toHaveBeenCalledWith({499      type: "RECOVER_WITH_WORDS",500      payload: {501        words: recoveryWords,502        newPassword: "fresh-recovery-password",503      },504    });505    expect(onRecoveryComplete).toHaveBeenCalled();506  });507508  it("Export menu surfaces both backup formats", async () => {509    const container = mount(<ExportView onBack={vi.fn()} />);510    await flushView();511512    expect(container.textContent).toContain("PeachScript");513    expect(container.textContent).toContain("CSV");514  });515516  it("Lock can open directly on backup restore from onboarding", async () => {517    const container = mount(() => (518      <LockView519        hasVault={false}520        ready={true}521        onUnlock={vi.fn()}522        initialRestore={true}523      />524    ));525    await flushView();526527    expect(container.textContent).toContain("PeachScript");528    expect(container.textContent).toContain("Codex");529    expect(container.textContent).toContain("CSV");530    expect(container.querySelector("#master-password")).toBeNull();531  });532533  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {534    const device = {535      deviceId: "phone-1",536      deviceName: "June's Phone",537      deviceType: "android" as const,538      longTermPublicKeyHex: "a".repeat(64),539      signingPublicKeyHex: "b".repeat(64),540      pairedAt: 1,541      trustSource: "direct" as const,542    };543    sendMessageMock.mockImplementation(async (msg: unknown) => {544      const m = msg as { type: string };545      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };546      return { ok: true, data: null };547    });548549    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);550    for (let i = 0; i < 30; i++) await Promise.resolve();551552    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>553      /sync/i.test(b.textContent ?? ""),554    );555    openSync?.click();556    for (let i = 0; i < 30; i++) await Promise.resolve();557558    const remove = Array.from(container.querySelectorAll("button")).find(559      (b) => (b.textContent ?? "").trim() === "remove",560    );561    expect(remove).toBeTruthy();562563    // Guard against a vacuous pass: the disclosure must not already be on564    // screen before the user asks to remove anything.565    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();566567    remove!.click();568    for (let i = 0; i < 10; i++) await Promise.resolve();569570    // Revocation must never be presented as securing the vault against the571    // removed device — recovery words survive it and restore everything.572    const note = container.querySelector(".peach-unpair-confirm-note");573    expect(note).toBeTruthy();574    const text = note!.textContent ?? "";575    expect(text).toContain("master password");576    expect(text).toContain("recovery words");577    expect(text).toContain("future changes");578  });579580  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {581    const device = {582      deviceId: "phone-1",583      deviceName: "June's Phone",584      deviceType: "android" as const,585      longTermPublicKeyHex: "a".repeat(64),586      signingPublicKeyHex: "b".repeat(64),587      pairedAt: 1,588      trustSource: "direct" as const,589    };590    let paired: typeof device[] = [device];591    sendMessageMock.mockImplementation(async (msg: any) => {592      if (msg?.type === "LIST_PAIRED_DEVICES") {593        return { ok: true, data: { devices: [...paired] } };594      }595      if (msg?.type === "UNPAIR_DEVICE") {596        // Mirror the real handler: UNPAIR_DEVICE drops the row from the597        // registry. The next LIST_PAIRED_DEVICES must not see it.598        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);599        return { ok: true };600      }601      if (msg?.type === "RENAME_PAIRED_DEVICE") {602        paired = paired.map((d) =>603          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,604        );605        return { ok: true };606      }607      return { ok: true, data: null };608    });609610    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);611    for (let i = 0; i < 30; i++) await Promise.resolve();612    Array.from(container.querySelectorAll("button"))613      .find((b) => /sync/i.test(b.textContent ?? ""))614      ?.click();615    for (let i = 0; i < 30; i++) await Promise.resolve();616617    // The list itself: extracting the relay selector must not take the hub with it.618    expect(container.textContent).toContain("PAIRED DEVICES");619    expect(container.textContent).toContain("June's Phone");620    expect(container.textContent).toContain("Android");621    Array.from(container.querySelectorAll("button"))622      .find((b) => (b.textContent ?? "").trim() === "rename")!623      .click();624    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;625    renameInput.value = "Travel phone";626    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));627    Array.from(container.querySelectorAll("button"))628      .find((b) => (b.textContent ?? "").trim() === "save")!629      .click();630    for (let i = 0; i < 30; i++) await Promise.resolve();631    expect(sendMessageMock).toHaveBeenCalledWith({632      type: "RENAME_PAIRED_DEVICE",633      payload: { deviceId: "phone-1", name: "Travel phone" },634    });635    expect(container.textContent).toContain("Travel phone");636    const syncNowButton = container.querySelector<HTMLButtonElement>(637      'button[aria-label="Sync this vault now"]',638    )!;639    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));640    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;641    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;642    expect(643      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,644    ).toBeTruthy();645    expect(646      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,647    ).toBeTruthy();648649    Array.from(container.querySelectorAll("button"))650      .find((b) => (b.textContent ?? "").trim() === "remove")!651      .click();652    for (let i = 0; i < 10; i++) await Promise.resolve();653    const confirm = Array.from(654      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),655    ).find((b) => (b.textContent ?? "").trim() === "remove");656    confirm!.click();657    for (let i = 0; i < 30; i++) await Promise.resolve();658659    expect(sendMessageMock).toHaveBeenCalledWith({660      type: "UNPAIR_DEVICE",661      payload: { deviceId: "phone-1" },662    });663    // The list must be refreshed and the device must be gone. The regression664    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but665    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.666    expect(container.textContent).not.toContain("June's Phone");667    expect(container.textContent).not.toContain("PAIRED DEVICES");668  });669670  it("Settings keeps connection and recovery details collapsed until requested", async () => {671    sendMessageMock.mockImplementation(async (msg: any) => {672      if (msg?.type === "LIST_PAIRED_DEVICES") {673        return { ok: true, data: { devices: [] } };674      }675      return { ok: true, data: null };676    });677678    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);679    for (let i = 0; i < 30; i++) await Promise.resolve();680    Array.from(container.querySelectorAll("button"))681      .find((button) => /sync/i.test(button.textContent ?? ""))682      ?.click();683    for (let i = 0; i < 20; i++) await Promise.resolve();684685    expect(container.textContent).toContain("SYNC STATUS");686    expect(container.textContent).toContain("ADD A DEVICE");687    expect(container.textContent).not.toContain("Public relay");688    expect(container.textContent).not.toContain("Rotate sync identity");689690    const connection = Array.from(container.querySelectorAll("button"))691      .find((button) => button.textContent?.includes("CONNECTION"));692    connection?.click();693    await Promise.resolve();694    expect(container.textContent).toContain("Public relay");695696    const recovery = Array.from(container.querySelectorAll("button"))697      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));698    recovery?.click();699    await Promise.resolve();700    expect(container.textContent).toContain("Rotate sync identity");701  });702703  it("Settings requires current, new, and confirm passwords before enabling save", async () => {704    sendMessageMock.mockResolvedValue({ ok: true, data: null });705    const container = mount(706      <SettingsView707        entriesCount={1}708        breachMonitoring={true}709        lockOnClose={true}710        autoLockMinutes={5}711        trashRetentionDays={30}712        theme="dark"713        accentColor="peach"714        textSize="medium"715        reduceMotion={false}716        onBreachMonitoringChange={vi.fn()}717        onLockOnCloseChange={vi.fn()}718        onAutoLockMinutesChange={vi.fn()}719        onTrashRetentionChange={vi.fn()}720        onThemeChange={vi.fn()}721        onAccentChange={vi.fn()}722        onTextSizeChange={vi.fn()}723        onReduceMotionChange={vi.fn()}724        shortcuts={DEFAULT_BINDINGS}725        onShortcutsChange={vi.fn()}726        onBack={vi.fn()}727        onImport={vi.fn()}728        onExport={vi.fn()}729        onBreachCheckDone={vi.fn()}730      />731    );732    await flushView();733734    expect(container.querySelectorAll(".stk-card").length).toBe(7);735    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]736    // "open settings to card I (Peach Pro) instead of card II (Protection)".737    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");738739    const changeButton = Array.from(container.querySelectorAll("button")).find(740      (candidate) => candidate.textContent?.trim() === "change"741    ) as HTMLButtonElement;742    changeButton.click();743    await flushView();744745    const saveButton = Array.from(container.querySelectorAll("button")).find(746      (candidate) => candidate.textContent?.trim() === "save new password"747    ) as HTMLButtonElement;748    expect(saveButton.disabled).toBe(true);749750    const inputs = container.querySelectorAll('input[type="password"]');751    inputText(inputs[0] as HTMLInputElement, "current-pass");752    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");753    await flushView();754    expect(saveButton.disabled).toBe(true);755756    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");757    await flushView();758    expect(saveButton.disabled).toBe(false);759  });760761  it("Settings feedback highlights the selected category and hides the form after submit", async () => {762    sendMessageMock.mockResolvedValue({ ok: true, data: null });763    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({764      ok: true,765      json: async () => ({ success: true }),766    }));767    vi.stubGlobal("fetch", fetchMock);768769    const container = mount(770      <SettingsView771        entriesCount={1}772        breachMonitoring={true}773        lockOnClose={true}774        autoLockMinutes={5}775        trashRetentionDays={30}776        theme="dark"777        accentColor="peach"778        textSize="medium"779        reduceMotion={false}780        initialSection="feedback"781        onBreachMonitoringChange={vi.fn()}782        onLockOnCloseChange={vi.fn()}783        onAutoLockMinutesChange={vi.fn()}784        onTrashRetentionChange={vi.fn()}785        onThemeChange={vi.fn()}786        onAccentChange={vi.fn()}787        onTextSizeChange={vi.fn()}788        onReduceMotionChange={vi.fn()}789        shortcuts={DEFAULT_BINDINGS}790        onShortcutsChange={vi.fn()}791        onBack={vi.fn()}792        onImport={vi.fn()}793        onExport={vi.fn()}794        onBreachCheckDone={vi.fn()}795      />796    );797    await flushView();798799    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;800    expect(feedbackCard.classList.contains("is-active")).toBe(true);801802    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];803    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;804    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;805    expect(bugButton.getAttribute("aria-pressed")).toBe("true");806    expect(featureButton.getAttribute("aria-pressed")).toBe("false");807808    featureButton.click();809    await flushView();810811    expect(bugButton.getAttribute("aria-pressed")).toBe("false");812    expect(featureButton.getAttribute("aria-pressed")).toBe("true");813    expect(featureButton.classList.contains("is-active")).toBe(true);814815    inputText(816      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,817      '<script>alert("xss")</script> Please make this easier to see & safer.'818    );819    const sendButton = Array.from(container.querySelectorAll("button")).find(820      (candidate) => candidate.textContent?.trim() === "Send"821    ) as HTMLButtonElement;822    sendButton.click();823824    await vi.waitFor(() => {825      expect(container.textContent).toContain("Thank you");826    });827828    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));829    expect(requestBody.subject).toContain("Feature request");830    expect(requestBody.message).toBe(831      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."832    );833    expect(requestBody.message).not.toContain("<script>");834    expect(container.textContent).toContain("Someone will process it soon");835    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();836    expect(container.querySelector(".peach-feedback-submit")).toBeNull();837  });838839  it("Settings keeps license activation on the full-screen celebration path", async () => {840    const onLicenseChange = vi.fn(async () => undefined);841    sendMessageMock.mockImplementation(async (msg: unknown) => {842      const type = (msg as { type?: string }).type;843      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };844      if (type === "ACTIVATE_LICENSE") {845        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };846      }847      if (type === "GET_ENTRIES") return { ok: true, data: [] };848      return { ok: true, data: null };849    });850851    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);852    await flushView();853854    inputText(855      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,856      "PEACH-TEST-KEY",857    );858    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;859    apply.click();860861    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));862    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);863    expect(container.textContent).not.toContain("thank you ✦");864  });865866  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {867    sendMessageMock.mockImplementation(async (msg: unknown) => {868      const type = (msg as { type?: string }).type;869      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };870      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };871      if (type === "GET_ENTRIES") return { ok: true, data: [] };872      return { ok: true, data: null };873    });874875    const onLicenseChangeFromStar = vi.fn();876    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);877    await flushView();878    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();879    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);880    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();881882    const onLicenseChangeFromPeach = vi.fn();883    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);884    await flushView();885    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();886    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);887    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();888  });889890  it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {891    sendMessageMock.mockResolvedValue({ ok: true, data: null });892    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({893      ok: true,894      json: async () => ({ success: true }),895    }));896    vi.stubGlobal("fetch", fetchMock);897    // Firefox-style permissions API reporting the optional consent as absent.898    // The old code gated send behind this and silently returned to idle, which899    // read as a dead Send button. The gate is gone: Send must always send.900    vi.stubGlobal("browser", {901      permissions: {902        getAll: vi.fn(async () => ({ data_collection: [] })),903        request: vi.fn(async () => false),904      },905    });906907    const container = mount(908      <SettingsView909        entriesCount={1}910        breachMonitoring={true}911        lockOnClose={true}912        autoLockMinutes={5}913        trashRetentionDays={30}914        theme="dark"915        accentColor="peach"916        textSize="medium"917        reduceMotion={false}918        initialSection="feedback"919        onBreachMonitoringChange={vi.fn()}920        onLockOnCloseChange={vi.fn()}921        onAutoLockMinutesChange={vi.fn()}922        onTrashRetentionChange={vi.fn()}923        onThemeChange={vi.fn()}924        onAccentChange={vi.fn()}925        onTextSizeChange={vi.fn()}926        onReduceMotionChange={vi.fn()}927        shortcuts={DEFAULT_BINDINGS}928        onShortcutsChange={vi.fn()}929        onBack={vi.fn()}930        onImport={vi.fn()}931        onExport={vi.fn()}932        onBreachCheckDone={vi.fn()}933      />934    );935    await flushView();936937    inputText(938      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,939      "Please improve the feedback flow."940    );941    const sendButton = Array.from(container.querySelectorAll("button")).find(942      (candidate) => candidate.textContent?.trim() === "Send"943    ) as HTMLButtonElement;944    sendButton.click();945946    await vi.waitFor(() => {947      expect(fetchMock).toHaveBeenCalledTimes(1);948    });949    await vi.waitFor(() => {950      expect(container.textContent).toContain("Thank you");951    });952    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();953  });954955  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {956    sendMessageMock.mockImplementation(async (msg: unknown) => {957      const m = msg as { type: string };958      if (m.type === "GET_RECOVERY_WORDS") {959        return {960          ok: true,961          data: [962            "abandon", "ability", "able", "about", "above", "absent",963            "absorb", "abstract", "absurd", "abuse", "access", "accident",964          ],965        };966      }967      return { ok: true, data: null };968    });969    const onAutoLockMinutesChange = vi.fn();970    const onThemeChange = vi.fn();971    const container = mount(972      <SettingsView973        entriesCount={1}974        breachMonitoring={true}975        lockOnClose={true}976        autoLockMinutes={5}977        trashRetentionDays={30}978        theme="dark"979        accentColor="peach"980        textSize="medium"981        reduceMotion={false}982        onBreachMonitoringChange={vi.fn()}983        onLockOnCloseChange={vi.fn()}984        onAutoLockMinutesChange={onAutoLockMinutesChange}985        onTrashRetentionChange={vi.fn()}986        onThemeChange={onThemeChange}987        onAccentChange={vi.fn()}988        onTextSizeChange={vi.fn()}989        onReduceMotionChange={vi.fn()}990        shortcuts={DEFAULT_BINDINGS}991        onShortcutsChange={vi.fn()}992        onBack={vi.fn()}993        onImport={vi.fn()}994        onExport={vi.fn()}995        onBreachCheckDone={vi.fn()}996      />997    );998    await flushView();999    sendMessageMock.mockClear();10001001    // ── Auto-lock + theme changes (before password change) ─────────────1002    const selects = container.querySelectorAll("select");1003    changeSelect(selects[0] as HTMLSelectElement, "15");1004    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);10051006    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1007    appearanceCard.click();1008    await flushView();1009    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1010    const themeButton = Array.from(container.querySelectorAll("button")).find(1011      (candidate) => candidate.textContent?.trim() === "Switch to light"1012    ) as HTMLButtonElement;1013    themeButton.click();1014    expect(onThemeChange).toHaveBeenCalledWith("light");10151016    // ── Password change → recovery words display ────────────────────────1017    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1018    protectionCard.click();1019    await flushView();10201021    const changeButton = Array.from(container.querySelectorAll("button")).find(1022      (candidate) => candidate.textContent?.trim() === "change"1023    ) as HTMLButtonElement;1024    changeButton.click();1025    await flushView();10261027    const inputs = container.querySelectorAll('input[type="password"]');1028    inputText(inputs[0] as HTMLInputElement, "current-pass");1029    inputText(inputs[1] as HTMLInputElement, "new-password-123");1030    inputText(inputs[2] as HTMLInputElement, "new-password-123");1031    await flushView();10321033    const saveButton = Array.from(container.querySelectorAll("button")).find(1034      (candidate) => candidate.textContent?.trim() === "save new password"1035    ) as HTMLButtonElement;1036    saveButton.click();1037    await flushView();10381039    expect(sendMessageMock).toHaveBeenCalledWith({1040      type: "CHANGE_PASSWORD",1041      payload: {1042        currentPassword: "current-pass",1043        newPassword: "new-password-123",1044      },1045    });10461047    // After a successful password change the recovery word display replaces settings.1048    expect(container.textContent).toContain("save your recovery words");1049    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1050  });10511052  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1053    sendMessageMock.mockResolvedValue({ ok: true });1054    const container = mount(1055      <EntryView1056        entry={sampleEntry()}1057        onDelete={vi.fn()}1058        onFieldSaved={vi.fn(async () => undefined)}1059      />1060    );10611062    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1063    passwordButton.click();1064    await flushView();10651066    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1067      type: "COPY",1068      payload: { text: "secret123", label: "password", alreadyWritten: false },1069    });1070    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1071      type: "FIRE_CANARY_PING",1072      payload: {1073        webhookUrl: "https://example.com/hook",1074        entryTitle: "GitHub",1075        trigger: "password_copied",1076      },1077    });10781079    sendMessageMock.mockClear();1080    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1081    usernameButton.click();1082    await flushView();10831084    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1085      type: "COPY",1086      payload: { text: "june", label: "username", alreadyWritten: false },1087    });1088    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1089      type: "FIRE_CANARY_PING",1090      payload: {1091        webhookUrl: "https://example.com/hook",1092        entryTitle: "GitHub",1093        trigger: "username_copied",1094      },1095    });1096  });10971098  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1099    const container = mount(1100      <EntryView1101        entry={sampleEntry({1102          type: "passkey",1103          username: undefined,1104          password: undefined,1105          totp: undefined,1106          canary: undefined,1107          passkey: {1108            rpId: "vercel.com",1109            rpName: "Vercel",1110            credentialId: "cred-1",1111            publicKeyCose: "cose",1112            userHandle: "handle",1113            userName: "june",1114            userDisplayName: "June",1115            algorithm: -7,1116            signCount: 0,1117            aaguid: "",1118            backupEligible: false,1119            backupState: false,1120            createdAt: 1,1121          },1122        })}1123        onDelete={vi.fn()}1124        onFieldSaved={vi.fn(async () => undefined)}1125      />1126    );11271128    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1129      (button) => button.textContent?.includes("Advanced")1130    ) as HTMLButtonElement | undefined;1131    expect(advanced).toBeDefined();1132    expect(advanced?.textContent).toContain("+ Advanced");11331134    advanced!.click();1135    await flushView();11361137    expect(container.textContent).toContain("passkey · vercel.com");1138  });11391140  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1141    sendMessageMock.mockResolvedValue({ ok: true });1142    const onFieldSaved = vi.fn(async () => undefined);1143    const container = mount(1144      <EntryView1145        entry={sampleEntry()}1146        onDelete={vi.fn()}1147        onFieldSaved={onFieldSaved}1148      />1149    );11501151    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1152    passwordValue.click();1153    await flushView();11541155    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1156    inputText(passwordInput, "");1157    await flushView();11581159    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1160    saveButton.click();1161    await flushView();11621163    expect(sendMessageMock).not.toHaveBeenCalledWith(1164      expect.objectContaining({ type: "UPDATE_ENTRY" })1165    );1166    expect(onFieldSaved).not.toHaveBeenCalled();11671168    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1169    totpMain.click();11701171    await vi.waitFor(() => {1172      expect(sendMessageMock).toHaveBeenLastCalledWith({1173        type: "COPY",1174        payload: { text: "123456", label: "totp", alreadyWritten: false },1175      });1176    }, { timeout: 500 });1177  });11781179  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1180    sendMessageMock.mockResolvedValue({ ok: true });1181    const container = mount(1182      <EntryView1183        entry={sampleEntry()}1184        onDelete={vi.fn()}1185        onFieldSaved={vi.fn(async () => undefined)}1186      />1187    );11881189    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1190    usernameValue.click();1191    await flushView();11921193    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1194    usernameInput.blur();1195    await flushView();11961197    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1198    expect(sendMessageMock).not.toHaveBeenCalledWith(1199      expect.objectContaining({ type: "UPDATE_ENTRY" })1200    );1201  });12021203  it("Entry keeps the full used-email list available while editing an email", async () => {1204    const container = mount(1205      <EntryView1206        entry={sampleEntry({ email: "[email protected]" })}1207        entries={[1208          sampleEntry({ email: "[email protected]" }),1209          sampleEntry({ id: "entry-2", email: "[email protected]" }),1210          sampleEntry({ id: "entry-3", username: "duckuccino" }),1211        ]}1212        onDelete={vi.fn()}1213        onFieldSaved={vi.fn(async () => undefined)}1214      />1215    );12161217    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1218      (card) => card.querySelector(".peach-label")?.textContent === "email"1219    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1220    emailValue.click();1221    await flushView();12221223    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1224      .map((element) => element.textContent);1225    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1226  });12271228  it("Entry lets TOTP secrets be edited inline", async () => {1229    sendMessageMock.mockResolvedValue({ ok: true });1230    const onFieldSaved = vi.fn(async () => undefined);1231    const container = mount(1232      <EntryView1233        entry={sampleEntry()}1234        onDelete={vi.fn()}1235        onFieldSaved={onFieldSaved}1236      />1237    );12381239    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1240    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1241    await flushView();12421243    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1244    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1245    await flushView();12461247    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1248    saveButton.click();1249    await flushView();12501251    expect(sendMessageMock).toHaveBeenCalledWith({1252      type: "UPDATE_ENTRY",1253      payload: {1254        id: "entry-1",1255        patch: { totp: "NB2W45DFOIZA====" },1256      },1257    });1258    expect(onFieldSaved).toHaveBeenCalled();1259  });12601261  it("Vault seeds and focuses search when a search seed is provided", async () => {1262    const container = mount(1263      <VaultView1264        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1265        sortOrder="url-first"1266        searchSeed="g"1267        searchSeedNonce={1}1268        onSelectEntry={vi.fn()}1269        onSortOrderChange={vi.fn()}1270        onAdd={vi.fn()}1271        onClose={vi.fn()}1272      />1273    );12741275    await flushView();12761277    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1278    expect(search.value).toBe("g");1279    expect(document.activeElement).toBe(search);1280  });12811282  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1283    parseImportSourceMock.mockResolvedValue(1284      sampleImportResult({1285        entries: [1286          {1287            type: "login",1288            title: "Imported site",1289            username: "imported-user",1290            password: "imported-pass",1291            url: "https://imported.test",1292          },1293        ],1294      })1295    );1296    sendMessageMock.mockImplementation(async (msg: unknown) => {1297      const type = (msg as { type?: string }).type;1298      if (type === "GET_ENTRIES") {1299        return {1300          ok: true,1301          data: [1302            sampleEntry({1303              id: "existing-1",1304              title: "Imported site",1305              username: "imported-user",1306              url: "https://imported.test",1307            }),1308          ],1309        };1310      }1311      if (type === "BATCH_ADD_ENTRIES") {1312        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1313      }1314      return { ok: true };1315    });1316    const onImport = vi.fn(async () => undefined);1317    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1318    await flushView();1319    sendMessageMock.mockClear();13201321    await selectImportFile(container);1322    findButton(container, "Import 1").click();1323    await flushView();13241325    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1326    expect(sendMessageMock).toHaveBeenCalledWith({1327      type: "BATCH_ADD_ENTRIES",1328      payload: {1329        entries: [1330          expect.objectContaining({1331            title: "Imported site",1332            username: "imported-user",1333            password: "imported-pass",1334            url: "https://imported.test",1335          }),1336        ],1337      },1338    });1339    expect(onImport).toHaveBeenCalled();1340    expect(container.textContent).toContain("1 entry appears to be duplicates");1341  });13421343  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1344    parseImportSourceMock.mockResolvedValue(sampleImportResult());1345    sendMessageMock.mockImplementation(async (msg: unknown) => {1346      const type = (msg as { type?: string }).type;1347      if (type === "GET_ENTRIES") {1348        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1349      }1350      if (type === "CLEAR_VAULT_ENTRIES") {1351        return { ok: true };1352      }1353      if (type === "BATCH_ADD_ENTRIES") {1354        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1355      }1356      return { ok: true };1357    });1358    const onImport = vi.fn(async () => undefined);1359    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1360    await flushView();1361    sendMessageMock.mockClear();13621363    await selectImportFile(container);1364    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1365    await flushView();13661367    findButton(container, "Replace vault with 1").click();1368    await flushView();13691370    expect(sendMessageMock).not.toHaveBeenCalledWith(1371      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1372    );1373    expect(container.textContent).toContain("Replace forever? This cannot be undone.");13741375    findButton(container, "Yes, replace forever").click();1376    await flushView();13771378    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1379    expect(sendMessageMock).toHaveBeenCalledWith({1380      type: "BATCH_ADD_ENTRIES",1381      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1382    });1383    expect(onImport).toHaveBeenCalled();1384  });13851386  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1387    parseImportSourceMock.mockResolvedValue(sampleImportResult());1388    sendMessageMock.mockImplementation(async (msg: unknown) => {1389      const type = (msg as { type?: string }).type;1390      if (type === "CREATE_VAULT") return { ok: true };1391      if (type === "BATCH_ADD_ENTRIES") {1392        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1393      }1394      return { ok: true };1395    });1396    const onImport = vi.fn(async () => undefined);1397    const container = mount(1398      <ImportView1399        mode="onboarding"1400        replacesExistingVault={true}1401        onBack={vi.fn()}1402        onImport={onImport}1403      />1404    );14051406    await selectImportFile(container);14071408    const createButton = findButton(container, "Create vault + import 1");1409    expect(createButton.disabled).toBe(true);1410    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1411    expect(container.textContent).toContain("Use at least 8 characters");14121413    const masterInput = container.querySelector(1414      'input[placeholder="new master password"]'1415    ) as HTMLInputElement;1416    const confirmInput = container.querySelector(1417      'input[placeholder="confirm master password"]'1418    ) as HTMLInputElement;1419    const revealButton = container.querySelector(1420      'button[aria-label="Show passwords"]'1421    ) as HTMLButtonElement;1422    expect(revealButton).toBeTruthy();1423    revealButton.click();1424    await flushView();1425    expect(masterInput.type).toBe("text");1426    expect(confirmInput.type).toBe("text");14271428    inputText(masterInput, "fresh-master-password");1429    inputText(confirmInput, "fresh-master-password");1430    await flushView();14311432    expect(createButton.disabled).toBe(false);1433    createButton.click();1434    await flushView();14351436    expect(sendMessageMock).toHaveBeenCalledWith({1437      type: "CREATE_VAULT",1438      payload: { password: "fresh-master-password" },1439    });1440    expect(sendMessageMock).toHaveBeenCalledWith({1441      type: "BATCH_ADD_ENTRIES",1442      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1443    });1444    expect(onImport).toHaveBeenCalled();1445  });14461447  it("Import encrypted backups reparse with the backup password before import", async () => {1448    parseImportSourceMock1449      .mockResolvedValueOnce(1450        sampleImportResult({1451          format: "peach-json",1452          label: "Peach encrypted backup",1453          entries: [],1454          totalItems: 0,1455          requiresPassword: true,1456        })1457      )1458      .mockResolvedValueOnce(1459        sampleImportResult({1460          format: "peach-json",1461          label: "Peach encrypted backup",1462        })1463      );1464    sendMessageMock.mockImplementation(async (msg: unknown) => {1465      const type = (msg as { type?: string }).type;1466      if (type === "GET_ENTRIES") return { ok: true, data: [] };1467      if (type === "BATCH_ADD_ENTRIES") {1468        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1469      }1470      return { ok: true };1471    });1472    const onImport = vi.fn(async () => undefined);1473    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1474    await flushView();1475    sendMessageMock.mockClear();14761477    await selectImportFile(container, '{"type":"peach-export"}');1478    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1479    inputText(password, "backup-passphrase");1480    pressEnter(password);1481    await flushView();1482    await flushView();14831484    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1485    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14861487    findButton(container, "Import 1").click();1488    await flushView();14891490    expect(sendMessageMock).toHaveBeenCalledWith({1491      type: "BATCH_ADD_ENTRIES",1492      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1493    });1494    expect(onImport).toHaveBeenCalled();1495  });1496});