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 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 64 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 74 75 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 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 94 95 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});