contracts.test.tsx

51.4 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { AddView } from "./Add";16import { NotesEditor } from "./NotesEditor";17import { RecoveryWordEntryView } from "./RecoveryWordEntry";18import { 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/import", () => ({34  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),35}));3637vi.mock("../../shared/favicon", async (importOriginal) => {38  const actual = await importOriginal<typeof import("../../shared/favicon")>();39  return { ...actual, faviconUrl: () => null };40});4142vi.mock("../../crypto/totp", () => ({43  generateTOTP: () => ({ code: "123 456", remaining: 24 }),44  parseOtpauthURI: (value: string) =>45    value.startsWith("otpauth://")46      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }47      : null,48}));4950vi.mock("../components/GeneratorDrawer", () => ({51  GeneratorDrawer: () => null,52}));5354vi.mock("../components/RuneBackground", () => ({55  RuneBackground: () => null,56}));5758vi.mock("@zxcvbn-ts/core", () => ({59  zxcvbn: (value: string) => zxcvbnMock(value),60  zxcvbnOptions: { setOptions: vi.fn() },61}));62vi.mock("@zxcvbn-ts/language-common", () => ({63  adjacencyGraphs: {},64  dictionary: {},65}));66vi.mock("@zxcvbn-ts/language-en", () => ({67  translations: {},68  dictionary: {},69}));7071describe("popup contract tests", () => {72  const disposers: Array<() => void> = [];7374  beforeEach(() => {75    document.body.innerHTML = "";76    sendMessageMock.mockReset();77    triggerRingMock.mockReset();78    triggerPipPulseMock.mockReset();79    parseImportSourceMock.mockReset();80    zxcvbnMock.mockReset();81    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));82    parseImportSourceMock.mockResolvedValue({83      format: "unknown",84      label: "Unknown format",85      entries: [],86      totalItems: 0,87      skippedItems: 0,88      requiresPassword: false,89    });9091    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {92      return window.setTimeout(() => cb(0), 0) as unknown as number;93    });94    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));95    vi.stubGlobal("URL", {96      createObjectURL: vi.fn(() => "blob:peach-test"),97      revokeObjectURL: vi.fn(),98    });99    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});100101    (globalThis as { chrome?: typeof chrome }).chrome = {102      runtime: {103        getManifest: () => ({ version: "1.4.47" }),104        onMessage: { addListener: vi.fn() },105      },106    } as unknown as typeof chrome;107  });108109  afterEach(() => {110    while (disposers.length > 0) {111      disposers.pop()?.();112    }113    vi.restoreAllMocks();114    vi.unstubAllGlobals();115  });116117  function mount(view: JSX.Element | (() => JSX.Element)) {118    const mounted = renderView(view);119    disposers.push(mounted.dispose);120    return mounted.container;121  }122123  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {124    return {125      id: "entry-1",126      type: "login",127      title: "GitHub",128      username: "june",129      password: "secret123",130      url: "https://github.com",131      totp: "JBSWY3DPEHPK3PXP",132      canary: {133        webhookUrl: "https://example.com/hook",134      },135      createdAt: 1,136      modifiedAt: 1,137      ...overrides,138    };139  }140141  function sampleImportResult(142    overrides: Partial<ImportParseResult> = {}143  ): ImportParseResult {144    return {145      format: "csv",146      label: "CSV import",147      entries: [148        {149          type: "login",150          title: "Imported site",151          username: "imported-user",152          password: "imported-pass",153          url: "https://imported.test",154        },155      ],156      totalItems: 1,157      skippedItems: 0,158      requiresPassword: false,159      ...overrides,160    };161  }162163  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {164    return {165      entriesCount: 1,166      breachMonitoring: true,167      lockOnClose: true,168      autoLockMinutes: 5,169      trashRetentionDays: 30,170      theme: "dark" as const,171      accentColor: "peach" as const,172      textSize: "medium" as const,173      reduceMotion: false,174      onBreachMonitoringChange: vi.fn(),175      onLockOnCloseChange: vi.fn(),176      onAutoLockMinutesChange: vi.fn(),177      onTrashRetentionChange: vi.fn(),178      onThemeChange: vi.fn(),179      onAccentChange: vi.fn(),180      onTextSizeChange: vi.fn(),181      onReduceMotionChange: vi.fn(),182      shortcuts: DEFAULT_BINDINGS,183      onShortcutsChange: vi.fn(),184      onBack: vi.fn(),185      onImport: vi.fn(),186      onExport: vi.fn(),187      onBreachCheckDone: vi.fn(),188      ...overrides,189    };190  }191192  async function selectImportFile(193    container: HTMLElement,194    contents = '{"export":"test"}'195  ) {196    // The Import view now opens onto a menu — click into the file subview197    // before locating the file input. Idempotent: a second click won't re-fire198    // because the menu unmounts once the subview renders.199    const fileCard = findButton(container, "Choose file →");200    if (fileCard) {201      fileCard.click();202      await flushView();203    }204    const input = container.querySelector('input[type="file"]') as HTMLInputElement;205    const file = new File([contents], "import.json", { type: "application/json" });206    Object.defineProperty(input, "files", {207      configurable: true,208      value: [file],209    });210    input.dispatchEvent(new Event("change", { bubbles: true }));211    await flushView();212    await flushView();213  }214215  function findButton(container: HTMLElement, text: string): HTMLButtonElement {216    return Array.from(container.querySelectorAll("button")).find(217      (candidate) => candidate.textContent?.trim() === text218    ) as HTMLButtonElement;219  }220221  it("Lock sends UNLOCK with the entered passphrase", async () => {222    sendMessageMock.mockResolvedValue({ ok: true });223    const onUnlock = vi.fn(async () => undefined);224    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);225226    const password = container.querySelector("#unlock-password") as HTMLInputElement;227    inputText(password, "correct horse battery staple");228    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();229    await flushView();230231    expect(sendMessageMock).toHaveBeenCalledWith({232      type: "UNLOCK",233      payload: { password: "correct horse battery staple" },234    });235    expect(onUnlock).toHaveBeenCalled();236  });237238  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {239    sendMessageMock.mockImplementation(async (msg: unknown) => {240      return (msg as { type: string }).type === "GET_SETTINGS"241        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }242        : { ok: true };243    });244    const onUnlock = vi.fn(async () => undefined);245    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);246    await flushView();247248    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");249    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();250    await flushView();251252    expect(sendMessageMock).toHaveBeenCalledWith({253      type: "UNLOCK_WITH_PIN",254      payload: { pin: "1234" },255    });256    expect(onUnlock).toHaveBeenCalled();257    expect(container.textContent).toContain("use master password");258  });259260  it("places the PIN method switch below the unlock button", async () => {261    sendMessageMock.mockImplementation(async (msg: unknown) => {262      return (msg as { type: string }).type === "GET_SETTINGS"263        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }264        : { ok: true };265    });266    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);267    await flushView();268269    const buttons = Array.from(container.querySelectorAll("button"));270    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");271    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");272    expect(pinIndex).toBeGreaterThan(unlockIndex);273  });274275  it("keeps standalone note creation in Notes mode", async () => {276    const chromeApi = globalThis.chrome as unknown as {277      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };278    };279    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };280281    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);282    await flushView();283284    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))285      .toEqual(["login", "card"]);286    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();287  });288289  it("gives Notes mode the full note-formatting toolbar", async () => {290    const container = mount(291      <NotesEditor292        note={null}293        onBack={vi.fn()}294        onSaved={vi.fn()}295        onDelete={vi.fn()}296      />297    );298    await flushView();299300    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();301    expect(container.querySelector('[title="Divider"]')).toBeTruthy();302    expect(container.querySelector('[title="Quote"]')).toBeTruthy();303  });304305  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {306    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);307    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;308    const password = container.querySelector("#master-password") as HTMLInputElement;309    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;310311    inputText(password, "short");312    inputText(confirm, "short");313    await flushView();314    expect(createButton.disabled).toBe(true);315316    zxcvbnMock.mockImplementation(() => ({ score: 1 }));317    inputText(password, "weakweakweak");318    inputText(confirm, "weakweakweak");319    await flushView();320    expect(createButton.disabled).toBe(true);321  });322323  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {324    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock325    sendMessageMock.mockImplementation(326      async (msg: unknown) => {327        const m = msg as { type: string; payload?: { password?: string } };328        if (m.type === "CREATE_VAULT") return { ok: true };329        if (m.type === "GET_RECOVERY_WORDS") {330          // Return 12 fake words — must pass findInvalidWords331          return {332            ok: true,333            data: [334              "abandon", "ability", "able", "about", "above", "absent",335              "absorb", "abstract", "absurd", "abuse", "access", "accident",336            ],337          };338        }339        return { ok: true };340      }341    );342    zxcvbnMock.mockImplementation(() => ({ score: 3 }));343    const onUnlock = vi.fn(async () => undefined);344    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);345346    const password = container.querySelector("#master-password") as HTMLInputElement;347    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;348    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;349350    inputText(password, "correct horse battery staple");351    inputText(confirm, "correct horse battery staple");352    await flushView();353    expect(createButton.disabled).toBe(false);354355    pressEnter(confirm);356    await flushView();357358    expect(sendMessageMock).toHaveBeenCalledWith({359      type: "CREATE_VAULT",360      payload: { password: "correct horse battery staple" },361    });362    // After CREATE_VAULT success, the recovery word display is shown.363    // onUnlock is NOT called yet — user must first confirm their recovery words.364    expect(onUnlock).not.toHaveBeenCalled();365  });366367  it("Recovery words flow submits the new master password and completes", async () => {368    sendMessageMock.mockImplementation(async (msg: unknown) => {369      const type = (msg as { type?: string }).type;370      if (type === "VALIDATE_RECOVERY_WORDS") {371        return { ok: true, data: { valid: true, invalid: [] } };372      }373      if (type === "RECOVER_WITH_WORDS") return { ok: true };374      return { ok: true };375    });376    const canvasContext = {377      clearRect: vi.fn(),378      fillText: vi.fn(),379      beginPath: vi.fn(),380      closePath: vi.fn(),381      arc: vi.fn(),382      fill: vi.fn(),383      stroke: vi.fn(),384      save: vi.fn(),385      restore: vi.fn(),386      translate: vi.fn(),387      rotate: vi.fn(),388      scale: vi.fn(),389      drawImage: vi.fn(),390      measureText: vi.fn(() => ({ width: 0 })),391      setTransform: vi.fn(),392      fillRect: vi.fn(),393      strokeRect: vi.fn(),394    } as unknown as CanvasRenderingContext2D;395    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);396397    const onRecoveryComplete = vi.fn(async () => undefined);398    const container = mount(399      <RecoveryWordEntryView400        onCancel={vi.fn()}401        onRecoveryComplete={onRecoveryComplete}402      />403    );404405    const recoveryWords = WORD_LIST.slice(0, 12);406    for (let i = 0; i < recoveryWords.length; i++) {407      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;408      inputText(input, recoveryWords[i]!);409    }410    await flushView();411412    findButton(container, "verify words").click();413    await flushView();414415    expect(container.textContent).toContain("new master password");416417    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;418    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;419    inputText(newPassword, "fresh-recovery-password");420    inputText(confirmPassword, "fresh-recovery-password");421    await flushView();422423    findButton(container, "recover vault").click();424    await flushView();425426    expect(sendMessageMock).toHaveBeenCalledWith({427      type: "RECOVER_WITH_WORDS",428      payload: {429        words: recoveryWords,430        newPassword: "fresh-recovery-password",431      },432    });433    expect(onRecoveryComplete).toHaveBeenCalled();434  });435436  it("Export menu surfaces both backup formats", async () => {437    const container = mount(<ExportView onBack={vi.fn()} />);438    await flushView();439440    expect(container.textContent).toContain("PeachScript");441    expect(container.textContent).toContain("CSV");442  });443444  it("Lock can open directly on backup restore from onboarding", async () => {445    const container = mount(() => (446      <LockView447        hasVault={false}448        ready={true}449        onUnlock={vi.fn()}450        initialRestore={true}451      />452    ));453    await flushView();454455    expect(container.textContent).toContain("PeachScript");456    expect(container.textContent).toContain("Codex");457    expect(container.textContent).toContain("CSV");458    expect(container.querySelector("#master-password")).toBeNull();459  });460461  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {462    const device = {463      deviceId: "phone-1",464      deviceName: "June's Phone",465      deviceType: "android" as const,466      longTermPublicKeyHex: "a".repeat(64),467      signingPublicKeyHex: "b".repeat(64),468      pairedAt: 1,469      trustSource: "direct" as const,470    };471    sendMessageMock.mockImplementation(async (msg: unknown) => {472      const m = msg as { type: string };473      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };474      return { ok: true, data: null };475    });476477    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);478    for (let i = 0; i < 30; i++) await Promise.resolve();479480    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>481      /sync/i.test(b.textContent ?? ""),482    );483    openSync?.click();484    for (let i = 0; i < 30; i++) await Promise.resolve();485486    const remove = Array.from(container.querySelectorAll("button")).find(487      (b) => (b.textContent ?? "").trim() === "remove",488    );489    expect(remove).toBeTruthy();490491    // Guard against a vacuous pass: the disclosure must not already be on492    // screen before the user asks to remove anything.493    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();494495    remove!.click();496    for (let i = 0; i < 10; i++) await Promise.resolve();497498    // Revocation must never be presented as securing the vault against the499    // removed device — recovery words survive it and restore everything.500    const note = container.querySelector(".peach-unpair-confirm-note");501    expect(note).toBeTruthy();502    const text = note!.textContent ?? "";503    expect(text).toContain("master password");504    expect(text).toContain("recovery words");505    expect(text).toContain("future changes");506  });507508  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {509    const device = {510      deviceId: "phone-1",511      deviceName: "June's Phone",512      deviceType: "android" as const,513      longTermPublicKeyHex: "a".repeat(64),514      signingPublicKeyHex: "b".repeat(64),515      pairedAt: 1,516      trustSource: "direct" as const,517    };518    let paired: typeof device[] = [device];519    sendMessageMock.mockImplementation(async (msg: any) => {520      if (msg?.type === "LIST_PAIRED_DEVICES") {521        return { ok: true, data: { devices: [...paired] } };522      }523      if (msg?.type === "UNPAIR_DEVICE") {524        // Mirror the real handler: UNPAIR_DEVICE drops the row from the525        // registry. The next LIST_PAIRED_DEVICES must not see it.526        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);527        return { ok: true };528      }529      if (msg?.type === "RENAME_PAIRED_DEVICE") {530        paired = paired.map((d) =>531          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,532        );533        return { ok: true };534      }535      return { ok: true, data: null };536    });537538    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);539    for (let i = 0; i < 30; i++) await Promise.resolve();540    Array.from(container.querySelectorAll("button"))541      .find((b) => /sync/i.test(b.textContent ?? ""))542      ?.click();543    for (let i = 0; i < 30; i++) await Promise.resolve();544545    // The list itself: extracting the relay selector must not take the hub with it.546    expect(container.textContent).toContain("PAIRED DEVICES");547    expect(container.textContent).toContain("June's Phone");548    expect(container.textContent).toContain("Android");549    Array.from(container.querySelectorAll("button"))550      .find((b) => (b.textContent ?? "").trim() === "rename")!551      .click();552    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;553    renameInput.value = "Travel phone";554    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));555    Array.from(container.querySelectorAll("button"))556      .find((b) => (b.textContent ?? "").trim() === "save")!557      .click();558    for (let i = 0; i < 30; i++) await Promise.resolve();559    expect(sendMessageMock).toHaveBeenCalledWith({560      type: "RENAME_PAIRED_DEVICE",561      payload: { deviceId: "phone-1", name: "Travel phone" },562    });563    expect(container.textContent).toContain("Travel phone");564    const syncNowButton = container.querySelector<HTMLButtonElement>(565      'button[aria-label="Sync this vault now"]',566    )!;567    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));568    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;569    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;570    expect(571      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,572    ).toBeTruthy();573    expect(574      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,575    ).toBeTruthy();576577    Array.from(container.querySelectorAll("button"))578      .find((b) => (b.textContent ?? "").trim() === "remove")!579      .click();580    for (let i = 0; i < 10; i++) await Promise.resolve();581    const confirm = Array.from(582      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),583    ).find((b) => (b.textContent ?? "").trim() === "remove");584    confirm!.click();585    for (let i = 0; i < 30; i++) await Promise.resolve();586587    expect(sendMessageMock).toHaveBeenCalledWith({588      type: "UNPAIR_DEVICE",589      payload: { deviceId: "phone-1" },590    });591    // The list must be refreshed and the device must be gone. The regression592    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but593    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.594    expect(container.textContent).not.toContain("June's Phone");595    expect(container.textContent).not.toContain("PAIRED DEVICES");596  });597598  it("Settings keeps connection and recovery details collapsed until requested", async () => {599    sendMessageMock.mockImplementation(async (msg: any) => {600      if (msg?.type === "LIST_PAIRED_DEVICES") {601        return { ok: true, data: { devices: [] } };602      }603      return { ok: true, data: null };604    });605606    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);607    for (let i = 0; i < 30; i++) await Promise.resolve();608    Array.from(container.querySelectorAll("button"))609      .find((button) => /sync/i.test(button.textContent ?? ""))610      ?.click();611    for (let i = 0; i < 20; i++) await Promise.resolve();612613    expect(container.textContent).toContain("SYNC STATUS");614    expect(container.textContent).toContain("ADD A DEVICE");615    expect(container.textContent).not.toContain("Public relay");616    expect(container.textContent).not.toContain("Rotate sync identity");617618    const connection = Array.from(container.querySelectorAll("button"))619      .find((button) => button.textContent?.includes("CONNECTION"));620    connection?.click();621    await Promise.resolve();622    expect(container.textContent).toContain("Public relay");623624    const recovery = Array.from(container.querySelectorAll("button"))625      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));626    recovery?.click();627    await Promise.resolve();628    expect(container.textContent).toContain("Rotate sync identity");629  });630631  it("Settings requires current, new, and confirm passwords before enabling save", async () => {632    sendMessageMock.mockResolvedValue({ ok: true, data: null });633    const container = mount(634      <SettingsView635        entriesCount={1}636        breachMonitoring={true}637        lockOnClose={true}638        autoLockMinutes={5}639        trashRetentionDays={30}640        theme="dark"641        accentColor="peach"642        textSize="medium"643        reduceMotion={false}644        onBreachMonitoringChange={vi.fn()}645        onLockOnCloseChange={vi.fn()}646        onAutoLockMinutesChange={vi.fn()}647        onTrashRetentionChange={vi.fn()}648        onThemeChange={vi.fn()}649        onAccentChange={vi.fn()}650        onTextSizeChange={vi.fn()}651        onReduceMotionChange={vi.fn()}652        shortcuts={DEFAULT_BINDINGS}653        onShortcutsChange={vi.fn()}654        onBack={vi.fn()}655        onImport={vi.fn()}656        onExport={vi.fn()}657        onBreachCheckDone={vi.fn()}658      />659    );660    await flushView();661662    expect(container.querySelectorAll(".stk-card").length).toBe(7);663    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]664    // "open settings to card I (Peach Pro) instead of card II (Protection)".665    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");666667    const changeButton = Array.from(container.querySelectorAll("button")).find(668      (candidate) => candidate.textContent?.trim() === "change"669    ) as HTMLButtonElement;670    changeButton.click();671    await flushView();672673    const saveButton = Array.from(container.querySelectorAll("button")).find(674      (candidate) => candidate.textContent?.trim() === "save new password"675    ) as HTMLButtonElement;676    expect(saveButton.disabled).toBe(true);677678    const inputs = container.querySelectorAll('input[type="password"]');679    inputText(inputs[0] as HTMLInputElement, "current-pass");680    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");681    await flushView();682    expect(saveButton.disabled).toBe(true);683684    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");685    await flushView();686    expect(saveButton.disabled).toBe(false);687  });688689  it("Settings feedback highlights the selected category and hides the form after submit", async () => {690    sendMessageMock.mockResolvedValue({ ok: true, data: null });691    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({692      ok: true,693      json: async () => ({ success: true }),694    }));695    vi.stubGlobal("fetch", fetchMock);696697    const container = mount(698      <SettingsView699        entriesCount={1}700        breachMonitoring={true}701        lockOnClose={true}702        autoLockMinutes={5}703        trashRetentionDays={30}704        theme="dark"705        accentColor="peach"706        textSize="medium"707        reduceMotion={false}708        initialSection="feedback"709        onBreachMonitoringChange={vi.fn()}710        onLockOnCloseChange={vi.fn()}711        onAutoLockMinutesChange={vi.fn()}712        onTrashRetentionChange={vi.fn()}713        onThemeChange={vi.fn()}714        onAccentChange={vi.fn()}715        onTextSizeChange={vi.fn()}716        onReduceMotionChange={vi.fn()}717        shortcuts={DEFAULT_BINDINGS}718        onShortcutsChange={vi.fn()}719        onBack={vi.fn()}720        onImport={vi.fn()}721        onExport={vi.fn()}722        onBreachCheckDone={vi.fn()}723      />724    );725    await flushView();726727    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;728    expect(feedbackCard.classList.contains("is-active")).toBe(true);729730    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];731    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;732    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;733    expect(bugButton.getAttribute("aria-pressed")).toBe("true");734    expect(featureButton.getAttribute("aria-pressed")).toBe("false");735736    featureButton.click();737    await flushView();738739    expect(bugButton.getAttribute("aria-pressed")).toBe("false");740    expect(featureButton.getAttribute("aria-pressed")).toBe("true");741    expect(featureButton.classList.contains("is-active")).toBe(true);742743    inputText(744      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,745      '<script>alert("xss")</script> Please make this easier to see & safer.'746    );747    const sendButton = Array.from(container.querySelectorAll("button")).find(748      (candidate) => candidate.textContent?.trim() === "Send"749    ) as HTMLButtonElement;750    sendButton.click();751752    await vi.waitFor(() => {753      expect(container.textContent).toContain("Thank you");754    });755756    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));757    expect(requestBody.subject).toContain("Feature request");758    expect(requestBody.message).toBe(759      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."760    );761    expect(requestBody.message).not.toContain("<script>");762    expect(container.textContent).toContain("Someone will process it soon");763    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();764    expect(container.querySelector(".peach-feedback-submit")).toBeNull();765  });766767  it("Settings keeps license activation on the full-screen celebration path", async () => {768    const onLicenseChange = vi.fn(async () => undefined);769    sendMessageMock.mockImplementation(async (msg: unknown) => {770      const type = (msg as { type?: string }).type;771      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };772      if (type === "ACTIVATE_LICENSE") {773        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };774      }775      if (type === "GET_ENTRIES") return { ok: true, data: [] };776      return { ok: true, data: null };777    });778779    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);780    await flushView();781782    inputText(783      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,784      "PEACH-TEST-KEY",785    );786    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;787    apply.click();788789    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));790    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);791    expect(container.textContent).not.toContain("thank you ✦");792  });793794  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {795    sendMessageMock.mockImplementation(async (msg: unknown) => {796      const type = (msg as { type?: string }).type;797      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };798      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };799      if (type === "GET_ENTRIES") return { ok: true, data: [] };800      return { ok: true, data: null };801    });802803    const onLicenseChangeFromStar = vi.fn();804    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);805    await flushView();806    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();807    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);808    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();809810    const onLicenseChangeFromPeach = vi.fn();811    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);812    await flushView();813    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();814    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);815    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();816  });817818  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {819    sendMessageMock.mockResolvedValue({ ok: true, data: null });820    const fetchMock = vi.fn();821    const requestMock = vi.fn(async () => false);822    vi.stubGlobal("fetch", fetchMock);823    vi.stubGlobal("browser", {824      permissions: {825        getAll: vi.fn(async () => ({ data_collection: [] })),826        request: requestMock,827      },828    });829830    const container = mount(831      <SettingsView832        entriesCount={1}833        breachMonitoring={true}834        lockOnClose={true}835        autoLockMinutes={5}836        trashRetentionDays={30}837        theme="dark"838        accentColor="peach"839        textSize="medium"840        reduceMotion={false}841        initialSection="feedback"842        onBreachMonitoringChange={vi.fn()}843        onLockOnCloseChange={vi.fn()}844        onAutoLockMinutesChange={vi.fn()}845        onTrashRetentionChange={vi.fn()}846        onThemeChange={vi.fn()}847        onAccentChange={vi.fn()}848        onTextSizeChange={vi.fn()}849        onReduceMotionChange={vi.fn()}850        shortcuts={DEFAULT_BINDINGS}851        onShortcutsChange={vi.fn()}852        onBack={vi.fn()}853        onImport={vi.fn()}854        onExport={vi.fn()}855        onBreachCheckDone={vi.fn()}856      />857    );858    await flushView();859860    inputText(861      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,862      "Please improve the feedback flow."863    );864    const sendButton = Array.from(container.querySelectorAll("button")).find(865      (candidate) => candidate.textContent?.trim() === "Send"866    ) as HTMLButtonElement;867    sendButton.click();868869    await vi.waitFor(() => {870      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });871    });872    expect(fetchMock).not.toHaveBeenCalled();873    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();874  });875876  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {877    sendMessageMock.mockImplementation(async (msg: unknown) => {878      const m = msg as { type: string };879      if (m.type === "GET_RECOVERY_WORDS") {880        return {881          ok: true,882          data: [883            "abandon", "ability", "able", "about", "above", "absent",884            "absorb", "abstract", "absurd", "abuse", "access", "accident",885          ],886        };887      }888      return { ok: true, data: null };889    });890    const onAutoLockMinutesChange = vi.fn();891    const onThemeChange = vi.fn();892    const container = mount(893      <SettingsView894        entriesCount={1}895        breachMonitoring={true}896        lockOnClose={true}897        autoLockMinutes={5}898        trashRetentionDays={30}899        theme="dark"900        accentColor="peach"901        textSize="medium"902        reduceMotion={false}903        onBreachMonitoringChange={vi.fn()}904        onLockOnCloseChange={vi.fn()}905        onAutoLockMinutesChange={onAutoLockMinutesChange}906        onTrashRetentionChange={vi.fn()}907        onThemeChange={onThemeChange}908        onAccentChange={vi.fn()}909        onTextSizeChange={vi.fn()}910        onReduceMotionChange={vi.fn()}911        shortcuts={DEFAULT_BINDINGS}912        onShortcutsChange={vi.fn()}913        onBack={vi.fn()}914        onImport={vi.fn()}915        onExport={vi.fn()}916        onBreachCheckDone={vi.fn()}917      />918    );919    await flushView();920    sendMessageMock.mockClear();921922    // ── Auto-lock + theme changes (before password change) ─────────────923    const selects = container.querySelectorAll("select");924    changeSelect(selects[0] as HTMLSelectElement, "15");925    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);926927    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;928    appearanceCard.click();929    await flushView();930    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");931    const themeButton = Array.from(container.querySelectorAll("button")).find(932      (candidate) => candidate.textContent?.trim() === "Switch to light"933    ) as HTMLButtonElement;934    themeButton.click();935    expect(onThemeChange).toHaveBeenCalledWith("light");936937    // ── Password change → recovery words display ────────────────────────938    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;939    protectionCard.click();940    await flushView();941942    const changeButton = Array.from(container.querySelectorAll("button")).find(943      (candidate) => candidate.textContent?.trim() === "change"944    ) as HTMLButtonElement;945    changeButton.click();946    await flushView();947948    const inputs = container.querySelectorAll('input[type="password"]');949    inputText(inputs[0] as HTMLInputElement, "current-pass");950    inputText(inputs[1] as HTMLInputElement, "new-password-123");951    inputText(inputs[2] as HTMLInputElement, "new-password-123");952    await flushView();953954    const saveButton = Array.from(container.querySelectorAll("button")).find(955      (candidate) => candidate.textContent?.trim() === "save new password"956    ) as HTMLButtonElement;957    saveButton.click();958    await flushView();959960    expect(sendMessageMock).toHaveBeenCalledWith({961      type: "CHANGE_PASSWORD",962      payload: {963        currentPassword: "current-pass",964        newPassword: "new-password-123",965      },966    });967968    // After a successful password change the recovery word display replaces settings.969    expect(container.textContent).toContain("save your recovery words");970    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });971  });972973  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {974    sendMessageMock.mockResolvedValue({ ok: true });975    const container = mount(976      <EntryView977        entry={sampleEntry()}978        onDelete={vi.fn()}979        onFieldSaved={vi.fn(async () => undefined)}980      />981    );982983    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;984    passwordButton.click();985    await flushView();986987    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {988      type: "COPY",989      payload: { text: "secret123", label: "password", alreadyWritten: false },990    });991    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {992      type: "FIRE_CANARY_PING",993      payload: {994        webhookUrl: "https://example.com/hook",995        entryTitle: "GitHub",996        trigger: "password_copied",997      },998    });9991000    sendMessageMock.mockClear();1001    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1002    usernameButton.click();1003    await flushView();10041005    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1006      type: "COPY",1007      payload: { text: "june", label: "username", alreadyWritten: false },1008    });1009    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1010      type: "FIRE_CANARY_PING",1011      payload: {1012        webhookUrl: "https://example.com/hook",1013        entryTitle: "GitHub",1014        trigger: "username_copied",1015      },1016    });1017  });10181019  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1020    const container = mount(1021      <EntryView1022        entry={sampleEntry({1023          type: "passkey",1024          username: undefined,1025          password: undefined,1026          totp: undefined,1027          canary: undefined,1028          passkey: {1029            rpId: "vercel.com",1030            rpName: "Vercel",1031            credentialId: "cred-1",1032            publicKeyCose: "cose",1033            userHandle: "handle",1034            userName: "june",1035            userDisplayName: "June",1036            algorithm: -7,1037            signCount: 0,1038            aaguid: "",1039            backupEligible: false,1040            backupState: false,1041            createdAt: 1,1042          },1043        })}1044        onDelete={vi.fn()}1045        onFieldSaved={vi.fn(async () => undefined)}1046      />1047    );10481049    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1050      (button) => button.textContent?.includes("Advanced")1051    ) as HTMLButtonElement | undefined;1052    expect(advanced).toBeDefined();1053    expect(advanced?.textContent).toContain("+ Advanced");10541055    advanced!.click();1056    await flushView();10571058    expect(container.textContent).toContain("passkey · vercel.com");1059  });10601061  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1062    sendMessageMock.mockResolvedValue({ ok: true });1063    const onFieldSaved = vi.fn(async () => undefined);1064    const container = mount(1065      <EntryView1066        entry={sampleEntry()}1067        onDelete={vi.fn()}1068        onFieldSaved={onFieldSaved}1069      />1070    );10711072    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1073    passwordValue.click();1074    await flushView();10751076    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1077    inputText(passwordInput, "");1078    await flushView();10791080    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1081    saveButton.click();1082    await flushView();10831084    expect(sendMessageMock).not.toHaveBeenCalledWith(1085      expect.objectContaining({ type: "UPDATE_ENTRY" })1086    );1087    expect(onFieldSaved).not.toHaveBeenCalled();10881089    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1090    totpMain.click();10911092    await vi.waitFor(() => {1093      expect(sendMessageMock).toHaveBeenLastCalledWith({1094        type: "COPY",1095        payload: { text: "123456", label: "totp", alreadyWritten: false },1096      });1097    }, { timeout: 500 });1098  });10991100  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1101    sendMessageMock.mockResolvedValue({ ok: true });1102    const container = mount(1103      <EntryView1104        entry={sampleEntry()}1105        onDelete={vi.fn()}1106        onFieldSaved={vi.fn(async () => undefined)}1107      />1108    );11091110    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1111    usernameValue.click();1112    await flushView();11131114    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1115    usernameInput.blur();1116    await flushView();11171118    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1119    expect(sendMessageMock).not.toHaveBeenCalledWith(1120      expect.objectContaining({ type: "UPDATE_ENTRY" })1121    );1122  });11231124  it("Entry keeps the full used-email list available while editing an email", async () => {1125    const container = mount(1126      <EntryView1127        entry={sampleEntry({ email: "[email protected]" })}1128        entries={[1129          sampleEntry({ email: "[email protected]" }),1130          sampleEntry({ id: "entry-2", email: "[email protected]" }),1131          sampleEntry({ id: "entry-3", username: "duckuccino" }),1132        ]}1133        onDelete={vi.fn()}1134        onFieldSaved={vi.fn(async () => undefined)}1135      />1136    );11371138    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1139      (card) => card.querySelector(".peach-label")?.textContent === "email"1140    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1141    emailValue.click();1142    await flushView();11431144    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1145      .map((element) => element.textContent);1146    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1147  });11481149  it("Entry lets TOTP secrets be edited inline", async () => {1150    sendMessageMock.mockResolvedValue({ ok: true });1151    const onFieldSaved = vi.fn(async () => undefined);1152    const container = mount(1153      <EntryView1154        entry={sampleEntry()}1155        onDelete={vi.fn()}1156        onFieldSaved={onFieldSaved}1157      />1158    );11591160    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1161    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1162    await flushView();11631164    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1165    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1166    await flushView();11671168    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1169    saveButton.click();1170    await flushView();11711172    expect(sendMessageMock).toHaveBeenCalledWith({1173      type: "UPDATE_ENTRY",1174      payload: {1175        id: "entry-1",1176        patch: { totp: "NB2W45DFOIZA====" },1177      },1178    });1179    expect(onFieldSaved).toHaveBeenCalled();1180  });11811182  it("Vault seeds and focuses search when a search seed is provided", async () => {1183    const container = mount(1184      <VaultView1185        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1186        sortOrder="url-first"1187        searchSeed="g"1188        searchSeedNonce={1}1189        onSelectEntry={vi.fn()}1190        onSortOrderChange={vi.fn()}1191        onAdd={vi.fn()}1192        onClose={vi.fn()}1193      />1194    );11951196    await flushView();11971198    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1199    expect(search.value).toBe("g");1200    expect(document.activeElement).toBe(search);1201  });12021203  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1204    parseImportSourceMock.mockResolvedValue(1205      sampleImportResult({1206        entries: [1207          {1208            type: "login",1209            title: "Imported site",1210            username: "imported-user",1211            password: "imported-pass",1212            url: "https://imported.test",1213          },1214        ],1215      })1216    );1217    sendMessageMock.mockImplementation(async (msg: unknown) => {1218      const type = (msg as { type?: string }).type;1219      if (type === "GET_ENTRIES") {1220        return {1221          ok: true,1222          data: [1223            sampleEntry({1224              id: "existing-1",1225              title: "Imported site",1226              username: "imported-user",1227              url: "https://imported.test",1228            }),1229          ],1230        };1231      }1232      if (type === "BATCH_ADD_ENTRIES") {1233        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1234      }1235      return { ok: true };1236    });1237    const onImport = vi.fn(async () => undefined);1238    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1239    await flushView();1240    sendMessageMock.mockClear();12411242    await selectImportFile(container);1243    findButton(container, "Import 1").click();1244    await flushView();12451246    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1247    expect(sendMessageMock).toHaveBeenCalledWith({1248      type: "BATCH_ADD_ENTRIES",1249      payload: {1250        entries: [1251          expect.objectContaining({1252            title: "Imported site",1253            username: "imported-user",1254            password: "imported-pass",1255            url: "https://imported.test",1256          }),1257        ],1258      },1259    });1260    expect(onImport).toHaveBeenCalled();1261    expect(container.textContent).toContain("1 entry appears to be duplicates");1262  });12631264  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1265    parseImportSourceMock.mockResolvedValue(sampleImportResult());1266    sendMessageMock.mockImplementation(async (msg: unknown) => {1267      const type = (msg as { type?: string }).type;1268      if (type === "GET_ENTRIES") {1269        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1270      }1271      if (type === "CLEAR_VAULT_ENTRIES") {1272        return { ok: true };1273      }1274      if (type === "BATCH_ADD_ENTRIES") {1275        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1276      }1277      return { ok: true };1278    });1279    const onImport = vi.fn(async () => undefined);1280    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1281    await flushView();1282    sendMessageMock.mockClear();12831284    await selectImportFile(container);1285    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1286    await flushView();12871288    findButton(container, "Replace vault with 1").click();1289    await flushView();12901291    expect(sendMessageMock).not.toHaveBeenCalledWith(1292      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1293    );1294    expect(container.textContent).toContain("Replace forever? This cannot be undone.");12951296    findButton(container, "Yes, replace forever").click();1297    await flushView();12981299    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1300    expect(sendMessageMock).toHaveBeenCalledWith({1301      type: "BATCH_ADD_ENTRIES",1302      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1303    });1304    expect(onImport).toHaveBeenCalled();1305  });13061307  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1308    parseImportSourceMock.mockResolvedValue(sampleImportResult());1309    sendMessageMock.mockImplementation(async (msg: unknown) => {1310      const type = (msg as { type?: string }).type;1311      if (type === "CREATE_VAULT") return { ok: true };1312      if (type === "BATCH_ADD_ENTRIES") {1313        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1314      }1315      return { ok: true };1316    });1317    const onImport = vi.fn(async () => undefined);1318    const container = mount(1319      <ImportView1320        mode="onboarding"1321        replacesExistingVault={true}1322        onBack={vi.fn()}1323        onImport={onImport}1324      />1325    );13261327    await selectImportFile(container);13281329    const createButton = findButton(container, "Create vault + import 1");1330    expect(createButton.disabled).toBe(true);1331    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1332    expect(container.textContent).toContain("Use at least 8 characters");13331334    const masterInput = container.querySelector(1335      'input[placeholder="new master password"]'1336    ) as HTMLInputElement;1337    const confirmInput = container.querySelector(1338      'input[placeholder="confirm master password"]'1339    ) as HTMLInputElement;1340    const revealButton = container.querySelector(1341      'button[aria-label="Show passwords"]'1342    ) as HTMLButtonElement;1343    expect(revealButton).toBeTruthy();1344    revealButton.click();1345    await flushView();1346    expect(masterInput.type).toBe("text");1347    expect(confirmInput.type).toBe("text");13481349    inputText(masterInput, "fresh-master-password");1350    inputText(confirmInput, "fresh-master-password");1351    await flushView();13521353    expect(createButton.disabled).toBe(false);1354    createButton.click();1355    await flushView();13561357    expect(sendMessageMock).toHaveBeenCalledWith({1358      type: "CREATE_VAULT",1359      payload: { password: "fresh-master-password" },1360    });1361    expect(sendMessageMock).toHaveBeenCalledWith({1362      type: "BATCH_ADD_ENTRIES",1363      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1364    });1365    expect(onImport).toHaveBeenCalled();1366  });13671368  it("Import encrypted backups reparse with the backup password before import", async () => {1369    parseImportSourceMock1370      .mockResolvedValueOnce(1371        sampleImportResult({1372          format: "peach-json",1373          label: "Peach encrypted backup",1374          entries: [],1375          totalItems: 0,1376          requiresPassword: true,1377        })1378      )1379      .mockResolvedValueOnce(1380        sampleImportResult({1381          format: "peach-json",1382          label: "Peach encrypted backup",1383        })1384      );1385    sendMessageMock.mockImplementation(async (msg: unknown) => {1386      const type = (msg as { type?: string }).type;1387      if (type === "GET_ENTRIES") return { ok: true, data: [] };1388      if (type === "BATCH_ADD_ENTRIES") {1389        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1390      }1391      return { ok: true };1392    });1393    const onImport = vi.fn(async () => undefined);1394    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1395    await flushView();1396    sendMessageMock.mockClear();13971398    await selectImportFile(container, '{"type":"peach-export"}');1399    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1400    inputText(password, "backup-passphrase");1401    pressEnter(password);1402    await flushView();1403    await flushView();14041405    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1406    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14071408    findButton(container, "Import 1").click();1409    await flushView();14101411    expect(sendMessageMock).toHaveBeenCalledWith({1412      type: "BATCH_ADD_ENTRIES",1413      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1414    });1415    expect(onImport).toHaveBeenCalled();1416  });1417});