// @vitest-environment jsdom // // Test File 8 — fill.ts credential injection (T-FL01–T-FL12). // // fill.ts is where credentials actually enter the page. The subtle part is // setNativeValue: React/Vue track input values through the native value setter, // not direct `.value =` assignment, so fill.ts pulls the prototype setter and // dispatches real input+change events. If that breaks, fills look successful to // Peach but the site's controlled-input state stays empty — the canonical // "autofill did nothing" bug. These tests use real DOM elements, never mocks. import { beforeEach, describe, expect, it, vi } from "vitest"; import { detectCardNetwork, extractLast4, fillCardForm, fillFields, fillTOTPCode, formatCardNumber, passesLuhn, setNativeValue, } from "./fill"; import { makeInput } from "./_setup"; beforeEach(() => { document.body.innerHTML = ""; }); describe("T-FL01–T-FL03: fillFields + event dispatch", () => { // T-FL01: the core contract — username and password land in their inputs. it("T-FL01: fills username and password into standard inputs", () => { const u = makeInput({ type: "text" }); const p = makeInput({ type: "password" }); fillFields(u, p, "alice", "s3cret"); expect(u.value).toBe("alice"); expect(p.value).toBe("s3cret"); }); // T-FL02: an input event must fire so reactive frameworks re-read the field. it("T-FL02: dispatches an input event on filled fields", () => { const p = makeInput({ type: "password" }); const onInput = vi.fn(); p.addEventListener("input", onInput); fillFields(null, p, "", "pw"); expect(onInput).toHaveBeenCalledTimes(1); }); // T-FL03: a change event must fire too (some frameworks/validators key on it). it("T-FL03: dispatches a change event on filled fields", () => { const p = makeInput({ type: "password" }); const onChange = vi.fn(); p.addEventListener("change", onChange); fillFields(null, p, "", "pw"); expect(onChange).toHaveBeenCalledTimes(1); }); }); describe("T-FL04–T-FL05: framework-controlled inputs (setNativeValue)", () => { // T-FL04: React installs its own value setter on the *instance*; setNativeValue // must call the *prototype* setter so React's value tracker observes the change. // We emulate React's tracker by shadowing the instance value descriptor and // asserting the prototype setter still updated the underlying value AND that // the synthetic input event carried the new value. it("T-FL04: setNativeValue bypasses a React-style instance setter via the prototype setter", () => { const el = makeInput({ type: "text" }); let trackerSawValue: string | null = null; el.addEventListener("input", () => { trackerSawValue = el.value; }); // React installs an own `value` accessor on the element whose setter // swallows direct `.value =` assignment (its value-tracker). setNativeValue // must reach the *prototype* setter to update the real value slot. The // getter here delegates to the prototype so reads still work. const protoDesc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!; let instanceSetterCalled = false; Object.defineProperty(el, "value", { configurable: true, get() { return protoDesc.get!.call(el); }, set() { instanceSetterCalled = true; /* React swallows direct assignment */ }, }); setNativeValue(el, "from-peach"); expect(instanceSetterCalled).toBe(false); // went around the instance setter expect(el.value).toBe("from-peach"); // prototype setter wrote the slot expect(trackerSawValue).toBe("from-peach"); // event fired AFTER the value was set }); // T-FL05: Vue (and any framework) relies on the same input/change emission. it("T-FL05: setNativeValue emits input then change for Vue-style v-model", () => { const el = makeInput({ type: "text" }); const seq: string[] = []; el.addEventListener("input", () => seq.push("input")); el.addEventListener("change", () => seq.push("change")); setNativeValue(el, "x"); expect(seq).toEqual(["input", "change"]); }); }); describe("T-FL06: cross-origin / wrong-domain guard", () => { // T-FL06: a true cross-origin iframe can't be scripted from jsdom, but the // same defensive path fires when a form's action points at a different // registrable domain (phished/relayed form). fillFields must refuse to fill // and must not throw. it("T-FL06: form action on a foreign domain → fields are not filled, no crash", () => { const formEl = document.createElement("form"); formEl.action = "https://evil.example.org/collect"; document.body.appendChild(formEl); const u = makeInput({ type: "text" }, formEl); const p = makeInput({ type: "password" }, formEl); expect(() => fillFields(u, p, "alice", "s3cret", formEl, "mybank.example.com") ).not.toThrow(); expect(u.value).toBe(""); expect(p.value).toBe(""); }); }); describe("T-FL07–T-FL09: card fill + validation", () => { // T-FL07: every card field that exists gets its value. it("T-FL07: fills number, expiry combo, cvc, and cardholder name", () => { const number = makeInput({ autocomplete: "cc-number" }); const expiryCombo = makeInput({ autocomplete: "cc-exp" }); const cvc = makeInput({ autocomplete: "cc-csc" }); const name = makeInput({ autocomplete: "cc-name" }); fillCardForm( { number, expMonth: null, expYear: null, expiryCombo, cvc, name }, { cardNumber: "4111111111111111", expMonth: "08", expYear: "2027", cvc: "123", cardholderName: "Alice Wong" } ); expect(number.value).toBe("4111 1111 1111 1111"); // formatted in 4-digit groups expect(expiryCombo.value).toBe("08/27"); expect(cvc.value).toBe("123"); expect(name.value).toBe("Alice Wong"); }); // T-FL08: Luhn check — guards against a fill writing a transposed/typo'd PAN. it("T-FL08: passesLuhn accepts valid PANs and rejects invalid ones", () => { expect(passesLuhn("4111111111111111")).toBe(true); // canonical Visa test number expect(passesLuhn("4111 1111 1111 1111")).toBe(true); // whitespace tolerated expect(passesLuhn("4111111111111112")).toBe(false); // last digit wrong }); // T-FL09: network detection from the leading digits drives the card icon. it("T-FL09: detectCardNetwork keys off the issuer prefix", () => { expect(detectCardNetwork("4111111111111111")).toBe("visa"); expect(detectCardNetwork("5500005555555559")).toBe("mastercard"); expect(detectCardNetwork("340000000000009")).toBe("amex"); expect(detectCardNetwork("6011000000000004")).toBe("discover"); expect(detectCardNetwork("9999999999999999")).toBe("unknown"); }); it("T-FL09b: amex (15-digit) numbers format in 4-6-5 groups", () => { expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009"); expect(extractLast4("3400 000000 00009")).toBe("0009"); }); }); describe("T-FL10–T-FL12: TOTP fill, preservation, and the return contract", () => { // T-FL10: TOTP code goes into an empty one-time-code field. it("T-FL10: fillTOTPCode inserts the code into an empty visible field", () => { const totp = makeInput({ type: "text", autocomplete: "one-time-code" }); expect(fillTOTPCode(totp, "123456")).toBe(true); expect(totp.value).toBe("123456"); }); it("T-FL10b: fillTOTPCode refuses to overwrite a non-empty field", () => { const totp = makeInput({ type: "text", autocomplete: "one-time-code" }); setNativeValue(totp, "000000"); expect(fillTOTPCode(totp, "123456")).toBe(false); expect(totp.value).toBe("000000"); }); // T-FL11: only the targeted fields are mutated — a stray "remember me" or // search box on the same page keeps its value. it("T-FL11: fields not passed to fillFields are left untouched", () => { const u = makeInput({ type: "text" }); const p = makeInput({ type: "password" }); const other = makeInput({ type: "text" }); setNativeValue(other, "do-not-touch"); fillFields(u, p, "alice", "s3cret"); expect(other.value).toBe("do-not-touch"); }); // T-FL12: fillFields reports whether the form-action gate allowed the fill // (PB-26-105) — callers use it to decide whether any further field writes or // side effects may follow. An invisible password is intentionally skipped, // which is still not reported as a refusal. it("T-FL12: fillFields reports gate permission and skips an invisible password field", () => { const u = makeInput({ type: "text" }); const p = makeInput({ type: "password" }); p.style.display = "none"; // invisible → isVisible() false → not filled const result = fillFields(u, p, "alice", "s3cret"); expect(result).toBe(true); expect(u.value).toBe("alice"); expect(p.value).toBe(""); // skipped because not visible }); it("T-FL12b: fillFields reports false when the form-action gate refuses", () => { const form = document.createElement("form"); form.action = "https://evil.example/login"; document.body.appendChild(form); const u = makeInput({ type: "text" }, form); const p = makeInput({ type: "password" }, form); expect(fillFields(u, p, "alice", "s3cret", form, "localhost")).toBe(false); expect(u.value).toBe(""); expect(p.value).toBe(""); }); });