detect.test.ts

7.5 KB · raw

1// @vitest-environment jsdom23import { beforeEach, describe, expect, it } from "vitest";45import {6  detectCardForm,7  detectLoginFields,8  getPageDomain,9  hasPaymentProcessorIframe,10  isLoginForm,11  isSignupForm,12  isTOTPField,13} from "./detect";1415describe("content detect", () => {16  beforeEach(() => {17    document.body.innerHTML = "";18  });1920  function visibleInput(21    attributes: Record<string, string> = {},22    tagName: "input" = "input"23  ): HTMLInputElement {24    const input = document.createElement(tagName);25    Object.entries(attributes).forEach(([key, value]) => input.setAttribute(key, value));26    Object.defineProperty(input, "getBoundingClientRect", {27      configurable: true,28      value: () => ({29        width: 100,30        height: 20,31        top: 0,32        left: 0,33        right: 100,34        bottom: 20,35        x: 0,36        y: 0,37        toJSON: () => ({}),38      }),39    });40    document.body.appendChild(input);41    return input as HTMLInputElement;42  }4344  it("isLoginForm returns true for a username/password pair", () => {45    const username = visibleInput({ type: "email", name: "email" });46    const password = visibleInput({ type: "password", autocomplete: "current-password" });4748    expect(49      isLoginForm([50        { type: "username", el: username },51        { type: "password", el: password },52      ])53    ).toBe(true);54  });5556  it("isLoginForm returns false for fields without a password", () => {57    const username = visibleInput({ type: "text", name: "username" });5859    expect(isLoginForm([{ type: "username", el: username }])).toBe(false);60  });6162  it("isSignupForm returns true for confirm-password flows", () => {63    const password = visibleInput({ type: "password", name: "password" });64    const confirm = visibleInput({ type: "password", name: "confirmPassword" });6566    expect(67      isSignupForm([68        { type: "password", el: password },69        { type: "password", el: confirm },70      ])71    ).toBe(true);72  });7374  it("isSignupForm returns false for a single current-password field", () => {75    const password = visibleInput({ type: "password", autocomplete: "current-password" });7677    expect(isSignupForm([{ type: "password", el: password }])).toBe(false);78  });7980  it("detectLoginFields matches email and username-style inputs but not tag-name fields", () => {81    const email = visibleInput({ type: "email", name: "email" });82    const username = visibleInput({ type: "text", name: "login-id" });83    const tagName = visibleInput({ type: "text", name: "tag-name" });84    const password = visibleInput({ type: "password", autocomplete: "current-password" });85    const hidden = visibleInput({ type: "hidden", name: "username" });8687    const fields = detectLoginFields(document);8889    expect(fields.some((field) => field.el === email && field.type === "email")).toBe(true);90    expect(fields.some((field) => field.el === username && field.type === "username")).toBe(true);91    expect(fields.some((field) => field.el === password && field.type === "password")).toBe(true);92    expect(fields.some((field) => field.el === tagName)).toBe(false);93    expect(fields.some((field) => field.el === hidden)).toBe(false);94  });9596  it("getPageDomain falls back to the current hostname when no registrable domain exists", () => {97    expect(getPageDomain()).toBe(window.location.hostname);98  });99100  it("isTOTPField detects one-time-code fields without matching generic code words", () => {101    const oneTimeCode = visibleInput({ autocomplete: "one-time-code" });102    const otpName = visibleInput({ name: "otp" });103    const verification = visibleInput({ placeholder: "Verification code" });104    const zipCode = visibleInput({ name: "zipcode" });105106    expect(isTOTPField(oneTimeCode)).toBe(true);107    expect(isTOTPField(otpName)).toBe(true);108    expect(isTOTPField(verification)).toBe(true);109    expect(isTOTPField(zipCode)).toBe(false);110  });111112  it("isTOTPField detects an adjacent 2FA label", () => {113    const label = document.createElement("label");114    label.textContent = "2FA Code";115    document.body.appendChild(label);116    const input = visibleInput({ placeholder: "If enabled" });117118    expect(isTOTPField(input)).toBe(true);119  });120121  // Regression: numeric 2FA boxes are usually type="number", where the maxlength122  // ATTRIBUTE is set to 1 but the .maxLength PROPERTY ignores it and reports a123  // large default. Detection must key on the attribute so each digit box is124  // recognised as a split-OTP box, not a full TOTP field getting its own icon.125  it("isTOTPField ignores type=number split-OTP digit boxes", () => {126    for (let i = 0; i < 6; i++) {127      const box = visibleInput({ type: "number", maxlength: "1", name: "otp" });128      expect(isTOTPField(box)).toBe(false);129    }130  });131132  it("detectLoginFields skips type=number split-OTP digit boxes", () => {133    for (let i = 0; i < 6; i++) {134      visibleInput({ type: "number", maxlength: "1", name: "otp" });135    }136    expect(detectLoginFields(document)).toEqual([]);137  });138139  // PCH-01-025: ids may legally contain CSS-special chars; field detection140  // must not throw a SyntaxError out of the label[for=...] lookup.141  it("field_detection_does_not_throw_on_id_with_css_special_chars", () => {142    const label = document.createElement("label");143    label.setAttribute("for", "user[email]");144    label.textContent = "Email";145    document.body.appendChild(label);146    const email = visibleInput({ type: "email", name: "email", id: "user[email]" });147    visibleInput({ type: "password", autocomplete: "current-password" });148149    let fields: ReturnType<typeof detectLoginFields> = [];150    expect(() => {151      fields = detectLoginFields(document);152    }).not.toThrow();153    expect(fields.some((field) => field.el === email)).toBe(true);154  });155156  it("field_detection_does_not_throw_on_id_with_quotes", () => {157    const email = visibleInput({ type: "email", name: "email", id: "it's-me" });158    visibleInput({ type: "password", autocomplete: "current-password" });159160    let fields: ReturnType<typeof detectLoginFields> = [];161    expect(() => {162      fields = detectLoginFields(document);163    }).not.toThrow();164    expect(fields.some((field) => field.el === email)).toBe(true);165  });166});167168describe("card form detection", () => {169  beforeEach(() => {170    document.body.innerHTML = "";171  });172173  it("detectCardForm — finds number field by autocomplete attribute", () => {174    document.body.innerHTML = '<input autocomplete="cc-number" /><input autocomplete="cc-exp-month" /><input autocomplete="cc-exp-year" /><input autocomplete="cc-csc" />';175    const result = detectCardForm(document);176    expect(result).not.toBeNull();177    expect(result!.number).toBeTruthy();178    expect(result!.expMonth).toBeTruthy();179  });180181  it("detectCardForm — finds fields by name/id heuristics when no autocomplete", () => {182    document.body.innerHTML = '<input name="cardnum" /><input name="expiry" /><input name="cvv" />';183    const result = detectCardForm(document);184    expect(result).not.toBeNull();185    expect(result!.number).toBeTruthy();186  });187188  it("detectCardForm — returns null when no card number field found", () => {189    document.body.innerHTML = '<input type="text" name="username" />';190    expect(detectCardForm(document)).toBeNull();191  });192193  it("hasPaymentProcessorIframe — returns true when Stripe iframe present", () => {194    document.body.innerHTML = '<iframe src="https://js.stripe.com/v3/"></iframe>';195    expect(hasPaymentProcessorIframe()).toBe(true);196  });197198  it("hasPaymentProcessorIframe — returns false on standard checkout page", () => {199    document.body.innerHTML = '<input name="cardnum" />';200    expect(hasPaymentProcessorIframe()).toBe(false);201  });202});