content.fill.test.ts

8.8 KB · raw

1// @vitest-environment jsdom2//3// Test File 8 — fill.ts credential injection (T-FL01–T-FL12).4//5// fill.ts is where credentials actually enter the page. The subtle part is6// setNativeValue: React/Vue track input values through the native value setter,7// not direct `.value =` assignment, so fill.ts pulls the prototype setter and8// dispatches real input+change events. If that breaks, fills look successful to9// Peach but the site's controlled-input state stays empty — the canonical10// "autofill did nothing" bug. These tests use real DOM elements, never mocks.1112import { beforeEach, describe, expect, it, vi } from "vitest";1314import {15  detectCardNetwork,16  extractLast4,17  fillCardForm,18  fillFields,19  fillTOTPCode,20  formatCardNumber,21  passesLuhn,22  setNativeValue,23} from "./fill";24import { makeInput } from "./_setup";2526beforeEach(() => {27  document.body.innerHTML = "";28});2930describe("T-FL01–T-FL03: fillFields + event dispatch", () => {31  // T-FL01: the core contract — username and password land in their inputs.32  it("T-FL01: fills username and password into standard inputs", () => {33    const u = makeInput({ type: "text" });34    const p = makeInput({ type: "password" });35    fillFields(u, p, "alice", "s3cret");36    expect(u.value).toBe("alice");37    expect(p.value).toBe("s3cret");38  });3940  // T-FL02: an input event must fire so reactive frameworks re-read the field.41  it("T-FL02: dispatches an input event on filled fields", () => {42    const p = makeInput({ type: "password" });43    const onInput = vi.fn();44    p.addEventListener("input", onInput);45    fillFields(null, p, "", "pw");46    expect(onInput).toHaveBeenCalledTimes(1);47  });4849  // T-FL03: a change event must fire too (some frameworks/validators key on it).50  it("T-FL03: dispatches a change event on filled fields", () => {51    const p = makeInput({ type: "password" });52    const onChange = vi.fn();53    p.addEventListener("change", onChange);54    fillFields(null, p, "", "pw");55    expect(onChange).toHaveBeenCalledTimes(1);56  });57});5859describe("T-FL04–T-FL05: framework-controlled inputs (setNativeValue)", () => {60  // T-FL04: React installs its own value setter on the *instance*; setNativeValue61  // must call the *prototype* setter so React's value tracker observes the change.62  // We emulate React's tracker by shadowing the instance value descriptor and63  // asserting the prototype setter still updated the underlying value AND that64  // the synthetic input event carried the new value.65  it("T-FL04: setNativeValue bypasses a React-style instance setter via the prototype setter", () => {66    const el = makeInput({ type: "text" });67    let trackerSawValue: string | null = null;68    el.addEventListener("input", () => { trackerSawValue = el.value; });6970    // React installs an own `value` accessor on the element whose setter71    // swallows direct `.value =` assignment (its value-tracker). setNativeValue72    // must reach the *prototype* setter to update the real value slot. The73    // getter here delegates to the prototype so reads still work.74    const protoDesc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!;75    let instanceSetterCalled = false;76    Object.defineProperty(el, "value", {77      configurable: true,78      get() { return protoDesc.get!.call(el); },79      set() { instanceSetterCalled = true; /* React swallows direct assignment */ },80    });8182    setNativeValue(el, "from-peach");8384    expect(instanceSetterCalled).toBe(false); // went around the instance setter85    expect(el.value).toBe("from-peach");       // prototype setter wrote the slot86    expect(trackerSawValue).toBe("from-peach"); // event fired AFTER the value was set87  });8889  // T-FL05: Vue (and any framework) relies on the same input/change emission.90  it("T-FL05: setNativeValue emits input then change for Vue-style v-model", () => {91    const el = makeInput({ type: "text" });92    const seq: string[] = [];93    el.addEventListener("input", () => seq.push("input"));94    el.addEventListener("change", () => seq.push("change"));95    setNativeValue(el, "x");96    expect(seq).toEqual(["input", "change"]);97  });98});99100describe("T-FL06: cross-origin / wrong-domain guard", () => {101  // T-FL06: a true cross-origin iframe can't be scripted from jsdom, but the102  // same defensive path fires when a form's action points at a different103  // registrable domain (phished/relayed form). fillFields must refuse to fill104  // and must not throw.105  it("T-FL06: form action on a foreign domain → fields are not filled, no crash", () => {106    const formEl = document.createElement("form");107    formEl.action = "https://evil.example.org/collect";108    document.body.appendChild(formEl);109    const u = makeInput({ type: "text" }, formEl);110    const p = makeInput({ type: "password" }, formEl);111    expect(() =>112      fillFields(u, p, "alice", "s3cret", formEl, "mybank.example.com")113    ).not.toThrow();114    expect(u.value).toBe("");115    expect(p.value).toBe("");116  });117});118119describe("T-FL07–T-FL09: card fill + validation", () => {120  // T-FL07: every card field that exists gets its value.121  it("T-FL07: fills number, expiry combo, cvc, and cardholder name", () => {122    const number = makeInput({ autocomplete: "cc-number" });123    const expiryCombo = makeInput({ autocomplete: "cc-exp" });124    const cvc = makeInput({ autocomplete: "cc-csc" });125    const name = makeInput({ autocomplete: "cc-name" });126    fillCardForm(127      { number, expMonth: null, expYear: null, expiryCombo, cvc, name },128      { cardNumber: "4111111111111111", expMonth: "08", expYear: "2027", cvc: "123", cardholderName: "Alice Wong" }129    );130    expect(number.value).toBe("4111 1111 1111 1111"); // formatted in 4-digit groups131    expect(expiryCombo.value).toBe("08/27");132    expect(cvc.value).toBe("123");133    expect(name.value).toBe("Alice Wong");134  });135136  // T-FL08: Luhn check — guards against a fill writing a transposed/typo'd PAN.137  it("T-FL08: passesLuhn accepts valid PANs and rejects invalid ones", () => {138    expect(passesLuhn("4111111111111111")).toBe(true); // canonical Visa test number139    expect(passesLuhn("4111 1111 1111 1111")).toBe(true); // whitespace tolerated140    expect(passesLuhn("4111111111111112")).toBe(false); // last digit wrong141  });142143  // T-FL09: network detection from the leading digits drives the card icon.144  it("T-FL09: detectCardNetwork keys off the issuer prefix", () => {145    expect(detectCardNetwork("4111111111111111")).toBe("visa");146    expect(detectCardNetwork("5500005555555559")).toBe("mastercard");147    expect(detectCardNetwork("340000000000009")).toBe("amex");148    expect(detectCardNetwork("6011000000000004")).toBe("discover");149    expect(detectCardNetwork("9999999999999999")).toBe("unknown");150  });151152  it("T-FL09b: amex (15-digit) numbers format in 4-6-5 groups", () => {153    expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009");154    expect(extractLast4("3400 000000 00009")).toBe("0009");155  });156});157158describe("T-FL10–T-FL12: TOTP fill, preservation, and the return contract", () => {159  // T-FL10: TOTP code goes into an empty one-time-code field.160  it("T-FL10: fillTOTPCode inserts the code into an empty visible field", () => {161    const totp = makeInput({ type: "text", autocomplete: "one-time-code" });162    expect(fillTOTPCode(totp, "123456")).toBe(true);163    expect(totp.value).toBe("123456");164  });165166  it("T-FL10b: fillTOTPCode refuses to overwrite a non-empty field", () => {167    const totp = makeInput({ type: "text", autocomplete: "one-time-code" });168    setNativeValue(totp, "000000");169    expect(fillTOTPCode(totp, "123456")).toBe(false);170    expect(totp.value).toBe("000000");171  });172173  // T-FL11: only the targeted fields are mutated — a stray "remember me" or174  // search box on the same page keeps its value.175  it("T-FL11: fields not passed to fillFields are left untouched", () => {176    const u = makeInput({ type: "text" });177    const p = makeInput({ type: "password" });178    const other = makeInput({ type: "text" });179    setNativeValue(other, "do-not-touch");180    fillFields(u, p, "alice", "s3cret");181    expect(other.value).toBe("do-not-touch");182  });183184  // T-FL12: the brief assumes fillFields returns the filled set; the real API185  // returns void and the caller (index.ts recordFillSuccess) tracks success by186  // the elements it passed. We pin the actual contract: fillFields returns187  // undefined, and an invisible password is intentionally skipped — which is how188  // a caller can tell a fill was partial. (See INTEGRATION-CONTRACT.md.)189  it("T-FL12: fillFields returns void and skips an invisible password field", () => {190    const u = makeInput({ type: "text" });191    const p = makeInput({ type: "password" });192    p.style.display = "none"; // invisible → isVisible() false → not filled193    const result = fillFields(u, p, "alice", "s3cret");194    expect(result).toBeUndefined();195    expect(u.value).toBe("alice");196    expect(p.value).toBe(""); // skipped because not visible197  });198});