content.message-routing.test.ts

7.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), and the Addendum-2 showTotpCopyToast that13// does not exist yet, the cases are documented rather than faked. See14// INTEGRATION-CONTRACT.md.1516import { vi } from "vitest";1718const { predictBatch, dismissSpy } = vi.hoisted(() => ({19  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),20  dismissSpy: vi.fn(),21}));2223vi.mock("./inject", () => ({24  injectFieldIcon: vi.fn(() => vi.fn()),25  showAutofillSelector: vi.fn(() => dismissSpy),26  showTOTPSelector: vi.fn(() => dismissSpy),27  showCardSelector: vi.fn(() => dismissSpy),28  showRememberSiteToast: vi.fn(),29  showPasskeyAvailable: vi.fn(),30  showPasskeyProGate: vi.fn(),31  showPasskeyRegistrationOffer: vi.fn(),32}));33vi.mock("./content-ml-detector", () => ({34  contentMLDetector: { predictBatch },35}));3637import { afterEach, beforeEach, describe, expect, it } from "vitest";38import "./_setup";39import { resetChromeMock, setMessageResponder, messageListeners, createLoginForm, makeInput } from "./_setup";40import "./index"; // registers the onMessage listener at import time41import * as inject from "./inject";4243const showSelector = inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44const showPasskeyProGate = inject.showPasskeyProGate as unknown as ReturnType<typeof vi.fn>;45const SELF_SENDER = { id: "peach-test-extension-id" };46const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];4748// Deliver a message to every registered onMessage listener; return the49// sendResponse spy so the test can assert the reply.50function dispatch(msg: any, sender: any = SELF_SENDER) {51  const sendResponse = vi.fn();52  for (const l of messageListeners) l(msg, sender, sendResponse);53  return sendResponse;54}5556function withEntries(): void {57  setMessageResponder((msg) =>58    msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }59  );60}6162let urlCounter = 0;63beforeEach(async () => {64  try { history.pushState({}, "", `/m${++urlCounter}`); } catch { /* ignore */ }65  document.body.innerHTML = "";66  await new Promise((r) => setTimeout(r, 0));67  resetChromeMock();68  showSelector.mockClear();69  showPasskeyProGate.mockClear();70  predictBatch.mockReset();71  predictBatch.mockReturnValue(Promise.resolve([]));72});7374afterEach(async () => {75  await new Promise((r) => setTimeout(r, 0));76});7778describe("T-M01–T-M02: SUMMON_AUTOFILL", () => {79  // T-M01: the shortcut on a login form opens the selector for the focused field.80  it("T-M01: SUMMON_AUTOFILL with a focused login field → selector opens", async () => {81    const { password } = createLoginForm();82    password.focus();83    withEntries();84    const send = dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });85    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());86    expect(send).toHaveBeenCalled(); // listener acknowledged87  });8889  // T-M02: the shortcut on a page with no login form must not crash and must not90  // open the credential selector (it falls back to the search palette).91  it("T-M02: SUMMON_AUTOFILL with no login form → no crash, no credential selector", () => {92    expect(() => dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" })).not.toThrow();93    expect(showSelector).not.toHaveBeenCalled();94  });95});9697describe("T-M03–T-M05: VAULT_UNLOCKED", () => {98  // T-M03: after unlock, the pending field's interaction is retried.99  it("T-M03: VAULT_UNLOCKED with a pending anchor → re-triggers and reopens", async () => {100    const { password } = createLoginForm();101    password.focus();102    withEntries();103    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" }); // opens → sets currentAnchorField104    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());105    showSelector.mockClear();106107    dispatch({ type: "VAULT_UNLOCKED" });108    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); // reopened at the same field109  });110111  // T-M05: VAULT_UNLOCKED with nothing pending and no focused field is a no-op —112  // it must not throw or open anything.113  it("T-M05: VAULT_UNLOCKED with no pending interaction → no-op, no crash", async () => {114    (document.activeElement as HTMLElement | null)?.blur?.();115    const send = dispatch({ type: "VAULT_UNLOCKED" });116    await new Promise((r) => setTimeout(r, 20));117    expect(send).toHaveBeenCalledWith({ ok: true });118    expect(showSelector).not.toHaveBeenCalled();119  });120121  // T-M04: when a passkey unlock is in flight (passkeySummonActive), VAULT_UNLOCKED122  // must NOT re-trigger autofill — the passkey toast owns the next step. We arm123  // passkeySummonActive via the passkey-unlock summon path, then assert the124  // unlock only dismisses (no fresh credential selector). Ordered last in the125  // group because the VAULT_UNLOCKED it sends also clears the flag.126  it("T-M04: VAULT_UNLOCKED while passkeySummonActive → no re-trigger", async () => {127    // The passkey-unlock branch only fires when there is NO credential form on128    // the page (vault locked mid-passkey-GET); it anchors the locked prompt on129    // whatever element is focused (typically the sign-in button).130    const anchor = makeInput({ type: "text", name: "search-box", placeholder: "Search" });131    anchor.focus();132    dispatch({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }); // sets passkeySummonActive133    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());134    showSelector.mockClear();135136    dispatch({ type: "VAULT_UNLOCKED" }); // passkey branch: dismiss only, no reopen137    await new Promise((r) => setTimeout(r, 20));138    expect(showSelector).not.toHaveBeenCalled();139  });140});141142describe("T-M08–T-M10: PING, passkey gate, robustness", () => {143  // PING is the liveness probe the background uses to confirm the content script144  // is injected.145  it("PING → sendResponse({ ok: true })", () => {146    const send = dispatch({ type: "PING" });147    expect(send).toHaveBeenCalledWith({ ok: true });148  });149150  // GET_SITE_PASSWORD_POLICY returns the scraped policy for the generator.151  it("GET_SITE_PASSWORD_POLICY → returns a policy object", () => {152    document.body.innerHTML = '<input type="password" maxlength="32" />';153    const send = dispatch({ type: "GET_SITE_PASSWORD_POLICY" });154    expect(send).toHaveBeenCalledTimes(1);155    const arg = send.mock.calls[0]![0];156    expect(arg.ok).toBe(true);157    expect(arg.data.maxLength).toBe(32);158  });159160  // PASSKEY_PRO_GATE surfaces the upgrade prompt (State 11).161  it("PASSKEY_PRO_GATE → showPasskeyProGate called", () => {162    dispatch({ type: "PASSKEY_PRO_GATE" });163    expect(showPasskeyProGate).toHaveBeenCalled();164  });165166  // T-M09: an unknown message type falls through harmlessly — no throw, no167  // unhandled rejection.168  it("T-M09: unknown message type → handled gracefully", () => {169    expect(() => dispatch({ type: "TOTALLY_UNKNOWN_MESSAGE" })).not.toThrow();170    expect(showSelector).not.toHaveBeenCalled();171  });172173  // T-M10: a foreign-extension sender (sender.id mismatch) is ignored entirely —174  // a security boundary, not just robustness.175  it("T-M10: message from a foreign sender id is ignored", () => {176    const send = dispatch({ type: "PING" }, { id: "some-other-extension" });177    expect(send).not.toHaveBeenCalled();178  });179});180181// Addendum 2 (T-M11–T-M13, proposed showTotpCopyToast) lives in182// content.totp-toast.addendum.test.ts, which imports the REAL inject.ts so it183// can detect whether the proposed export exists. It cannot be probed here184// because this file mocks ./inject (and the mock throws on unknown exports).