123456789101112131415161718import "./_setup"; 19import { 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];27282930function 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 44 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 54 55 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 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 74 75 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});