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