// @vitest-environment jsdom // // Regression: split-OTP digit boxes that lack maxlength="1" but carry OTP // signaling (autocomplete="one-time-code", name="otp", …) used to each get // their own injected peach icon — the "icon spammed into every 2FA box" bug. // scanTOTPFields must collapse such a group to a single icon on the first box // and pass the full group so the selector fills every box at once. import { vi } from "vitest"; const { injectIcon } = vi.hoisted(() => ({ injectIcon: vi.fn<(field: HTMLInputElement) => () => void>(() => vi.fn()), })); vi.mock("./inject", () => ({ injectFieldIcon: injectIcon, showAutofillSelector: vi.fn(() => vi.fn()), showTOTPSelector: vi.fn(() => vi.fn()), showCardSelector: vi.fn(() => vi.fn()), showRememberSiteToast: vi.fn(), showPasskeyAvailable: vi.fn(), showPasskeyProGate: vi.fn(), showPasskeyRegistrationOffer: vi.fn(), })); vi.mock("./content-ml-detector", () => ({ contentMLDetector: { predictBatch: vi.fn(() => Promise.resolve([])) }, })); import { afterEach, beforeEach, describe, expect, it } from "vitest"; import "./_setup"; import { resetChromeMock, makeInput } from "./_setup"; import { scanTOTPFields } from "./index"; beforeEach(() => { document.body.innerHTML = ""; resetChromeMock(); injectIcon.mockClear(); }); afterEach(async () => { // Flush the module-load setTimeout(scanTOTPFields, 0/500/1500) calls so they // run against an empty DOM (cleared above) and never bleed into the next test. await new Promise((r) => setTimeout(r, 0)); }); describe("split-OTP group icon injection (no maxlength)", () => { // The bug: six one-time-code boxes without maxlength="1" each classify as a // TOTP field, so the naive scan injects six icons. The fix resolves the whole // group to a single anchor (box[0]) and relies on the processedTOTPInputs // WeakMap to inject exactly once. it("six one-time-code boxes (no maxlength) → one icon on box[0]", () => { const f = document.createElement("form"); document.body.appendChild(f); const boxes: HTMLInputElement[] = []; for (let i = 0; i < 6; i++) { boxes.push( makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f) ); } scanTOTPFields(); expect(injectIcon).toHaveBeenCalledTimes(1); expect(injectIcon.mock.calls[0]![0]).toBe(boxes[0]); }); // A second scan must not stack another icon — the WeakMap dedup guards // against duplicate icons on re-scan (the MutationObserver path). it("rescan is idempotent — still one icon", () => { const f = document.createElement("form"); document.body.appendChild(f); const boxes: HTMLInputElement[] = []; for (let i = 0; i < 6; i++) { boxes.push( makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f) ); } scanTOTPFields(); scanTOTPFields(); expect(injectIcon).toHaveBeenCalledTimes(1); expect(injectIcon.mock.calls[0]![0]).toBe(boxes[0]); }); // A lone one-time-code field (no sibling boxes) is not a group — it keeps its // own icon on itself, as before. it("a lone one-time-code field → one icon on itself", () => { const f = document.createElement("form"); document.body.appendChild(f); const lone = makeInput( { type: "text", autocomplete: "one-time-code", name: "code" }, f ); scanTOTPFields(); expect(injectIcon).toHaveBeenCalledTimes(1); expect(injectIcon.mock.calls[0]![0]).toBe(lone); }); });