content.detect.test.ts

19.0 KB · raw

1// @vitest-environment jsdom2//3// Test File 7 — detect.ts field detection (T-DT01–T-DT21).4//5// detect.ts is the foundation of the whole autofill layer: every gate in6// index.ts asks it "is this a login/signup/email-first/card/totp form?". A7// false positive injects an icon onto a search box; a false negative means the8// user's saved login never offers to fill. These tests pin the classification9// boundaries against real HTML structures that production sites emit.1011import { beforeEach, describe, expect, it } from "vitest";1213import {14  detectCardForm,15  detectLoginFields,16  findOTPDigitBoxGroup,17  findOtpSignalingGroup,18  isAnyCredentialForm,19  isEmailFirstForm,20  isLoginForm,21  isSignupForm,22  isTOTPField,23  isVisible,24} from "./detect";25import { makeFieldVisible, makeInput } from "./_setup";2627// detect.ts's querySelectorAllDeep re-queries every ancestor element, so a field28// nested under html>body>form appears 3× in detectLoginFields() output. The29// committed detect.test.ts copes the same way we do here: assert detection by30// *membership* (fields.some) and gate "is this page fillable?" through31// isAnyCredentialForm (the exact predicate index.ts uses — true via the signup32// branch even when duplication inflates the password count). The precise33// login-vs-signup classifier contract is exercised on clean, hand-built arrays.3435beforeEach(() => {36  document.body.innerHTML = "";37});3839// Build a form, append it, and return it. Children are added via makeInput so40// every field gets a non-zero bounding rect (jsdom defaults to 0×0, which41// detect.ts's isVisible() would reject).42function form(): HTMLFormElement {43  const f = document.createElement("form");44  document.body.appendChild(f);45  return f;46}4748describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => {49  // T-DT01: a text + current-password pair is the canonical login form. If this50  // regresses, saved logins stop offering on the most common form on the web.51  it("T-DT01: text + password → detected as a fillable login form", () => {52    const f = form();53    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);54    const p = makeInput({ type: "password", name: "password", autocomplete: "current-password" }, f);55    const fields = detectLoginFields();56    expect(fields.some((x) => x.el === u && x.type === "username")).toBe(true);57    expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);58    // The gate index.ts actually consults:59    expect(isAnyCredentialForm(fields)).toBe(true);60    // Classifier contract on a clean field set:61    expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);62  });6364  // T-DT02: two password fields (password + confirm) is the signature of signup.65  // Misclassifying it as login would offer the saved-password selector on an66  // account-creation form.67  it("T-DT02: text + new-password + confirm → isSignupForm true, isLoginForm false", () => {68    const f = form();69    makeInput({ type: "text", name: "username" }, f);70    makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);71    makeInput({ type: "password", name: "confirm", autocomplete: "new-password" }, f);72    const fields = detectLoginFields();73    expect(isSignupForm(fields)).toBe(true);74    expect(isLoginForm(fields)).toBe(false);75  });7677  // T-DT03: email-only step with an auth-intent submit button is the Google/MS78  // "enter your email first" pattern. Requires an auth signal so newsletter79  // boxes don't match.80  it("T-DT03: email-only form with auth submit → isEmailFirstForm true", () => {81    const f = form();82    makeInput({ type: "email", name: "email", autocomplete: "email" }, f);83    const btn = document.createElement("button");84    btn.type = "submit";85    btn.textContent = "Next";86    f.appendChild(btn);87    const fields = detectLoginFields();88    expect(isEmailFirstForm(fields)).toBe(true);89  });9091  // T-DT06: a search box (single text input, search submit) must NOT read as a92  // login form. False positive here is the classic "icon on the search bar" bug.93  it("T-DT06: search form → isLoginForm false", () => {94    const f = form();95    makeInput({ type: "search", name: "q", placeholder: "Search" }, f);96    const fields = detectLoginFields();97    expect(isLoginForm(fields)).toBe(false);98    expect(isSignupForm(fields)).toBe(false);99  });100});101102describe("T-DT04: TOTP field detection", () => {103  // T-DT04: autocomplete=one-time-code is the unambiguous TOTP marker. The TOTP104  // selector path keys off this; losing it breaks 2FA autofill.105  it("T-DT04: input[autocomplete=one-time-code] → isTotpField true", () => {106    const f = form();107    const otp = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);108    expect(isTOTPField(otp)).toBe(true);109  });110111  it("T-DT04b: a plain password field is not a TOTP field", () => {112    const f = form();113    const pw = makeInput({ type: "password", name: "password" }, f);114    expect(isTOTPField(pw)).toBe(false);115  });116});117118describe("T-DT05: credit-card form detection", () => {119  // T-DT05: cc-number + cc-exp + cc-csc autocomplete tokens → card form. detect120  // exposes detectCardForm (returns the field set or null), not a boolean.121  it("T-DT05: cardnumber + expiry + cvc → detectCardForm returns the field set", () => {122    const f = form();123    const number = makeInput({ autocomplete: "cc-number", name: "cardnumber" }, f);124    const exp = makeInput({ autocomplete: "cc-exp", name: "exp" }, f);125    const cvc = makeInput({ autocomplete: "cc-csc", name: "cvc" }, f);126    const card = detectCardForm();127    expect(card).not.toBeNull();128    expect(card!.number).toBe(number);129    expect(card!.expiryCombo).toBe(exp);130    expect(card!.cvc).toBe(cvc);131  });132133  it("T-DT05b: a login form is not a card form", () => {134    const f = form();135    makeInput({ type: "text", name: "username" }, f);136    makeInput({ type: "password", name: "password" }, f);137    expect(detectCardForm()).toBeNull();138  });139});140141describe("T-DT07–T-DT09: isVisible branches", () => {142  // isVisible gates every detection path. Each of its three rejection reasons143  // (zero area, display:none, opacity:0) must independently return false, or144  // off-screen/hidden honeypot fields would receive icons.145146  // T-DT07: a zero-area element (jsdom default, models an unrendered field).147  it("T-DT07: zero-size rect → isVisible false", () => {148    const el = makeInput({ type: "text" });149    Object.defineProperty(el, "getBoundingClientRect", {150      configurable: true,151      value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),152    });153    expect(isVisible(el)).toBe(false);154  });155156  // T-DT08: display:none.157  it("T-DT08: display:none → isVisible false", () => {158    const el = makeInput({ type: "text" });159    el.style.display = "none";160    expect(isVisible(el)).toBe(false);161  });162163  // T-DT09: opacity:0 (a common "hidden but laid out" honeypot technique).164  it("T-DT09: opacity:0 → isVisible false", () => {165    const el = makeInput({ type: "text" });166    el.style.opacity = "0";167    expect(isVisible(el)).toBe(false);168  });169170  it("T-DT09b: a normally-laid-out field → isVisible true (control)", () => {171    const el = makeInput({ type: "text" });172    expect(isVisible(el)).toBe(true);173  });174});175176describe("T-DT10: deep (shadow DOM) detection", () => {177  // T-DT10: web-component login forms render fields inside a shadow root.178  // detectLoginFields walks shadow roots via querySelectorAllDeep; a regression179  // to a flat querySelectorAll would miss every shadow-DOM login.180  it("T-DT10: login fields inside an open shadow root are detected", () => {181    const host = document.createElement("div");182    document.body.appendChild(host);183    const root = host.attachShadow({ mode: "open" });184    // Real web-component login forms wrap fields in a container element inside185    // the shadow root; querySelectorAllDeep collects inputs via each element's186    // querySelectorAll, so the wrapper is what surfaces them.187    const wrapper = document.createElement("div");188    root.appendChild(wrapper);189    const user = document.createElement("input");190    user.type = "text";191    user.setAttribute("name", "username");192    user.setAttribute("autocomplete", "username");193    const pass = document.createElement("input");194    pass.type = "password";195    pass.setAttribute("autocomplete", "current-password");196    wrapper.append(user, pass);197    makeFieldVisible(user);198    makeFieldVisible(pass);199200    const fields = detectLoginFields();201    expect(fields.some((f) => f.type === "password")).toBe(true);202    expect(isAnyCredentialForm(fields)).toBe(true);203  });204});205206describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => {207  // PCH-01-025: ids can contain CSS-special characters. detect.ts escapes them208  // with CSS.escape AND wraps the label lookup in try/catch so a single weird id209  // can never abort detection for the whole page.210  //211  // NOTE on the environment: jsdom's selector engine (nwsapi) actually THROWS on212  // CSS.escape's output for these ids, so the *label-resolution* benefit cannot213  // be observed here — it is verified in the Playwright e2e. What we pin in214  // jsdom is the resilience invariant: a field whose id is hostile to215  // querySelector must not throw out of detectLoginFields, and the other fields216  // on the page must still be found. Reverting the try/catch makes T-DT12b throw.217  it("T-DT11: id='user[email]' does not abort detection; the password is still found", () => {218    const f = form();219    makeInput({ type: "text", id: "user[email]", name: "user[email]" }, f);220    makeInput({ type: "password", autocomplete: "current-password" }, f);221    let fields: ReturnType<typeof detectLoginFields> = [];222    expect(() => { fields = detectLoginFields(); }).not.toThrow();223    expect(fields.some((x) => x.type === "password")).toBe(true);224  });225226  it("T-DT12: id=\"it's-me\" does not abort detection", () => {227    const f = form();228    makeInput({ type: "text", id: "it's-me", name: "identifier" }, f);229    makeInput({ type: "password", autocomplete: "current-password" }, f);230    expect(() => detectLoginFields()).not.toThrow();231  });232233  // T-DT12b: an id containing a double-quote genuinely throws inside a raw234  // `label[for="..."]` selector. Only the try/catch keeps detection alive — this235  // case fails the moment the catch is removed.236  it("T-DT12b: id with an embedded double-quote does not abort detection", () => {237    const f = form();238    makeInput({ type: "text", id: 'a"quote', name: "weird" }, f);239    makeInput({ type: "password", autocomplete: "current-password" }, f);240    let fields: ReturnType<typeof detectLoginFields> = [];241    expect(() => { fields = detectLoginFields(); }).not.toThrow();242    expect(fields.some((x) => x.type === "password")).toBe(true);243  });244});245246describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => {247  // T-DT13: a <label for=id> with a plain (escape-safe) id classifies an248  // otherwise-unhinted text input as the username field.249  it("T-DT13: label[for] resolves the field as username", () => {250    const f = form();251    const input = makeInput({ type: "text", id: "login_user" }, f);252    const label = document.createElement("label");253    label.setAttribute("for", "login_user");254    label.textContent = "Username";255    // Append AFTER the input so the previous-sibling heuristic cannot match —256    // forcing the for-attribute path to be the one that resolves the label.257    f.appendChild(label);258    makeInput({ type: "password", autocomplete: "current-password" }, f);259    const fields = detectLoginFields();260    expect(fields.find((x) => x.el === input)?.type).toBe("username");261  });262263  // T-DT14: placeholder text is a real-world classification signal.264  it("T-DT14: placeholder='Username' → classified username", () => {265    const f = form();266    const input = makeInput({ type: "text", placeholder: "Username" }, f);267    makeInput({ type: "password", autocomplete: "current-password" }, f);268    const fields = detectLoginFields();269    expect(fields.find((x) => x.el === input)?.type).toBe("username");270  });271272  // T-DT15: aria-label is authoritative for accessibility-first forms.273  it("T-DT15: aria-label='Email address' → classified", () => {274    const f = form();275    const input = makeInput({ type: "text", "aria-label": "Email address" }, f);276    makeInput({ type: "password", autocomplete: "current-password" }, f);277    const fields = detectLoginFields();278    expect(fields.find((x) => x.el === input)?.type).toBe("username");279  });280});281282describe("T-DT16–T-DT17: edge form shapes", () => {283  // T-DT16: many SPA login forms submit via JS and have no submit button. Field284  // classification must not depend on a button existing.285  it("T-DT16: form without a submit button is still a fillable login form", () => {286    const f = form();287    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);288    const p = makeInput({ type: "password", autocomplete: "current-password" }, f);289    const fields = detectLoginFields();290    expect(fields.some((x) => x.el === u)).toBe(true);291    expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);292    expect(isAnyCredentialForm(fields)).toBe(true);293    expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);294  });295296  // T-DT17: change-password forms have old + new + confirm. Three password297  // fields must read as signup-shaped (>=2 passwords), never as login.298  it("T-DT17: old + new + confirm passwords → signup-shaped, not login", () => {299    const f = form();300    makeInput({ type: "password", name: "old-password", autocomplete: "current-password" }, f);301    makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);302    makeInput({ type: "password", name: "confirm-password", autocomplete: "new-password" }, f);303    const fields = detectLoginFields();304    expect(isSignupForm(fields)).toBe(true);305    expect(isLoginForm(fields)).toBe(false);306  });307});308309// ── Split-OTP grouping ──────────────────────────────────────────────────────310// The existing detector is findOTPDigitBoxGroup: it returns the ordered set of311// single-character boxes (4–10 of them) or null. index.ts uses it to open ONE312// TOTP selector anchored on the first box instead of icon-spamming all six.313describe("T-DT18–T-DT21 (existing findOTPDigitBoxGroup behavior)", () => {314  // T-DT18: a 6-box numeric group resolves to the full ordered set, anchored so315  // index.ts injects a single icon on box[0] rather than one per box.316  it("T-DT18: 6× maxlength=1 numeric inputs → group of 6 returned, first is box[0]", () => {317    const f = form();318    const boxes: HTMLInputElement[] = [];319    for (let i = 0; i < 6; i++) boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f));320    const group = findOTPDigitBoxGroup(boxes[2]!);321    expect(group).not.toBeNull();322    expect(group!.length).toBe(6);323    expect(group![0]).toBe(boxes[0]);324  });325326  // T-DT19: when a real one-time-code field exists, IT is the TOTP target; the327  // single-char boxes elsewhere are a distinct concern. isTOTPField ignores328  // maxlength=1 boxes, so the proper field is the one that classifies as TOTP.329  it("T-DT19: a real one-time-code field classifies as TOTP; maxlength=1 boxes do not", () => {330    const f = form();331    const box = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);332    const real = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f);333    expect(isTOTPField(real)).toBe(true);334    expect(isTOTPField(box)).toBe(false);335  });336337  // T-DT21: a group below the 4-box floor is not an OTP group.338  it("T-DT21: only 2 single-char boxes → not an OTP group (null)", () => {339    const f = form();340    const boxes = [341      makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),342      makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),343    ];344    expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();345  });346});347348// ── Addendum 2 — OTP-signaling groups without maxlength="1" ──────────────────349// Some sites render split-digit 2FA boxes as plain inputs that carry OTP350// signaling (autocomplete="one-time-code", name="otp", …) but never set351// maxlength="1". isSingleDigitBox / findOTPDigitBoxGroup miss them, so without352// findOtpSignalingGroup each box would get its own injected icon (the "peach353// icon spam in every 2FA box" bug). The group is detected by OTP signaling +354// count, so a phone-form's numeric boxes (no OTP text) are never matched.355describe("T-DT22–T-DT24: findOtpSignalingGroup (split-OTP boxes lacking maxlength)", () => {356  // T-DT22: 6 one-time-code boxes without maxlength → a single group of 6,357  // ordered, anchored on box[0]. Each box still classifies as a TOTP field358  // (the property scanTOTPFields keys on), so without grouping every one of359  // them would be injected.360  it("T-DT22: 6 one-time-code boxes (no maxlength) → group of 6, anchor is box[0]", () => {361    const f = form();362    const boxes: HTMLInputElement[] = [];363    for (let i = 0; i < 6; i++) {364      boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));365    }366    expect(isTOTPField(boxes[3]!)).toBe(true);367    const group = findOtpSignalingGroup(boxes[3]!);368    expect(group).not.toBeNull();369    expect(group!.length).toBe(6);370    expect(group![0]).toBe(boxes[0]);371    expect(group![5]).toBe(boxes[5]);372  });373374  // T-DT23: a lone one-time-code field is NOT a group — it gets its own icon375  // via the normal lone-TOTP path. findOtpSignalingGroup returns null so the376  // caller injects on the field itself.377  it("T-DT23: a lone one-time-code field → null (not a group)", () => {378    const f = form();379    const lone = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);380    expect(findOtpSignalingGroup(lone)).toBeNull();381  });382383  // T-DT24: only 3 OTP-signaling boxes is below the 4-box floor → null. Avoids384  // grouping incidental otp-named fields that happen to coexist on a form.385  it("T-DT24: only 3 one-time-code boxes → null (below 4-box floor)", () => {386    const f = form();387    const boxes: HTMLInputElement[] = [];388    for (let i = 0; i < 3; i++) {389      boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));390    }391    expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();392  });393394  // T-DT25: a maxlength="1" box is rejected by isTOTPField, so it never enters395  // an OTP-signaling group — it is handled exclusively by findOTPDigitBoxGroup.396  // Confirms the two finders don't overlap and maxlength boxes keep their397  // existing (zero-icon + focus-open) behaviour.398  it("T-DT25: a maxlength=1 box is not part of an OTP-signaling group", () => {399    const f = form();400    const boxes: HTMLInputElement[] = [];401    for (let i = 0; i < 6; i++) {402      boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric", autocomplete: "one-time-code" }, f));403    }404    expect(isTOTPField(boxes[0]!)).toBe(false);405    expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();406    expect(findOTPDigitBoxGroup(boxes[0]!)).not.toBeNull();407  });408});