content.selector-ui.test.ts

13.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  it("PB-26-707: shows the full security origin instead of the registrable domain", () => {44    const originalAttachShadow = Element.prototype.attachShadow;45    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {46      return originalAttachShadow.call(this, { mode: "open" });47    });48    const anchor = makeInput({ type: "password" });4950    try {51      const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn(), {52        domain: "example.com",53        securityOrigin: "https://login.accounts.example.com:8443",54      });55      expect(selectorHost()?.shadowRoot?.querySelector(".domain-name")?.textContent)56        .toBe("https://login.accounts.example.com:8443");57      dismiss();58    } finally {59      attachSpy.mockRestore();60    }61  });6263  // T-I01: opening the selector mounts a host into the document and returns a64  // dismiss function.65  it("T-I01: showAutofillSelector mounts a host and returns a dismiss fn", () => {66    const anchor = makeInput({ type: "password" });67    const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());68    expect(typeof dismiss).toBe("function");69    expect(selectorHost()).not.toBeNull();70    dismiss();71  });7273  // Dismiss tears the host back down — the closed shadow goes with it. Teardown74  // is animated: dismiss() schedules host.remove() ~130ms later, so we wait it75  // out rather than asserting synchronously.76  it("T-I05/T-I06 (teardown half): the returned dismiss removes the host", async () => {77    const anchor = makeInput({ type: "password" });78    const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());79    expect(selectorHost()).not.toBeNull();80    dismiss();81    await new Promise((r) => setTimeout(r, 180));82    expect(selectorHost()).toBeNull();83  });8485  // The host mounts even in locked mode (the unlock prompt is also closed-shadow).86  it("T-I01b: locked-mode open also mounts a host", () => {87    const anchor = makeInput({ type: "password" });88    const dismiss = showAutofillSelector(anchor, [], vi.fn(), { locked: true });89    expect(selectorHost()).not.toBeNull();90    dismiss();91  });9293  // Re-opening dismisses any previous host the caller forgot — but the caller94  // (index.ts) owns currentDismiss, so here we just confirm two opens leave two95  // hosts unless dismissed, proving each open is independent.96  it("T-I01c: each open creates its own host (caller owns single-instance policy)", () => {97    const anchor = makeInput({ type: "password" });98    const d1 = showAutofillSelector(anchor, ENTRIES, vi.fn());99    const d2 = showAutofillSelector(anchor, ENTRIES, vi.fn());100    expect(document.body.querySelectorAll('div[style*="2147483647"]').length).toBe(2);101    d1();102    d2();103  });104});105106describe("PB-26-201: content-safe privileged actions", () => {107  it("opens new-entry creation inline without rechecking an already-unlocked selector", async () => {108    const originalAttachShadow = Element.prototype.attachShadow;109    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {110      return originalAttachShadow.call(this, { mode: "open" });111    });112    const anchor = makeInput({ type: "password" });113    setMessageResponder((message) =>114      message.type === "GET_VAULT_STATUS"115        ? { ok: true, unlocked: false }116        : { ok: true, data: null },117    );118119    try {120      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), { domain: "example.com" });121      const host = selectorHost();122      host?.shadowRoot?.querySelector<HTMLButtonElement>(".domain-add-btn")?.click();123124      await vi.waitFor(() => {125        expect(host?.shadowRoot?.querySelector(".create-form-wrap")).not.toBeNull();126      });127      expect(host?.shadowRoot?.querySelector(".locked-wrap")).toBeNull();128      expect(sentMessages.some((message) => message.type === "GET_VAULT_STATUS")).toBe(false);129      dismiss();130    } finally {131      attachSpy.mockRestore();132    }133  });134135  it("uses a command capability for exactly one login-with-TOTP secret fetch", async () => {136    const originalAttachShadow = Element.prototype.attachShadow;137    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {138      return originalAttachShadow.call(this, { mode: "open" });139    });140    const anchor = makeInput({ type: "password" });141    const onSelect = vi.fn(async (id: string) => {142      await chrome.runtime.sendMessage({143        type: "GET_ENTRY_FOR_FILL",144        payload: { id, capability: "login-capability" },145      });146    });147    setMessageResponder((message) =>148      message.type === "GET_ENTRY_FOR_FILL"149        ? { ok: true, data: { password: "secret", totpCode: "123456" } }150        : { ok: true, data: null },151    );152153    try {154      const dismiss = showAutofillSelector(155        anchor,156        [{ id: "e1", title: "Acme", username: "alice", hasTotp: true }],157        onSelect,158        { domain: "example.com", capability: "login-capability" },159      );160      const host = selectorHost();161      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_FILL")).toBe(false);162      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();163164      await vi.waitFor(() => expect(onSelect).toHaveBeenCalledWith("e1"));165      expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);166      dismiss();167    } finally {168      attachSpy.mockRestore();169    }170  });171172  it("passes the command capability into a selected password copy", async () => {173    const originalAttachShadow = Element.prototype.attachShadow;174    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {175      return originalAttachShadow.call(this, { mode: "open" });176    });177    const anchor = makeInput({ type: "password" });178    setMessageResponder((message) =>179      message.type === "GET_ENTRY_FOR_FILL"180        ? { ok: true, data: { password: "correct horse battery staple" } }181        : { ok: true, data: null },182    );183184    try {185      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), {186        domain: "example.com",187        capability: "login-capability",188      } as Parameters<typeof showAutofillSelector>[3]);189      const host = selectorHost();190      host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy password"]')?.click();191192      await vi.waitFor(() => {193        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({194          payload: { id: "e1", capability: "login-capability" },195        });196      });197      dismiss();198    } finally {199      attachSpy.mockRestore();200    }201  });202203  it("opens the selected entry editor inside the inline helper", async () => {204    const originalAttachShadow = Element.prototype.attachShadow;205    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {206      return originalAttachShadow.call(this, { mode: "open" });207    });208    const anchor = makeInput({ type: "password" });209    setMessageResponder((message) =>210      message.type === "GET_ENTRY_FOR_FILL"211        ? {212            ok: true,213            data: {214              title: "Acme",215              username: "[email protected]",216              password: "current-password",217              url: "https://example.com",218            },219          }220        : { ok: true, data: null },221    );222223    try {224      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), { domain: "example.com" });225      const host = selectorHost();226      const edit = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-action-edit");227      expect(edit).not.toBeNull();228      edit?.click();229230      await vi.waitFor(() => {231        expect(host?.shadowRoot?.querySelector<HTMLInputElement>('.create-form-wrap input[type="password"]')?.value)232          .toBe("current-password");233      });234      expect(sentMessages).toContainEqual({235        type: "GET_ENTRY_FOR_FILL",236        payload: { id: "e1", capability: undefined },237      });238      expect(sentMessages.some((message) => message.type === "OPEN_POPUP")).toBe(false);239      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_EDIT")).toBe(false);240241      const title = host?.shadowRoot?.querySelector<HTMLInputElement>('.create-form-wrap input[type="text"]');242      if (title) title.value = "Acme Updated";243      host?.shadowRoot?.querySelector<HTMLButtonElement>(".create-form-wrap .save-btn")?.click();244      await vi.waitFor(() => {245        expect(sentMessages.find((message) => message.type === "UPDATE_ENTRY")).toMatchObject({246          payload: {247            id: "e1",248            patch: { title: "Acme Updated", password: "current-password" },249          },250        });251      });252      const patch = sentMessages.find((message) => message.type === "UPDATE_ENTRY")?.payload?.patch;253      expect(patch).not.toHaveProperty("totp");254      expect(patch).not.toHaveProperty("passwordHistory");255      dismiss();256    } finally {257      attachSpy.mockRestore();258    }259  });260261  it("passes the card command capability into the selected card fill", async () => {262    const originalAttachShadow = Element.prototype.attachShadow;263    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {264      return originalAttachShadow.call(this, { mode: "open" });265    });266    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });267    const onFill = vi.fn();268    setMessageResponder((message) =>269      message.type === "GET_ENTRY_FOR_FILL"270        ? { ok: true, data: { id: "card-1", type: "card", title: "Personal Visa", cardNumber: "4111111111111111" } }271        : { ok: true, data: null },272    );273274    try {275      const dismiss = showCardSelector(276        anchor,277        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],278        { domain: "shop.example", capability: "card-capability", onFill } as Parameters<typeof showCardSelector>[2],279      );280      const host = selectorHost();281      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();282283      await vi.waitFor(() => {284        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({285          payload: { id: "card-1", capability: "card-capability" },286        });287      });288      dismiss();289    } finally {290      attachSpy.mockRestore();291    }292  });293294  it("allows only one card secret action per command capability", async () => {295    const originalAttachShadow = Element.prototype.attachShadow;296    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {297      return originalAttachShadow.call(this, { mode: "open" });298    });299    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });300    setMessageResponder((message) =>301      message.type === "GET_ENTRY_FOR_FILL"302        ? { ok: true, data: { id: "card-1", type: "card", cardNumber: "4111111111111111", cvc: "123" } }303        : { ok: true, data: null },304    );305306    try {307      const dismiss = showCardSelector(308        anchor,309        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],310        { domain: "shop.example", capability: "card-capability", onFill: vi.fn() },311      );312      const host = selectorHost();313      const copyNumber = host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy card number"]');314      const fill = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn");315      copyNumber?.click();316      fill?.click();317318      await vi.waitFor(() => {319        expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);320      });321      dismiss();322    } finally {323      attachSpy.mockRestore();324    }325  });326});