content.totp-group-icon.test.ts

3.4 KB · raw

1// @vitest-environment jsdom2//3// Regression: split-OTP digit boxes that lack maxlength="1" but carry OTP4// signaling (autocomplete="one-time-code", name="otp", …) used to each get5// their own injected peach icon — the "icon spammed into every 2FA box" bug.6// scanTOTPFields must collapse such a group to a single icon on the first box7// and pass the full group so the selector fills every box at once.89import { vi } from "vitest";1011const { injectIcon } = vi.hoisted(() => ({12  injectIcon: vi.fn<(field: HTMLInputElement) => () => void>(() => vi.fn()),13}));1415vi.mock("./inject", () => ({16  injectFieldIcon: injectIcon,17  showAutofillSelector: vi.fn(() => vi.fn()),18  showTOTPSelector: vi.fn(() => vi.fn()),19  showCardSelector: vi.fn(() => vi.fn()),20  showRememberSiteToast: vi.fn(),21  showPasskeyAvailable: vi.fn(),22  showPasskeyProGate: vi.fn(),23  showPasskeyRegistrationOffer: vi.fn(),24}));25vi.mock("./content-ml-detector", () => ({26  contentMLDetector: { predictBatch: vi.fn(() => Promise.resolve([])) },27}));2829import { afterEach, beforeEach, describe, expect, it } from "vitest";30import "./_setup";31import { resetChromeMock, makeInput } from "./_setup";32import { scanTOTPFields } from "./index";3334beforeEach(() => {35  document.body.innerHTML = "";36  resetChromeMock();37  injectIcon.mockClear();38});3940afterEach(async () => {41  // Flush the module-load setTimeout(scanTOTPFields, 0/500/1500) calls so they42  // run against an empty DOM (cleared above) and never bleed into the next test.43  await new Promise((r) => setTimeout(r, 0));44});4546describe("split-OTP group icon injection (no maxlength)", () => {47  // The bug: six one-time-code boxes without maxlength="1" each classify as a48  // TOTP field, so the naive scan injects six icons. The fix resolves the whole49  // group to a single anchor (box[0]) and relies on the processedTOTPInputs50  // WeakMap to inject exactly once.51  it("six one-time-code boxes (no maxlength) → one icon on box[0]", () => {52    const f = document.createElement("form");53    document.body.appendChild(f);54    const boxes: HTMLInputElement[] = [];55    for (let i = 0; i < 6; i++) {56      boxes.push(57        makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f)58      );59    }6061    scanTOTPFields();6263    expect(injectIcon).toHaveBeenCalledTimes(1);64    expect(injectIcon.mock.calls[0]![0]).toBe(boxes[0]);65  });6667  // A second scan must not stack another icon — the WeakMap dedup guards68  // against duplicate icons on re-scan (the MutationObserver path).69  it("rescan is idempotent — still one icon", () => {70    const f = document.createElement("form");71    document.body.appendChild(f);72    const boxes: HTMLInputElement[] = [];73    for (let i = 0; i < 6; i++) {74      boxes.push(75        makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f)76      );77    }7879    scanTOTPFields();80    scanTOTPFields();8182    expect(injectIcon).toHaveBeenCalledTimes(1);83    expect(injectIcon.mock.calls[0]![0]).toBe(boxes[0]);84  });8586  // A lone one-time-code field (no sibling boxes) is not a group — it keeps its87  // own icon on itself, as before.88  it("a lone one-time-code field → one icon on itself", () => {89    const f = document.createElement("form");90    document.body.appendChild(f);91    const lone = makeInput(92      { type: "text", autocomplete: "one-time-code", name: "code" },93      f94    );9596    scanTOTPFields();9798    expect(injectIcon).toHaveBeenCalledTimes(1);99    expect(injectIcon.mock.calls[0]![0]).toBe(lone);100  });101});