content.message-routing.test.ts

11.9 KB · raw

1// @vitest-environment jsdom2//3// Test File 4 — index.ts message routing (T-M01–T-M13).4//5// index.ts registers a single chrome.runtime.onMessage listener that routes6// SUMMON_AUTOFILL (keyboard shortcut / toolbar button), VAULT_UNLOCKED (retry7// after unlock), PING, GET_SITE_PASSWORD_POLICY, and the passkey-toast control8// messages. The harness captures every registered listener; we invoke them with9// a spy sendResponse and a same-extension sender, then assert the routed effect.10//11// Where the brief assumed messages that this listener does not own12// (DISMISS_AUTOFILL, FILL_AND_SUBMIT), the cases are documented rather than13// faked.1415import { vi } from "vitest";1617const { predictBatch, dismissSpy } = vi.hoisted(() => ({18  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),19  dismissSpy: vi.fn(),20}));2122vi.mock("./inject", () => ({23  injectFieldIcon: vi.fn(() => vi.fn()),24  showAutofillSelector: vi.fn(() => dismissSpy),25  showTOTPSelector: vi.fn(() => dismissSpy),26  showCardSelector: vi.fn(() => dismissSpy),27  showRememberSiteToast: vi.fn(),28  showPasskeyAvailable: vi.fn(),29  showPasskeyProGate: vi.fn(),30  showPasskeyRegistrationOffer: vi.fn(),31}));32vi.mock("./content-ml-detector", () => ({33  contentMLDetector: { predictBatch },34}));3536import { afterEach, beforeEach, describe, expect, it } from "vitest";37import "./_setup";38import { resetChromeMock, setMessageResponder, sentMessages, messageListeners, createLoginForm, makeInput } from "./_setup";39import "./index"; // registers the onMessage listener at import time40import * as inject from "./inject";41import { notifyPasskeyToastClosed } from "./passkey-toast/lifecycle";4243const showSelector = inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44const showCardSelector = inject.showCardSelector as unknown as ReturnType<typeof vi.fn>;45const showPasskeyProGate = inject.showPasskeyProGate as unknown as ReturnType<typeof vi.fn>;46const SELF_SENDER = { id: "peach-test-extension-id" };47const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];4849// Deliver a message to every registered onMessage listener; return the50// sendResponse spy so the test can assert the reply.51function dispatch(msg: any, sender: any = SELF_SENDER) {52  const sendResponse = vi.fn();53  for (const l of messageListeners) l(msg, sender, sendResponse);54  return sendResponse;55}5657function withEntries(): void {58  setMessageResponder((msg) =>59    msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }60  );61}6263let urlCounter = 0;64beforeEach(async () => {65  try { history.pushState({}, "", `/m${++urlCounter}`); } catch { /* ignore */ }66  document.body.innerHTML = "";67  await new Promise((r) => setTimeout(r, 0));68  resetChromeMock();69  showSelector.mockClear();70  showCardSelector.mockClear();71  showPasskeyProGate.mockClear();72  predictBatch.mockReset();73  predictBatch.mockReturnValue(Promise.resolve([]));74});7576afterEach(async () => {77  await new Promise((r) => setTimeout(r, 0));78});7980describe("T-M01–T-M02: SUMMON_AUTOFILL", () => {81  // T-M01: the shortcut on a login form opens the selector for the focused field.82  it("T-M01: SUMMON_AUTOFILL with a focused login field → selector opens", async () => {83    const { password } = createLoginForm();84    password.focus();85    withEntries();86    const send = dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });87    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());88    expect(send).toHaveBeenCalled(); // listener acknowledged89  });9091  // T-M02: the shortcut on a page with no login form must not crash and must not92  // open the credential selector (it falls back to the search palette).93  it("T-M02: SUMMON_AUTOFILL with no login form → no crash, no credential selector", () => {94    expect(() => dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" })).not.toThrow();95    expect(showSelector).not.toHaveBeenCalled();96  });9798  it("PB-26-201: no-form command carries capability from inventory to selected fill", async () => {99    const originalAttachShadow = Element.prototype.attachShadow;100    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {101      return originalAttachShadow.call(this, { mode: "open" });102    });103    setMessageResponder((msg) => {104      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: ENTRIES };105      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { ...ENTRIES[0], password: "secret" } };106      return { ok: true, data: null };107    });108109    try {110      dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "no-form-capability" });111      await vi.waitFor(() => {112        expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({113          payload: { includeAll: true, capability: "no-form-capability" },114        });115      });116      const findRow = () => Array.from(document.body.children)117        .map((host) => host.shadowRoot?.querySelector<HTMLElement>(".e"))118        .find((candidate): candidate is HTMLElement => candidate !== null && candidate !== undefined);119      await vi.waitFor(() => expect(findRow()).toBeDefined());120      const row = findRow();121      row?.click();122      await vi.waitFor(() => {123        expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({124          payload: { id: "e1", capability: "no-form-capability" },125        });126      });127    } finally {128      attachSpy.mockRestore();129    }130  });131132  it("PB-26-201: card command carries capability into inventory and selector actions", async () => {133    const form = document.createElement("form");134    document.body.appendChild(form);135    const number = makeInput({ type: "text", autocomplete: "cc-number" }, form);136    makeInput({ type: "text", autocomplete: "cc-exp" }, form);137    makeInput({ type: "text", autocomplete: "cc-csc" }, form);138    number.focus();139    setMessageResponder((msg) =>140      msg.type === "AUTOFILL_QUERY"141        ? { ok: true, data: [{ id: "card-1", title: "Personal Visa", last4: "1111" }] }142        : { ok: true, data: null },143    );144145    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "card-capability" });146147    await vi.waitFor(() => expect(showCardSelector).toHaveBeenCalled());148    expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY" && msg.payload?.kind === "card")).toMatchObject({149      payload: { capability: "card-capability" },150    });151    expect(showCardSelector.mock.calls.at(-1)![2]).toMatchObject({ capability: "card-capability" });152  });153});154155describe("T-M03–T-M05: VAULT_UNLOCKED", () => {156  // T-M03: after unlock, the pending field's interaction is retried.157  it("T-M03: VAULT_UNLOCKED with a pending anchor → re-triggers and reopens", async () => {158    const { password } = createLoginForm();159    password.focus();160    withEntries();161    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" }); // opens → sets currentAnchorField162    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());163    showSelector.mockClear();164165    dispatch({ type: "VAULT_UNLOCKED" });166    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); // reopened at the same field167  });168169  // T-M05: VAULT_UNLOCKED with nothing pending and no focused field is a no-op —170  // it must not throw or open anything.171  it("T-M05: VAULT_UNLOCKED with no pending interaction → no-op, no crash", async () => {172    (document.activeElement as HTMLElement | null)?.blur?.();173    const send = dispatch({ type: "VAULT_UNLOCKED" });174    await new Promise((r) => setTimeout(r, 20));175    expect(send).toHaveBeenCalledWith({ ok: true });176    expect(showSelector).not.toHaveBeenCalled();177  });178179  // T-M04: when a passkey unlock is in flight (passkeySummonActive), VAULT_UNLOCKED180  // must NOT re-trigger autofill — the passkey toast owns the next step. We arm181  // passkeySummonActive via the passkey-unlock summon path, then assert the182  // unlock only dismisses (no fresh credential selector). Ordered last in the183  // group because the VAULT_UNLOCKED it sends also clears the flag.184  it("T-M04: VAULT_UNLOCKED while passkeySummonActive → no re-trigger", async () => {185    // The passkey-unlock branch only fires when there is NO credential form on186    // the page (vault locked mid-passkey-GET); it anchors the locked prompt on187    // whatever element is focused (typically the sign-in button).188    const anchor = makeInput({ type: "text", name: "search-box", placeholder: "Search" });189    anchor.focus();190    dispatch({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }); // sets passkeySummonActive191    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());192    showSelector.mockClear();193194    dispatch({ type: "VAULT_UNLOCKED" }); // passkey branch: dismiss only, no reopen195    await new Promise((r) => setTimeout(r, 20));196    expect(showSelector).not.toHaveBeenCalled();197  });198});199200describe("PB-26-319: toast close signals require the extension token", () => {201  it("ignores a page-forged toast-closed postMessage", async () => {202    const { password } = createLoginForm();203    password.focus();204    withEntries();205    dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "extension-token" } });206    window.dispatchEvent(new MessageEvent("message", { source: window, data: { source: "peach-passkey-toast", type: "toast-closed", token: "page-token" } }));207    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });208    await new Promise((resolve) => setTimeout(resolve, 0));209    expect(showSelector).not.toHaveBeenCalled();210  });211212  it("accepts the isolated-world toast lifecycle token", async () => {213    const { password } = createLoginForm();214    password.focus();215    withEntries();216    dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "extension-token" } });217    notifyPasskeyToastClosed("extension-token");218    await new Promise((resolve) => setTimeout(resolve, 0));219    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });220    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());221  });222223  it("ignores a stale isolated-world close token after a replacement toast appears", async () => {224    const { password } = createLoginForm();225    password.focus();226    withEntries();227    dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "replacement-token" } });228    notifyPasskeyToastClosed("previous-token");229    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });230    await new Promise((resolve) => setTimeout(resolve, 0));231    expect(showSelector).not.toHaveBeenCalled();232  });233});234235describe("T-M08–T-M10: PING, passkey gate, robustness", () => {236  // PING is the liveness probe the background uses to confirm the content script237  // is injected.238  it("PING → sendResponse({ ok: true })", () => {239    const send = dispatch({ type: "PING" });240    expect(send).toHaveBeenCalledWith({ ok: true });241  });242243  // GET_SITE_PASSWORD_POLICY returns the scraped policy for the generator.244  it("GET_SITE_PASSWORD_POLICY → returns a policy object", () => {245    document.body.innerHTML = '<input type="password" maxlength="32" />';246    const send = dispatch({ type: "GET_SITE_PASSWORD_POLICY" });247    expect(send).toHaveBeenCalledTimes(1);248    const arg = send.mock.calls[0]![0];249    expect(arg.ok).toBe(true);250    expect(arg.data.maxLength).toBe(32);251  });252253  // PASSKEY_PRO_GATE surfaces the upgrade prompt (State 11).254  it("PASSKEY_PRO_GATE → showPasskeyProGate called", () => {255    dispatch({ type: "PASSKEY_PRO_GATE" });256    expect(showPasskeyProGate).toHaveBeenCalled();257  });258259  // T-M09: an unknown message type falls through harmlessly — no throw, no260  // unhandled rejection.261  it("T-M09: unknown message type → handled gracefully", () => {262    expect(() => dispatch({ type: "TOTALLY_UNKNOWN_MESSAGE" })).not.toThrow();263    expect(showSelector).not.toHaveBeenCalled();264  });265266  // T-M10: a foreign-extension sender (sender.id mismatch) is ignored entirely —267  // a security boundary, not just robustness.268  it("T-M10: message from a foreign sender id is ignored", () => {269    const send = dispatch({ type: "PING" }, { id: "some-other-extension" });270    expect(send).not.toHaveBeenCalled();271  });272});