content.selector-ui.test.ts

11.4 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("opens the selected entry editor inside the inline helper", 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" });161    setMessageResponder((message) =>162      message.type === "GET_ENTRY_FOR_FILL"163        ? {164            ok: true,165            data: {166              title: "Acme",167              username: "[email protected]",168              password: "current-password",169              url: "https://example.com",170            },171          }172        : { ok: true, data: null },173    );174175    try {176      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), { domain: "example.com" });177      const host = selectorHost();178      const edit = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-action-edit");179      expect(edit).not.toBeNull();180      edit?.click();181182      await vi.waitFor(() => {183        expect(host?.shadowRoot?.querySelector<HTMLInputElement>('.create-form-wrap input[type="password"]')?.value)184          .toBe("current-password");185      });186      expect(sentMessages).toContainEqual({187        type: "GET_ENTRY_FOR_FILL",188        payload: { id: "e1", capability: undefined },189      });190      expect(sentMessages.some((message) => message.type === "OPEN_POPUP")).toBe(false);191      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_EDIT")).toBe(false);192193      const title = host?.shadowRoot?.querySelector<HTMLInputElement>('.create-form-wrap input[type="text"]');194      if (title) title.value = "Acme Updated";195      host?.shadowRoot?.querySelector<HTMLButtonElement>(".create-form-wrap .save-btn")?.click();196      await vi.waitFor(() => {197        expect(sentMessages.find((message) => message.type === "UPDATE_ENTRY")).toMatchObject({198          payload: {199            id: "e1",200            patch: { title: "Acme Updated", password: "current-password" },201          },202        });203      });204      const patch = sentMessages.find((message) => message.type === "UPDATE_ENTRY")?.payload?.patch;205      expect(patch).not.toHaveProperty("totp");206      expect(patch).not.toHaveProperty("passwordHistory");207      dismiss();208    } finally {209      attachSpy.mockRestore();210    }211  });212213  it("passes the card command capability into the selected card fill", async () => {214    const originalAttachShadow = Element.prototype.attachShadow;215    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {216      return originalAttachShadow.call(this, { mode: "open" });217    });218    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });219    const onFill = vi.fn();220    setMessageResponder((message) =>221      message.type === "GET_ENTRY_FOR_FILL"222        ? { ok: true, data: { id: "card-1", type: "card", title: "Personal Visa", cardNumber: "4111111111111111" } }223        : { ok: true, data: null },224    );225226    try {227      const dismiss = showCardSelector(228        anchor,229        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],230        { domain: "shop.example", capability: "card-capability", onFill } as Parameters<typeof showCardSelector>[2],231      );232      const host = selectorHost();233      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();234235      await vi.waitFor(() => {236        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({237          payload: { id: "card-1", capability: "card-capability" },238        });239      });240      dismiss();241    } finally {242      attachSpy.mockRestore();243    }244  });245246  it("allows only one card secret action per command capability", async () => {247    const originalAttachShadow = Element.prototype.attachShadow;248    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {249      return originalAttachShadow.call(this, { mode: "open" });250    });251    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });252    setMessageResponder((message) =>253      message.type === "GET_ENTRY_FOR_FILL"254        ? { ok: true, data: { id: "card-1", type: "card", cardNumber: "4111111111111111", cvc: "123" } }255        : { ok: true, data: null },256    );257258    try {259      const dismiss = showCardSelector(260        anchor,261        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],262        { domain: "shop.example", capability: "card-capability", onFill: vi.fn() },263      );264      const host = selectorHost();265      const copyNumber = host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy card number"]');266      const fill = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn");267      copyNumber?.click();268      fill?.click();269270      await vi.waitFor(() => {271        expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);272      });273      dismiss();274    } finally {275      attachSpy.mockRestore();276    }277  });278});