content.detect.test.ts

20.9 KB · raw

1// @vitest-environment jsdom2//3// Test File 7 — detect.ts field detection (T-DT01–T-DT21).4// Plus Addendum 1 — split-OTP grouping.5//6// detect.ts is the foundation of the whole autofill layer: every gate in7// index.ts asks it "is this a login/signup/email-first/card/totp form?". A8// false positive injects an icon onto a search box; a false negative means the9// user's saved login never offers to fill. These tests pin the classification10// boundaries against real HTML structures that production sites emit.1112import { beforeEach, describe, expect, it } from "vitest";1314import {15  detectCardForm,16  detectLoginFields,17  findOTPDigitBoxGroup,18  findOtpSignalingGroup,19  isAnyCredentialForm,20  isEmailFirstForm,21  isLoginForm,22  isSignupForm,23  isTOTPField,24  isVisible,25} from "./detect";26import { makeFieldVisible, makeInput } from "./_setup";2728// detect.ts's querySelectorAllDeep re-queries every ancestor element, so a field29// nested under html>body>form appears 3× in detectLoginFields() output. The30// committed detect.test.ts copes the same way we do here: assert detection by31// *membership* (fields.some) and gate "is this page fillable?" through32// isAnyCredentialForm (the exact predicate index.ts uses — true via the signup33// branch even when duplication inflates the password count). The precise34// login-vs-signup classifier contract is exercised on clean, hand-built arrays.3536beforeEach(() => {37  document.body.innerHTML = "";38});3940// Build a form, append it, and return it. Children are added via makeInput so41// every field gets a non-zero bounding rect (jsdom defaults to 0×0, which42// detect.ts's isVisible() would reject).43function form(): HTMLFormElement {44  const f = document.createElement("form");45  document.body.appendChild(f);46  return f;47}4849describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => {50  // T-DT01: a text + current-password pair is the canonical login form. If this51  // regresses, saved logins stop offering on the most common form on the web.52  it("T-DT01: text + password → detected as a fillable login form", () => {53    const f = form();54    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);55    const p = makeInput({ type: "password", name: "password", autocomplete: "current-password" }, f);56    const fields = detectLoginFields();57    expect(fields.some((x) => x.el === u && x.type === "username")).toBe(true);58    expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);59    // The gate index.ts actually consults:60    expect(isAnyCredentialForm(fields)).toBe(true);61    // Classifier contract on a clean field set:62    expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);63  });6465  // T-DT02: two password fields (password + confirm) is the signature of signup.66  // Misclassifying it as login would offer the saved-password selector on an67  // account-creation form.68  it("T-DT02: text + new-password + confirm → isSignupForm true, isLoginForm false", () => {69    const f = form();70    makeInput({ type: "text", name: "username" }, f);71    makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);72    makeInput({ type: "password", name: "confirm", autocomplete: "new-password" }, f);73    const fields = detectLoginFields();74    expect(isSignupForm(fields)).toBe(true);75    expect(isLoginForm(fields)).toBe(false);76  });7778  // T-DT03: email-only step with an auth-intent submit button is the Google/MS79  // "enter your email first" pattern. Requires an auth signal so newsletter80  // boxes don't match.81  it("T-DT03: email-only form with auth submit → isEmailFirstForm true", () => {82    const f = form();83    makeInput({ type: "email", name: "email", autocomplete: "email" }, f);84    const btn = document.createElement("button");85    btn.type = "submit";86    btn.textContent = "Next";87    f.appendChild(btn);88    const fields = detectLoginFields();89    expect(isEmailFirstForm(fields)).toBe(true);90  });9192  // T-DT06: a search box (single text input, search submit) must NOT read as a93  // login form. False positive here is the classic "icon on the search bar" bug.94  it("T-DT06: search form → isLoginForm false", () => {95    const f = form();96    makeInput({ type: "search", name: "q", placeholder: "Search" }, f);97    const fields = detectLoginFields();98    expect(isLoginForm(fields)).toBe(false);99    expect(isSignupForm(fields)).toBe(false);100  });101});102103describe("T-DT04: TOTP field detection", () => {104  // T-DT04: autocomplete=one-time-code is the unambiguous TOTP marker. The TOTP105  // selector path keys off this; losing it breaks 2FA autofill.106  it("T-DT04: input[autocomplete=one-time-code] → isTotpField true", () => {107    const f = form();108    const otp = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);109    expect(isTOTPField(otp)).toBe(true);110  });111112  it("T-DT04b: a plain password field is not a TOTP field", () => {113    const f = form();114    const pw = makeInput({ type: "password", name: "password" }, f);115    expect(isTOTPField(pw)).toBe(false);116  });117});118119describe("T-DT05: credit-card form detection", () => {120  // T-DT05: cc-number + cc-exp + cc-csc autocomplete tokens → card form. detect121  // exposes detectCardForm (returns the field set or null), not a boolean.122  it("T-DT05: cardnumber + expiry + cvc → detectCardForm returns the field set", () => {123    const f = form();124    const number = makeInput({ autocomplete: "cc-number", name: "cardnumber" }, f);125    const exp = makeInput({ autocomplete: "cc-exp", name: "exp" }, f);126    const cvc = makeInput({ autocomplete: "cc-csc", name: "cvc" }, f);127    const card = detectCardForm();128    expect(card).not.toBeNull();129    expect(card!.number).toBe(number);130    expect(card!.expiryCombo).toBe(exp);131    expect(card!.cvc).toBe(cvc);132  });133134  it("T-DT05b: a login form is not a card form", () => {135    const f = form();136    makeInput({ type: "text", name: "username" }, f);137    makeInput({ type: "password", name: "password" }, f);138    expect(detectCardForm()).toBeNull();139  });140});141142describe("T-DT07–T-DT09: isVisible branches", () => {143  // isVisible gates every detection path. Each of its three rejection reasons144  // (zero area, display:none, opacity:0) must independently return false, or145  // off-screen/hidden honeypot fields would receive icons.146147  // T-DT07: a zero-area element (jsdom default, models an unrendered field).148  it("T-DT07: zero-size rect → isVisible false", () => {149    const el = makeInput({ type: "text" });150    Object.defineProperty(el, "getBoundingClientRect", {151      configurable: true,152      value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),153    });154    expect(isVisible(el)).toBe(false);155  });156157  // T-DT08: display:none.158  it("T-DT08: display:none → isVisible false", () => {159    const el = makeInput({ type: "text" });160    el.style.display = "none";161    expect(isVisible(el)).toBe(false);162  });163164  // T-DT09: opacity:0 (a common "hidden but laid out" honeypot technique).165  it("T-DT09: opacity:0 → isVisible false", () => {166    const el = makeInput({ type: "text" });167    el.style.opacity = "0";168    expect(isVisible(el)).toBe(false);169  });170171  it("T-DT09b: a normally-laid-out field → isVisible true (control)", () => {172    const el = makeInput({ type: "text" });173    expect(isVisible(el)).toBe(true);174  });175});176177describe("T-DT10: deep (shadow DOM) detection", () => {178  // T-DT10: web-component login forms render fields inside a shadow root.179  // detectLoginFields walks shadow roots via querySelectorAllDeep; a regression180  // to a flat querySelectorAll would miss every shadow-DOM login.181  it("T-DT10: login fields inside an open shadow root are detected", () => {182    const host = document.createElement("div");183    document.body.appendChild(host);184    const root = host.attachShadow({ mode: "open" });185    // Real web-component login forms wrap fields in a container element inside186    // the shadow root; querySelectorAllDeep collects inputs via each element's187    // querySelectorAll, so the wrapper is what surfaces them.188    const wrapper = document.createElement("div");189    root.appendChild(wrapper);190    const user = document.createElement("input");191    user.type = "text";192    user.setAttribute("name", "username");193    user.setAttribute("autocomplete", "username");194    const pass = document.createElement("input");195    pass.type = "password";196    pass.setAttribute("autocomplete", "current-password");197    wrapper.append(user, pass);198    makeFieldVisible(user);199    makeFieldVisible(pass);200201    const fields = detectLoginFields();202    expect(fields.some((f) => f.type === "password")).toBe(true);203    expect(isAnyCredentialForm(fields)).toBe(true);204  });205});206207describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => {208  // PCH-01-025: ids can contain CSS-special characters. detect.ts escapes them209  // with CSS.escape AND wraps the label lookup in try/catch so a single weird id210  // can never abort detection for the whole page.211  //212  // NOTE on the environment: jsdom's selector engine (nwsapi) actually THROWS on213  // CSS.escape's output for these ids, so the *label-resolution* benefit cannot214  // be observed here — it is verified in the Playwright e2e. What we pin in215  // jsdom is the resilience invariant: a field whose id is hostile to216  // querySelector must not throw out of detectLoginFields, and the other fields217  // on the page must still be found. Reverting the try/catch makes T-DT12b throw.218  it("T-DT11: id='user[email]' does not abort detection; the password is still found", () => {219    const f = form();220    makeInput({ type: "text", id: "user[email]", name: "user[email]" }, f);221    makeInput({ type: "password", autocomplete: "current-password" }, f);222    let fields: ReturnType<typeof detectLoginFields> = [];223    expect(() => { fields = detectLoginFields(); }).not.toThrow();224    expect(fields.some((x) => x.type === "password")).toBe(true);225  });226227  it("T-DT12: id=\"it's-me\" does not abort detection", () => {228    const f = form();229    makeInput({ type: "text", id: "it's-me", name: "identifier" }, f);230    makeInput({ type: "password", autocomplete: "current-password" }, f);231    expect(() => detectLoginFields()).not.toThrow();232  });233234  // T-DT12b: an id containing a double-quote genuinely throws inside a raw235  // `label[for="..."]` selector. Only the try/catch keeps detection alive — this236  // case fails the moment the catch is removed.237  it("T-DT12b: id with an embedded double-quote does not abort detection", () => {238    const f = form();239    makeInput({ type: "text", id: 'a"quote', name: "weird" }, f);240    makeInput({ type: "password", autocomplete: "current-password" }, f);241    let fields: ReturnType<typeof detectLoginFields> = [];242    expect(() => { fields = detectLoginFields(); }).not.toThrow();243    expect(fields.some((x) => x.type === "password")).toBe(true);244  });245});246247describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => {248  // T-DT13: a <label for=id> with a plain (escape-safe) id classifies an249  // otherwise-unhinted text input as the username field.250  it("T-DT13: label[for] resolves the field as username", () => {251    const f = form();252    const input = makeInput({ type: "text", id: "login_user" }, f);253    const label = document.createElement("label");254    label.setAttribute("for", "login_user");255    label.textContent = "Username";256    // Append AFTER the input so the previous-sibling heuristic cannot match —257    // forcing the for-attribute path to be the one that resolves the label.258    f.appendChild(label);259    makeInput({ type: "password", autocomplete: "current-password" }, f);260    const fields = detectLoginFields();261    expect(fields.find((x) => x.el === input)?.type).toBe("username");262  });263264  // T-DT14: placeholder text is a real-world classification signal.265  it("T-DT14: placeholder='Username' → classified username", () => {266    const f = form();267    const input = makeInput({ type: "text", placeholder: "Username" }, f);268    makeInput({ type: "password", autocomplete: "current-password" }, f);269    const fields = detectLoginFields();270    expect(fields.find((x) => x.el === input)?.type).toBe("username");271  });272273  // T-DT15: aria-label is authoritative for accessibility-first forms.274  it("T-DT15: aria-label='Email address' → classified", () => {275    const f = form();276    const input = makeInput({ type: "text", "aria-label": "Email address" }, f);277    makeInput({ type: "password", autocomplete: "current-password" }, f);278    const fields = detectLoginFields();279    expect(fields.find((x) => x.el === input)?.type).toBe("username");280  });281});282283describe("T-DT16–T-DT17: edge form shapes", () => {284  // T-DT16: many SPA login forms submit via JS and have no submit button. Field285  // classification must not depend on a button existing.286  it("T-DT16: form without a submit button is still a fillable login form", () => {287    const f = form();288    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);289    const p = makeInput({ type: "password", autocomplete: "current-password" }, f);290    const fields = detectLoginFields();291    expect(fields.some((x) => x.el === u)).toBe(true);292    expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);293    expect(isAnyCredentialForm(fields)).toBe(true);294    expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);295  });296297  // T-DT17: change-password forms have old + new + confirm. Three password298  // fields must read as signup-shaped (>=2 passwords), never as login.299  it("T-DT17: old + new + confirm passwords → signup-shaped, not login", () => {300    const f = form();301    makeInput({ type: "password", name: "old-password", autocomplete: "current-password" }, f);302    makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);303    makeInput({ type: "password", name: "confirm-password", autocomplete: "new-password" }, f);304    const fields = detectLoginFields();305    expect(isSignupForm(fields)).toBe(true);306    expect(isLoginForm(fields)).toBe(false);307  });308});309310// ── Addendum 1 — split-OTP grouping ─────────────────────────────────────────311// The existing detector is findOTPDigitBoxGroup: it returns the ordered set of312// single-character boxes (4–10 of them) or null. index.ts uses it to open ONE313// TOTP selector anchored on the first box instead of icon-spamming all six.314describe("T-DT18–T-DT21 (existing findOTPDigitBoxGroup behavior)", () => {315  // T-DT18: a 6-box numeric group resolves to the full ordered set, anchored so316  // index.ts injects a single icon on box[0] rather than one per box.317  it("T-DT18: 6× maxlength=1 numeric inputs → group of 6 returned, first is box[0]", () => {318    const f = form();319    const boxes: HTMLInputElement[] = [];320    for (let i = 0; i < 6; i++) boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f));321    const group = findOTPDigitBoxGroup(boxes[2]!);322    expect(group).not.toBeNull();323    expect(group!.length).toBe(6);324    expect(group![0]).toBe(boxes[0]);325  });326327  // T-DT19: when a real one-time-code field exists, IT is the TOTP target; the328  // single-char boxes elsewhere are a distinct concern. isTOTPField ignores329  // maxlength=1 boxes, so the proper field is the one that classifies as TOTP.330  it("T-DT19: a real one-time-code field classifies as TOTP; maxlength=1 boxes do not", () => {331    const f = form();332    const box = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);333    const real = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f);334    expect(isTOTPField(real)).toBe(true);335    expect(isTOTPField(box)).toBe(false);336  });337338  // T-DT21: a group below the 4-box floor is not an OTP group (existing339  // threshold). Addendum 1 proposes a 3+ threshold for isSplitOtpGroup — see the340  // skipped block below.341  it("T-DT21: only 2 single-char boxes → not an OTP group (null)", () => {342    const f = form();343    const boxes = [344      makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),345      makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),346    ];347    expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();348  });349});350351// ── Addendum 2 — OTP-signaling groups without maxlength="1" ──────────────────352// Some sites render split-digit 2FA boxes as plain inputs that carry OTP353// signaling (autocomplete="one-time-code", name="otp", …) but never set354// maxlength="1". isSingleDigitBox / findOTPDigitBoxGroup miss them, so without355// findOtpSignalingGroup each box would get its own injected icon (the "peach356// icon spam in every 2FA box" bug). The group is detected by OTP signaling +357// count, so a phone-form's numeric boxes (no OTP text) are never matched.358describe("T-DT22–T-DT24: findOtpSignalingGroup (split-OTP boxes lacking maxlength)", () => {359  // T-DT22: 6 one-time-code boxes without maxlength → a single group of 6,360  // ordered, anchored on box[0]. Each box still classifies as a TOTP field361  // (the property scanTOTPFields keys on), so without grouping every one of362  // them would be injected.363  it("T-DT22: 6 one-time-code boxes (no maxlength) → group of 6, anchor is box[0]", () => {364    const f = form();365    const boxes: HTMLInputElement[] = [];366    for (let i = 0; i < 6; i++) {367      boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));368    }369    expect(isTOTPField(boxes[3]!)).toBe(true);370    const group = findOtpSignalingGroup(boxes[3]!);371    expect(group).not.toBeNull();372    expect(group!.length).toBe(6);373    expect(group![0]).toBe(boxes[0]);374    expect(group![5]).toBe(boxes[5]);375  });376377  // T-DT23: a lone one-time-code field is NOT a group — it gets its own icon378  // via the normal lone-TOTP path. findOtpSignalingGroup returns null so the379  // caller injects on the field itself.380  it("T-DT23: a lone one-time-code field → null (not a group)", () => {381    const f = form();382    const lone = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);383    expect(findOtpSignalingGroup(lone)).toBeNull();384  });385386  // T-DT24: only 3 OTP-signaling boxes is below the 4-box floor → null. Avoids387  // grouping incidental otp-named fields that happen to coexist on a form.388  it("T-DT24: only 3 one-time-code boxes → null (below 4-box floor)", () => {389    const f = form();390    const boxes: HTMLInputElement[] = [];391    for (let i = 0; i < 3; i++) {392      boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));393    }394    expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();395  });396397  // T-DT25: a maxlength="1" box is rejected by isTOTPField, so it never enters398  // an OTP-signaling group — it is handled exclusively by findOTPDigitBoxGroup.399  // Confirms the two finders don't overlap and maxlength boxes keep their400  // existing (zero-icon + focus-open) behaviour.401  it("T-DT25: a maxlength=1 box is not part of an OTP-signaling group", () => {402    const f = form();403    const boxes: HTMLInputElement[] = [];404    for (let i = 0; i < 6; i++) {405      boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric", autocomplete: "one-time-code" }, f));406    }407    expect(isTOTPField(boxes[0]!)).toBe(false);408    expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();409    expect(findOTPDigitBoxGroup(boxes[0]!)).not.toBeNull();410  });411});412413// Addendum 1 PROPOSED API — isSplitOtpGroup(field) with a 3+ threshold and a414// numeric-only guard so an initials form (3 single-char *alpha* inputs) is not415// treated as OTP. This function does not exist yet; these tests activate the416// moment it is exported from detect.ts. They are the executable spec for the417// addendum. See INTEGRATION-CONTRACT.md.418import * as detectModule from "./detect";419const hasIsSplitOtpGroup =420  typeof (detectModule as unknown as { isSplitOtpGroup?: unknown }).isSplitOtpGroup === "function";421422describe.skipIf(!hasIsSplitOtpGroup)("Addendum 1 — proposed isSplitOtpGroup (requires detect.ts change)", () => {423  const isSplitOtpGroup = (detectModule as unknown as {424    isSplitOtpGroup: (f: HTMLInputElement) => boolean;425  }).isSplitOtpGroup;426427  it("T-DT18 (proposed): 6 adjacent numeric single-char inputs → true", () => {428    const f = form();429    let first: HTMLInputElement | null = null;430    for (let i = 0; i < 6; i++) {431      const b = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);432      first ??= b;433    }434    expect(isSplitOtpGroup(first!)).toBe(true);435  });436437  it("T-DT20 (proposed): 3 single-char ALPHA inputs (initials) → false", () => {438    const f = form();439    let first: HTMLInputElement | null = null;440    for (let i = 0; i < 3; i++) {441      const b = makeInput({ type: "text", maxlength: "1" }, f); // no numeric mode442      first ??= b;443    }444    expect(isSplitOtpGroup(first!)).toBe(false);445  });446447  it("T-DT21 (proposed): a 2-box group is below the 3+ threshold → false", () => {448    const f = form();449    const a = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);450    makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);451    expect(isSplitOtpGroup(a)).toBe(false);452  });453});