fill.test.ts

8.8 KB · raw

1// @vitest-environment jsdom23import { beforeEach, describe, expect, it, vi } from "vitest";45import { fillFields, fillCardForm, fillTOTPCode, formatCardNumber } from "./fill";67describe("content fill", () => {8  beforeEach(() => {9    document.body.innerHTML = "";10  });1112  function makeVisibleInput(type: string): HTMLInputElement {13    const input = document.createElement("input");14    input.type = type;15    Object.defineProperty(input, "getBoundingClientRect", {16      configurable: true,17      value: () => ({18        width: 100,19        height: 20,20        top: 0,21        left: 0,22        right: 100,23        bottom: 20,24        x: 0,25        y: 0,26        toJSON: () => ({}),27      }),28    });29    document.body.appendChild(input);30    return input;31  }3233  it("fillFields writes username and password into matching inputs", () => {34    const username = makeVisibleInput("text");35    const password = makeVisibleInput("password");3637    fillFields(username, password, "june", "hunter2");3839    expect(username.value).toBe("june");40    expect(password.value).toBe("hunter2");41  });4243  it("fillFields refuses to fill when the form action domain does not match", () => {44    const form = document.createElement("form");45    form.action = "https://evil.com/login";46    const username = makeVisibleInput("text");47    const password = makeVisibleInput("password");48    form.append(username, password);49    document.body.appendChild(form);5051    fillFields(username, password, "june", "hunter2", form, "github.com");5253    expect(username.value).toBe("");54    expect(password.value).toBe("");55  });5657  // PB-26-008: where a <form> exists, the action must be https and on the58  // expected domain or a subdomain of it — not merely the same eTLD+1.59  it("fillFields refuses a plain-http form action (PB-26-008)", () => {60    const form = document.createElement("form");61    form.action = "http://github.com/login";62    const username = makeVisibleInput("text");63    const password = makeVisibleInput("password");64    form.append(username, password);65    document.body.appendChild(form);6667    fillFields(username, password, "june", "hunter2", form, "github.com");6869    expect(username.value).toBe("");70    expect(password.value).toBe("");71  });7273  it("fillFields allows an https form action on a subdomain of the expected domain (PB-26-008)", () => {74    const form = document.createElement("form");75    form.action = "https://auth.github.com/session";76    const username = makeVisibleInput("text");77    const password = makeVisibleInput("password");78    form.append(username, password);79    document.body.appendChild(form);8081    fillFields(username, password, "june", "hunter2", form, "github.com");8283    expect(username.value).toBe("june");84    expect(password.value).toBe("hunter2");85  });8687  it("fillFields still fills the username when the username field is hidden", () => {88    const username = makeVisibleInput("text");89    const password = makeVisibleInput("password");90    Object.defineProperty(username, "getBoundingClientRect", {91      value: () => ({92        width: 0,93        height: 0,94        top: 0,95        left: 0,96        right: 0,97        bottom: 0,98        x: 0,99        y: 0,100        toJSON: () => ({}),101      }),102    });103104    fillFields(username, password, "june", "hunter2");105106    expect(username.value).toBe("june");107    expect(password.value).toBe("hunter2");108  });109110  it("fillTOTPCode respects the form-action gate (PB-26-105)", () => {111    const form = document.createElement("form");112    form.action = "https://evil.com/login";113    const totpEl = makeVisibleInput("text");114    form.appendChild(totpEl);115    document.body.appendChild(form);116117    const filled = fillTOTPCode(totpEl, "123456", form, "github.com");118119    expect(filled).toBe(false);120    expect(totpEl.value).toBe("");121  });122123  it("fillTOTPCode allows https form action on expected domain (PB-26-105)", () => {124    const form = document.createElement("form");125    form.action = "https://github.com/login";126    const totpEl = makeVisibleInput("text");127    form.appendChild(totpEl);128    document.body.appendChild(form);129130    const filled = fillTOTPCode(totpEl, "123456", form, "github.com");131132    expect(filled).toBe(true);133    expect(totpEl.value).toBe("123456");134  });135136  it("fillFields dispatches standard input/change events rather than custom password events", () => {137    const username = makeVisibleInput("text");138    const password = makeVisibleInput("password");139    const usernameDispatch = vi.spyOn(username, "dispatchEvent");140    const passwordDispatch = vi.spyOn(password, "dispatchEvent");141142    fillFields(username, password, "june", "hunter2");143144    expect(usernameDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);145    expect(passwordDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);146    expect(passwordDispatch.mock.calls.every(([event]) => !(event instanceof CustomEvent))).toBe(true);147  });148});149150describe("card fill", () => {151  beforeEach(() => {152    document.body.innerHTML = "";153  });154155  function makeInput(attrs: Record<string, string> = {}): HTMLInputElement {156    const input = document.createElement("input");157    for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);158    // jsdom rects are all-zero, which isVisible treats as hidden — stub a159    // real rect so these inputs count as visible unless a test hides them.160    Object.defineProperty(input, "getBoundingClientRect", {161      configurable: true,162      value: () => ({163        width: 100,164        height: 20,165        top: 0,166        left: 0,167        right: 100,168        bottom: 20,169        x: 0,170        y: 0,171        toJSON: () => ({}),172      }),173    });174    document.body.appendChild(input);175    return input;176  }177178  function makeHiddenInput(attrs: Record<string, string> = {}): HTMLInputElement {179    const input = makeInput(attrs);180    Object.defineProperty(input, "getBoundingClientRect", {181      configurable: true,182      value: () => ({183        width: 0,184        height: 0,185        top: 0,186        left: 0,187        right: 0,188        bottom: 0,189        x: 0,190        y: 0,191        toJSON: () => ({}),192      }),193    });194    return input;195  }196197  // PB-26-007: card fill must apply the same visibility gate as the198  // password path — a crafted page must not be able to exfiltrate card199  // data into hidden fields.200  it("fillCardForm — skips hidden fields (PB-26-007)", () => {201    const number = makeHiddenInput({ type: "text" });202    const cvc = makeHiddenInput({ type: "text" });203    const name = makeInput({ type: "text" });204205    fillCardForm(206      { number, expMonth: null, expYear: null, expiryCombo: null, cvc, name },207      { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane Smith" }208    );209210    expect(number.value).toBe("");211    expect(cvc.value).toBe("");212    expect(name.value).toBe("Jane Smith");213  });214215  it("fillCardForm — fills number, expiry, CVC, and name fields", () => {216    const number = makeInput({ type: "text" });217    const expMonth = makeInput({ type: "text" });218    const expYear = makeInput({ type: "text" });219    const cvc = makeInput({ type: "text" });220    const name = makeInput({ type: "text" });221222    fillCardForm(223      { number, expMonth, expYear, expiryCombo: null, cvc, name },224      { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane Smith" }225    );226227    expect(number.value).toBe("4111 1111 1111 1111");228    expect(expMonth.value).toBe("05");229    expect(expYear.value).toBe("2028");230    expect(cvc.value).toBe("123");231    expect(name.value).toBe("Jane Smith");232  });233234  it("fillCardForm — handles combined expiry field (MM/YY)", () => {235    const number = makeInput({ type: "text" });236    const expiryCombo = makeInput({ type: "text" });237    const cvc = makeInput({ type: "text" });238239    fillCardForm(240      { number, expMonth: null, expYear: null, expiryCombo, cvc, name: null },241      { cardNumber: "5555555555554444", expMonth: "12", expYear: "2026", cvc: "456", cardholderName: "John Doe" }242    );243244    expect(expiryCombo.value).toBe("12/26");245  });246247  it("fillCardForm — dispatches input and change events for React compatibility", () => {248    const number = makeInput({ type: "text" });249    const numberDispatch = vi.spyOn(number, "dispatchEvent");250251    fillCardForm(252      { number, expMonth: null, expYear: null, expiryCombo: null, cvc: null, name: null },253      { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane" }254    );255256    expect(numberDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);257  });258259  it("formatCardNumber — formats Visa as 4-4-4-4", () => {260    expect(formatCardNumber("4111111111111111")).toBe("4111 1111 1111 1111");261  });262263  it("formatCardNumber — formats Amex as 4-6-5", () => {264    expect(formatCardNumber("378282246310005")).toBe("3782 822463 10005");265  });266});