content.selector-ui.test.ts

12.6 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("opens new-entry creation inline without rechecking an already-unlocked selector", 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    setMessageResponder((message) =>94      message.type === "GET_VAULT_STATUS"95        ? { ok: true, unlocked: false }96        : { ok: true, data: null },97    );9899    try {100      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), { domain: "example.com" });101      const host = selectorHost();102      host?.shadowRoot?.querySelector<HTMLButtonElement>(".domain-add-btn")?.click();103104      await vi.waitFor(() => {105        expect(host?.shadowRoot?.querySelector(".create-form-wrap")).not.toBeNull();106      });107      expect(host?.shadowRoot?.querySelector(".locked-wrap")).toBeNull();108      expect(sentMessages.some((message) => message.type === "GET_VAULT_STATUS")).toBe(false);109      dismiss();110    } finally {111      attachSpy.mockRestore();112    }113  });114115  it("uses a command capability for exactly one login-with-TOTP secret fetch", async () => {116    const originalAttachShadow = Element.prototype.attachShadow;117    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {118      return originalAttachShadow.call(this, { mode: "open" });119    });120    const anchor = makeInput({ type: "password" });121    const onSelect = vi.fn(async (id: string) => {122      await chrome.runtime.sendMessage({123        type: "GET_ENTRY_FOR_FILL",124        payload: { id, capability: "login-capability" },125      });126    });127    setMessageResponder((message) =>128      message.type === "GET_ENTRY_FOR_FILL"129        ? { ok: true, data: { password: "secret", totpCode: "123456" } }130        : { ok: true, data: null },131    );132133    try {134      const dismiss = showAutofillSelector(135        anchor,136        [{ id: "e1", title: "Acme", username: "alice", hasTotp: true }],137        onSelect,138        { domain: "example.com", capability: "login-capability" },139      );140      const host = selectorHost();141      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_FILL")).toBe(false);142      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();143144      await vi.waitFor(() => expect(onSelect).toHaveBeenCalledWith("e1"));145      expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);146      dismiss();147    } finally {148      attachSpy.mockRestore();149    }150  });151152  it("passes the command capability into a selected password copy", async () => {153    const originalAttachShadow = Element.prototype.attachShadow;154    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {155      return originalAttachShadow.call(this, { mode: "open" });156    });157    const anchor = makeInput({ type: "password" });158    setMessageResponder((message) =>159      message.type === "GET_ENTRY_FOR_FILL"160        ? { ok: true, data: { password: "correct horse battery staple" } }161        : { ok: true, data: null },162    );163164    try {165      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), {166        domain: "example.com",167        capability: "login-capability",168      } as Parameters<typeof showAutofillSelector>[3]);169      const host = selectorHost();170      host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy password"]')?.click();171172      await vi.waitFor(() => {173        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({174          payload: { id: "e1", capability: "login-capability" },175        });176      });177      dismiss();178    } finally {179      attachSpy.mockRestore();180    }181  });182183  it("opens the selected entry editor inside the inline helper", async () => {184    const originalAttachShadow = Element.prototype.attachShadow;185    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {186      return originalAttachShadow.call(this, { mode: "open" });187    });188    const anchor = makeInput({ type: "password" });189    setMessageResponder((message) =>190      message.type === "GET_ENTRY_FOR_FILL"191        ? {192            ok: true,193            data: {194              title: "Acme",195              username: "[email protected]",196              password: "current-password",197              url: "https://example.com",198            },199          }200        : { ok: true, data: null },201    );202203    try {204      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), { domain: "example.com" });205      const host = selectorHost();206      const edit = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-action-edit");207      expect(edit).not.toBeNull();208      edit?.click();209210      await vi.waitFor(() => {211        expect(host?.shadowRoot?.querySelector<HTMLInputElement>('.create-form-wrap input[type="password"]')?.value)212          .toBe("current-password");213      });214      expect(sentMessages).toContainEqual({215        type: "GET_ENTRY_FOR_FILL",216        payload: { id: "e1", capability: undefined },217      });218      expect(sentMessages.some((message) => message.type === "OPEN_POPUP")).toBe(false);219      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_EDIT")).toBe(false);220221      const title = host?.shadowRoot?.querySelector<HTMLInputElement>('.create-form-wrap input[type="text"]');222      if (title) title.value = "Acme Updated";223      host?.shadowRoot?.querySelector<HTMLButtonElement>(".create-form-wrap .save-btn")?.click();224      await vi.waitFor(() => {225        expect(sentMessages.find((message) => message.type === "UPDATE_ENTRY")).toMatchObject({226          payload: {227            id: "e1",228            patch: { title: "Acme Updated", password: "current-password" },229          },230        });231      });232      const patch = sentMessages.find((message) => message.type === "UPDATE_ENTRY")?.payload?.patch;233      expect(patch).not.toHaveProperty("totp");234      expect(patch).not.toHaveProperty("passwordHistory");235      dismiss();236    } finally {237      attachSpy.mockRestore();238    }239  });240241  it("passes the card command capability into the selected card fill", async () => {242    const originalAttachShadow = Element.prototype.attachShadow;243    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {244      return originalAttachShadow.call(this, { mode: "open" });245    });246    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });247    const onFill = vi.fn();248    setMessageResponder((message) =>249      message.type === "GET_ENTRY_FOR_FILL"250        ? { ok: true, data: { id: "card-1", type: "card", title: "Personal Visa", cardNumber: "4111111111111111" } }251        : { ok: true, data: null },252    );253254    try {255      const dismiss = showCardSelector(256        anchor,257        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],258        { domain: "shop.example", capability: "card-capability", onFill } as Parameters<typeof showCardSelector>[2],259      );260      const host = selectorHost();261      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();262263      await vi.waitFor(() => {264        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({265          payload: { id: "card-1", capability: "card-capability" },266        });267      });268      dismiss();269    } finally {270      attachSpy.mockRestore();271    }272  });273274  it("allows only one card secret action per command capability", async () => {275    const originalAttachShadow = Element.prototype.attachShadow;276    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {277      return originalAttachShadow.call(this, { mode: "open" });278    });279    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });280    setMessageResponder((message) =>281      message.type === "GET_ENTRY_FOR_FILL"282        ? { ok: true, data: { id: "card-1", type: "card", cardNumber: "4111111111111111", cvc: "123" } }283        : { ok: true, data: null },284    );285286    try {287      const dismiss = showCardSelector(288        anchor,289        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],290        { domain: "shop.example", capability: "card-capability", onFill: vi.fn() },291      );292      const host = selectorHost();293      const copyNumber = host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy card number"]');294      const fill = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn");295      copyNumber?.click();296      fill?.click();297298      await vi.waitFor(() => {299        expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);300      });301      dismiss();302    } finally {303      attachSpy.mockRestore();304    }305  });306});