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("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});