content.selector-ui.test.ts

10.1 KB · raw

1// @vitest-environment jsdom2//3// Test File 5 — inject.ts showAutofillSelector (T-I01–T-I16).4//5// IMPORTANT ARCHITECTURE NOTE: showAutofillSelector mounts its UI inside a6// CLOSED shadow root (host.attachShadow({ mode: "closed" })). Everything the7// brief wants to assert about the selector's interior — the entry list, arrow/8// Enter/Escape keyboard navigation, the search filter, the lazy GET_ENTRY load,9// the TOTP chip, focus-trapping, above/below placement, isTrusted gating — lives10// behind that closed boundary and is, by design, unreachable from any external11// script (including this test and even a page-context Playwright script).12//13// So this file asserts the externally-observable contract: the selector mounts14// a host into the document and the returned dismiss function tears it down.15// Browser-level behavior is covered through observable effects rather than by16// adding a test-only seam into the closed shadow root.1718import "./_setup"; // chrome mock + ResizeObserver polyfill19import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";20import { showAutofillSelector, showCardSelector } from "./inject";21import { makeInput, resetChromeMock, sentMessages, setMessageResponder } from "./_setup";2223const ENTRIES = [24  { id: "e1", title: "Acme", username: "[email protected]" },25  { id: "e2", title: "Acme Admin", username: "[email protected]" },26];2728// The host is a position:fixed div with the max z-index; closed shadow means we29// can only see the host, never its contents.30function selectorHost(): HTMLElement | null {31  return document.body.querySelector<HTMLElement>('div[style*="2147483647"]');32}3334beforeEach(() => {35  document.body.innerHTML = "";36  resetChromeMock();37});38afterEach(() => {39  document.body.innerHTML = "";40});4142describe("T-I01: mount + dismiss (the observable contract)", () => {43  // T-I01: opening the selector mounts a host into the document and returns a44  // dismiss function.45  it("T-I01: showAutofillSelector mounts a host and returns a dismiss fn", () => {46    const anchor = makeInput({ type: "password" });47    const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());48    expect(typeof dismiss).toBe("function");49    expect(selectorHost()).not.toBeNull();50    dismiss();51  });5253  // Dismiss tears the host back down — the closed shadow goes with it. Teardown54  // is animated: dismiss() schedules host.remove() ~130ms later, so we wait it55  // out rather than asserting synchronously.56  it("T-I05/T-I06 (teardown half): the returned dismiss removes the host", async () => {57    const anchor = makeInput({ type: "password" });58    const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());59    expect(selectorHost()).not.toBeNull();60    dismiss();61    await new Promise((r) => setTimeout(r, 180));62    expect(selectorHost()).toBeNull();63  });6465  // The host mounts even in locked mode (the unlock prompt is also closed-shadow).66  it("T-I01b: locked-mode open also mounts a host", () => {67    const anchor = makeInput({ type: "password" });68    const dismiss = showAutofillSelector(anchor, [], vi.fn(), { locked: true });69    expect(selectorHost()).not.toBeNull();70    dismiss();71  });7273  // Re-opening dismisses any previous host the caller forgot — but the caller74  // (index.ts) owns currentDismiss, so here we just confirm two opens leave two75  // hosts unless dismissed, proving each open is independent.76  it("T-I01c: each open creates its own host (caller owns single-instance policy)", () => {77    const anchor = makeInput({ type: "password" });78    const d1 = showAutofillSelector(anchor, ENTRIES, vi.fn());79    const d2 = showAutofillSelector(anchor, ENTRIES, vi.fn());80    expect(document.body.querySelectorAll('div[style*="2147483647"]').length).toBe(2);81    d1();82    d2();83  });84});8586describe("PB-26-201: content-safe privileged actions", () => {87  it("uses a command capability for exactly one login-with-TOTP secret fetch", async () => {88    const originalAttachShadow = Element.prototype.attachShadow;89    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {90      return originalAttachShadow.call(this, { mode: "open" });91    });92    const anchor = makeInput({ type: "password" });93    const onSelect = vi.fn(async (id: string) => {94      await chrome.runtime.sendMessage({95        type: "GET_ENTRY_FOR_FILL",96        payload: { id, capability: "login-capability" },97      });98    });99    setMessageResponder((message) =>100      message.type === "GET_ENTRY_FOR_FILL"101        ? { ok: true, data: { password: "secret", totpCode: "123456" } }102        : { ok: true, data: null },103    );104105    try {106      const dismiss = showAutofillSelector(107        anchor,108        [{ id: "e1", title: "Acme", username: "alice", hasTotp: true }],109        onSelect,110        { domain: "example.com", capability: "login-capability" },111      );112      const host = selectorHost();113      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_FILL")).toBe(false);114      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();115116      await vi.waitFor(() => expect(onSelect).toHaveBeenCalledWith("e1"));117      expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);118      dismiss();119    } finally {120      attachSpy.mockRestore();121    }122  });123124  it("passes the command capability into a selected password copy", async () => {125    const originalAttachShadow = Element.prototype.attachShadow;126    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {127      return originalAttachShadow.call(this, { mode: "open" });128    });129    const anchor = makeInput({ type: "password" });130    setMessageResponder((message) =>131      message.type === "GET_ENTRY_FOR_FILL"132        ? { ok: true, data: { password: "correct horse battery staple" } }133        : { ok: true, data: null },134    );135136    try {137      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), {138        domain: "example.com",139        capability: "login-capability",140      } as Parameters<typeof showAutofillSelector>[3]);141      const host = selectorHost();142      host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy password"]')?.click();143144      await vi.waitFor(() => {145        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({146          payload: { id: "e1", capability: "login-capability" },147        });148      });149      dismiss();150    } finally {151      attachSpy.mockRestore();152    }153  });154155  it("routes inline edit to the extension popup without requesting raw edit data", async () => {156    const originalAttachShadow = Element.prototype.attachShadow;157    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {158      return originalAttachShadow.call(this, { mode: "open" });159    });160    const anchor = makeInput({ type: "password" });161162    try {163      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), { domain: "example.com" });164      const host = selectorHost();165      const edit = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-action-edit");166      expect(edit).not.toBeNull();167      edit?.click();168169      await vi.waitFor(() => {170        expect(sentMessages).toContainEqual({ type: "OPEN_POPUP", payload: { entryId: "e1" } });171      });172      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_EDIT")).toBe(false);173      dismiss();174    } finally {175      attachSpy.mockRestore();176    }177  });178179  it("passes the card command capability into the selected card fill", async () => {180    const originalAttachShadow = Element.prototype.attachShadow;181    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {182      return originalAttachShadow.call(this, { mode: "open" });183    });184    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });185    const onFill = vi.fn();186    setMessageResponder((message) =>187      message.type === "GET_ENTRY_FOR_FILL"188        ? { ok: true, data: { id: "card-1", type: "card", title: "Personal Visa", cardNumber: "4111111111111111" } }189        : { ok: true, data: null },190    );191192    try {193      const dismiss = showCardSelector(194        anchor,195        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],196        { domain: "shop.example", capability: "card-capability", onFill } as Parameters<typeof showCardSelector>[2],197      );198      const host = selectorHost();199      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();200201      await vi.waitFor(() => {202        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({203          payload: { id: "card-1", capability: "card-capability" },204        });205      });206      dismiss();207    } finally {208      attachSpy.mockRestore();209    }210  });211212  it("allows only one card secret action per command capability", async () => {213    const originalAttachShadow = Element.prototype.attachShadow;214    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {215      return originalAttachShadow.call(this, { mode: "open" });216    });217    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });218    setMessageResponder((message) =>219      message.type === "GET_ENTRY_FOR_FILL"220        ? { ok: true, data: { id: "card-1", type: "card", cardNumber: "4111111111111111", cvc: "123" } }221        : { ok: true, data: null },222    );223224    try {225      const dismiss = showCardSelector(226        anchor,227        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],228        { domain: "shop.example", capability: "card-capability", onFill: vi.fn() },229      );230      const host = selectorHost();231      const copyNumber = host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy card number"]');232      const fill = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn");233      copyNumber?.click();234      fill?.click();235236      await vi.waitFor(() => {237        expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);238      });239      dismiss();240    } finally {241      attachSpy.mockRestore();242    }243  });244});