12345678import { vi } from "vitest";910const { predictBatch, dismissSpy } = vi.hoisted(() => ({11 predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),12 dismissSpy: vi.fn(),13}));1415vi.mock("./inject", () => ({16 injectFieldIcon: vi.fn(() => vi.fn()),17 showAutofillSelector: vi.fn(() => dismissSpy),18 showTOTPSelector: vi.fn(() => dismissSpy),19 showCardSelector: vi.fn(() => dismissSpy),20 showRememberSiteToast: vi.fn(),21 showPasskeyAvailable: vi.fn(),22 showPasskeyProGate: vi.fn(),23 showPasskeyRegistrationOffer: vi.fn(),24}));2526vi.mock("./content-ml-detector", () => ({27 contentMLDetector: { predictBatch },28}));2930import { afterEach, beforeEach, describe, expect, it } from "vitest";3132333435async function loadSubject() {36 vi.resetModules();37 const setup = await import("./_setup");38 const index = await import("./index");39 const inject = await import("./inject");40 return {41 setup,42 handleFieldInteraction: index.handleFieldInteraction,43 showSelector: inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>,44 showTOTP: inject.showTOTPSelector as unknown as ReturnType<typeof vi.fn>,45 };46}4748beforeEach(() => {49 document.body.innerHTML = "";50 predictBatch.mockReset();51 predictBatch.mockReturnValue(Promise.resolve([]));52});5354afterEach(async () => {55 vi.useRealTimers();56 57 58 await new Promise((r) => setTimeout(r, 0));59});6061describe("PB-26-105: form-action gate on every fill path", () => {62 it("TOTP-only path refuses a live code when the form posts off-site", async () => {63 const { setup, handleFieldInteraction, showTOTP } = await loadSubject();64 const { makeInput } = setup;65 const form = document.createElement("form");66 form.action = "https://evil.example/verify";67 document.body.appendChild(form);68 const otp = makeInput(69 { type: "text", name: "otp", autocomplete: "one-time-code" },70 form,71 );72 setup.setMessageResponder((msg: any) => {73 if (msg.type === "AUTOFILL_QUERY") {74 return { ok: true, data: [{ id: "t1", title: "Acme", username: "alice" }] };75 }76 if (msg.type === "GET_ENTRY_FOR_FILL") {77 return {78 ok: true,79 data: { id: "t1", title: "Acme", username: "alice", totpCode: "123456" },80 };81 }82 return { ok: true, data: null };83 });8485 await handleFieldInteraction(otp, true);86 await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());87 await (showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>)("t1");8889 expect(otp.value).toBe("");90 });9192 it("TOTP-only path still fills when the form posts to the page's own domain", async () => {93 const { setup, handleFieldInteraction, showTOTP } = await loadSubject();94 const form = document.createElement("form");95 form.action = "https://localhost/verify";96 document.body.appendChild(form);97 const otp = setup.makeInput(98 { type: "text", name: "otp", autocomplete: "one-time-code" },99 form,100 );101 setup.setMessageResponder((msg: any) => {102 if (msg.type === "AUTOFILL_QUERY") {103 return { ok: true, data: [{ id: "t1", title: "Acme", username: "alice" }] };104 }105 if (msg.type === "GET_ENTRY_FOR_FILL") {106 return {107 ok: true,108 data: { id: "t1", title: "Acme", username: "alice", totpCode: "123456" },109 };110 }111 return { ok: true, data: null };112 });113114 await handleFieldInteraction(otp, true);115 await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());116 await (showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>)("t1");117118 expect(otp.value).toBe("123456");119 });120121 it("signup fill writes neither password nor confirm-password when refused", async () => {122 const { setup, handleFieldInteraction, showSelector } = await loadSubject();123 const form = document.createElement("form");124 form.action = "https://evil.example/signup";125 document.body.appendChild(form);126 setup.makeInput({ type: "text", name: "username", autocomplete: "username" }, form);127 const password = setup.makeInput(128 { type: "password", name: "password", autocomplete: "new-password" },129 form,130 );131 const confirm = setup.makeInput(132 { type: "password", name: "confirmPassword", autocomplete: "new-password" },133 form,134 );135 setup.setMessageResponder((msg: any) =>136 msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null },137 );138139 await handleFieldInteraction(password, true);140 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());141142 const options = showSelector.mock.calls.at(-1)![3] as any;143 expect(options.isSignup).toBe(true);144 await options.onFillFields("alice", "correct-horse-battery", "");145146 expect(password.value).toBe("");147 expect(confirm.value).toBe("");148 });149150 it("refused credential fill writes no TOTP, no clipboard and no canary ping", async () => {151 const { setup, handleFieldInteraction, showSelector } = await loadSubject();152 const { form, username, password } = setup.createLoginForm();153 form.action = "https://evil.example/login";154 setup.setMessageResponder((msg: any) => {155 if (msg.type === "AUTOFILL_QUERY") {156 return { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] };157 }158 if (msg.type === "GET_ENTRY_FOR_FILL") {159 return {160 ok: true,161 data: {162 id: "e1",163 title: "Acme",164 username: "alice",165 password: "correct-horse",166 totpCode: "123456",167 canary: { webhookUrl: "https://hooks.example/x", pingCount: 0 },168 },169 };170 }171 return { ok: true, data: null };172 });173174 await handleFieldInteraction(username, true);175 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());176177 await (showSelector.mock.calls.at(-1)![2] as (id: string) => Promise<void>)("e1");178179 expect(username.value).toBe("");180 expect(password.value).toBe("");181 expect(setup.clipboardWrite).not.toHaveBeenCalled();182 expect(setup.sentMessages.some((m: any) => m?.type === "FIRE_CANARY_PING")).toBe(false);183 });184});