@prosopo/procaptcha-frictionless 2.13.22 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,388 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { Ti18n } from "@prosopo/locale";
16
+ import {
17
+ type Account,
18
+ type BotDetectionFunction,
19
+ type BotDetectionFunctionResult,
20
+ CaptchaType,
21
+ ModeEnum,
22
+ PROCAPTCHA_START_EVENT,
23
+ type ProcaptchaClientConfigInput,
24
+ type ProcaptchaProps,
25
+ type ProcaptchaStartEventDetail,
26
+ type RandomProvider,
27
+ StartModeEnum,
28
+ } from "@prosopo/types";
29
+ import { type ReactElement, act, createElement } from "react";
30
+ import { type Root, createRoot } from "react-dom/client";
31
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
32
+
33
+ declare global {
34
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
35
+ }
36
+
37
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
38
+
39
+ type InnerWidget = "pow" | "image" | "puzzle";
40
+
41
+ const mocks = vi.hoisted(() => ({
42
+ mounts: [] as {
43
+ widget: "pow" | "image" | "puzzle";
44
+ props: ProcaptchaProps;
45
+ }[],
46
+ }));
47
+
48
+ const stub = (widget: InnerWidget) => (props: ProcaptchaProps) => {
49
+ mocks.mounts.push({ widget, props });
50
+ return createElement("div", { "data-widget": widget });
51
+ };
52
+
53
+ vi.mock("@prosopo/procaptcha-pow", () => ({ ProcaptchaPow: stub("pow") }));
54
+ vi.mock("@prosopo/procaptcha-react", () => ({ Procaptcha: stub("image") }));
55
+ vi.mock("@prosopo/procaptcha-puzzle", () => ({
56
+ ProcaptchaPuzzle: stub("puzzle"),
57
+ }));
58
+
59
+ vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
60
+ const actual =
61
+ await importOriginal<typeof import("@prosopo/procaptcha-common")>();
62
+ return { ...actual, isSecureBrowserContext: () => true };
63
+ });
64
+
65
+ const { ProcaptchaFrictionless } = await import("../ProcaptchaFrictionless.js");
66
+
67
+ const SITE_KEY = "5siteKey";
68
+ const SESSION_ID = "provider-session";
69
+
70
+ const config = (
71
+ overrides: Partial<ProcaptchaClientConfigInput> = {},
72
+ ): ProcaptchaClientConfigInput => ({
73
+ account: { address: SITE_KEY },
74
+ userAccountAddress: "",
75
+ web2: true,
76
+ mode: ModeEnum.visible,
77
+ startMode: StartModeEnum.manual,
78
+ ...overrides,
79
+ });
80
+
81
+ const i18nStub = {
82
+ isInitialized: true,
83
+ language: "en",
84
+ t: (key: string) => key,
85
+ changeLanguage: vi.fn(),
86
+ } as unknown as Ti18n;
87
+
88
+ const detectionResult = (): BotDetectionFunctionResult => ({
89
+ status: "ok",
90
+ captchaType: CaptchaType.image,
91
+ sessionId: SESSION_ID,
92
+ provider: { provider: { url: "https://provider.test" } } as RandomProvider,
93
+ userAccount: { account: { address: "5FakeUserAccountAddress" } } as Account,
94
+ });
95
+
96
+ const detectBotResolving = () =>
97
+ vi.fn<BotDetectionFunction>().mockResolvedValue(detectionResult());
98
+
99
+ let host: HTMLDivElement;
100
+ let root: Root;
101
+
102
+ interface MountOptions {
103
+ config?: ProcaptchaClientConfigInput;
104
+ detectBot?: ReturnType<typeof detectBotResolving>;
105
+ container?: HTMLElement;
106
+ }
107
+
108
+ const mountWrapper = async (
109
+ options: MountOptions = {},
110
+ ): Promise<ReturnType<typeof detectBotResolving>> => {
111
+ const detectBot = options.detectBot ?? detectBotResolving();
112
+ await act(async () => {
113
+ root.render(
114
+ createElement(ProcaptchaFrictionless, {
115
+ config: options.config ?? config(),
116
+ callbacks: {},
117
+ restart: vi.fn(),
118
+ i18n: i18nStub,
119
+ detectBot,
120
+ container: options.container,
121
+ }) as ReactElement,
122
+ );
123
+ });
124
+ return detectBot;
125
+ };
126
+
127
+ const checkbox = (): HTMLInputElement => {
128
+ const element = host.querySelector<HTMLInputElement>(
129
+ '[data-cy="captcha-checkbox"]',
130
+ );
131
+ if (!element) throw new Error("expected the checkbox to be on the page");
132
+ return element;
133
+ };
134
+
135
+ const spinner = (): Element | null =>
136
+ host.querySelector('[aria-label="Loading spinner"]');
137
+
138
+ const lastMountOf = (widget: InnerWidget) => {
139
+ const mount = mocks.mounts.filter((m) => m.widget === widget).at(-1);
140
+ if (!mount) throw new Error(`expected the ${widget} widget to have mounted`);
141
+ return mount;
142
+ };
143
+
144
+ // jsdom exposes `isTrusted` as a non-configurable accessor on its internal
145
+ // implementation object, so the flag has to be pinned there.
146
+ const setTrusted = (event: Event, trusted: boolean): void => {
147
+ for (const symbol of Object.getOwnPropertySymbols(event)) {
148
+ const impl: unknown = Reflect.get(event, symbol);
149
+ if (impl && typeof impl === "object" && "isTrusted" in impl) {
150
+ Object.defineProperty(impl, "isTrusted", {
151
+ configurable: true,
152
+ get: () => trusted,
153
+ set: () => undefined,
154
+ });
155
+ return;
156
+ }
157
+ }
158
+ throw new Error("could not reach the jsdom event implementation");
159
+ };
160
+
161
+ interface ClickOptions {
162
+ trusted?: boolean;
163
+ clientX?: number;
164
+ clientY?: number;
165
+ }
166
+
167
+ const click = async (
168
+ element: Element,
169
+ options: ClickOptions = {},
170
+ ): Promise<void> => {
171
+ const event = new MouseEvent("click", {
172
+ bubbles: true,
173
+ cancelable: true,
174
+ clientX: options.clientX ?? 0,
175
+ clientY: options.clientY ?? 0,
176
+ });
177
+ setTrusted(event, options.trusted ?? true);
178
+ await act(async () => {
179
+ element.dispatchEvent(event);
180
+ });
181
+ };
182
+
183
+ const dispatchStart = async (
184
+ detail?: ProcaptchaStartEventDetail,
185
+ ): Promise<void> => {
186
+ await act(async () => {
187
+ document.dispatchEvent(
188
+ new CustomEvent<ProcaptchaStartEventDetail>(PROCAPTCHA_START_EVENT, {
189
+ detail,
190
+ bubbles: true,
191
+ }),
192
+ );
193
+ });
194
+ };
195
+
196
+ const dispatchExecute = async (): Promise<void> => {
197
+ await act(async () => {
198
+ document.dispatchEvent(new CustomEvent("procaptcha:execute"));
199
+ });
200
+ };
201
+
202
+ beforeEach(() => {
203
+ mocks.mounts.length = 0;
204
+ host = document.createElement("div");
205
+ document.body.appendChild(host);
206
+ act(() => {
207
+ root = createRoot(host);
208
+ });
209
+ });
210
+
211
+ afterEach(() => {
212
+ act(() => {
213
+ root.unmount();
214
+ });
215
+ host.remove();
216
+ vi.clearAllMocks();
217
+ });
218
+
219
+ describe("manual start mode", () => {
220
+ it("mounts a live checkbox without running detection", async () => {
221
+ const detectBot = await mountWrapper();
222
+
223
+ expect(detectBot).not.toHaveBeenCalled();
224
+ expect(mocks.mounts).toHaveLength(0);
225
+ expect(checkbox().disabled).toBe(false);
226
+ expect(spinner()).toBeNull();
227
+ });
228
+
229
+ it("still runs detection on mount in auto mode", async () => {
230
+ const detectBot = await mountWrapper({
231
+ config: config({ startMode: StartModeEnum.auto }),
232
+ });
233
+
234
+ expect(detectBot).toHaveBeenCalledTimes(1);
235
+ expect(lastMountOf("image").props.autoStart).toBe(false);
236
+ });
237
+
238
+ it("defaults to auto when no start mode is given", async () => {
239
+ const detectBot = await mountWrapper({
240
+ config: config({ startMode: undefined }),
241
+ });
242
+
243
+ expect(detectBot).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+ describe("started by the site", () => {
247
+ it("runs detection on procaptcha:start and leaves the widget waiting for a click", async () => {
248
+ const detectBot = await mountWrapper();
249
+
250
+ await dispatchStart();
251
+
252
+ expect(detectBot).toHaveBeenCalledTimes(1);
253
+ const mount = lastMountOf("image").props;
254
+ expect(mount.autoStart).toBe(false);
255
+ expect(mount.startCoords).toBeUndefined();
256
+ expect(mount.frictionlessState?.sessionId).toBe(SESSION_ID);
257
+ });
258
+
259
+ it("honours an event addressed to its own element", async () => {
260
+ const widgetElement = document.createElement("div");
261
+ const container = document.createElement("div");
262
+ widgetElement.appendChild(container);
263
+ const detectBot = await mountWrapper({ container });
264
+
265
+ await dispatchStart({ element: widgetElement });
266
+
267
+ expect(detectBot).toHaveBeenCalledTimes(1);
268
+ });
269
+
270
+ it("ignores an event addressed to another widget's element", async () => {
271
+ const container = document.createElement("div");
272
+ document.createElement("div").appendChild(container);
273
+ const detectBot = await mountWrapper({ container });
274
+
275
+ await dispatchStart({ element: document.createElement("div") });
276
+
277
+ expect(detectBot).not.toHaveBeenCalled();
278
+ expect(checkbox().disabled).toBe(false);
279
+ });
280
+
281
+ it("receives every event when mounted without a container", async () => {
282
+ const detectBot = await mountWrapper();
283
+
284
+ await dispatchStart({ element: document.createElement("div") });
285
+
286
+ expect(detectBot).toHaveBeenCalledTimes(1);
287
+ });
288
+
289
+ it("opens the challenge straight away on procaptcha:execute", async () => {
290
+ const detectBot = await mountWrapper({
291
+ config: config({ mode: ModeEnum.invisible }),
292
+ });
293
+
294
+ await dispatchExecute();
295
+
296
+ expect(detectBot).toHaveBeenCalledTimes(1);
297
+ expect(lastMountOf("image").props.autoStart).toBe(true);
298
+ });
299
+
300
+ it("ignores the events once started", async () => {
301
+ const detectBot = await mountWrapper();
302
+
303
+ await dispatchStart();
304
+ await dispatchStart();
305
+ await dispatchExecute();
306
+
307
+ expect(detectBot).toHaveBeenCalledTimes(1);
308
+ expect(mocks.mounts).toHaveLength(1);
309
+ });
310
+
311
+ it("does not react to the events in auto mode", async () => {
312
+ const detectBot = await mountWrapper({
313
+ config: config({ startMode: StartModeEnum.auto }),
314
+ });
315
+ expect(detectBot).toHaveBeenCalledTimes(1);
316
+
317
+ await dispatchStart();
318
+
319
+ expect(detectBot).toHaveBeenCalledTimes(1);
320
+ });
321
+ });
322
+
323
+ describe("started by the user", () => {
324
+ it("runs detection on a checkbox click and opens the challenge where the user clicked", async () => {
325
+ const detectBot = await mountWrapper();
326
+
327
+ await click(checkbox(), { clientX: 11, clientY: 22 });
328
+
329
+ expect(detectBot).toHaveBeenCalledTimes(1);
330
+ const mount = lastMountOf("image").props;
331
+ expect(mount.autoStart).toBe(true);
332
+ expect(mount.startCoords).toEqual({ x: 11, y: 22 });
333
+ });
334
+
335
+ it("opens the challenge after a keyboard activation with no position", async () => {
336
+ const detectBot = await mountWrapper();
337
+
338
+ await click(checkbox());
339
+
340
+ expect(detectBot).toHaveBeenCalledTimes(1);
341
+ const mount = lastMountOf("image").props;
342
+ expect(mount.autoStart).toBe(true);
343
+ expect(mount.startCoords).toBeUndefined();
344
+ });
345
+
346
+ it("shows the spinner while the deferred flow runs", async () => {
347
+ let finish: ((result: BotDetectionFunctionResult) => void) | undefined;
348
+ const detectBot = vi.fn<BotDetectionFunction>().mockImplementation(
349
+ () =>
350
+ new Promise<BotDetectionFunctionResult>((resolve) => {
351
+ finish = resolve;
352
+ }),
353
+ );
354
+ await mountWrapper({ detectBot });
355
+
356
+ await click(checkbox(), { clientX: 1, clientY: 1 });
357
+
358
+ expect(spinner()).not.toBeNull();
359
+ expect(mocks.mounts).toHaveLength(0);
360
+
361
+ await act(async () => {
362
+ finish?.(detectionResult());
363
+ });
364
+
365
+ expect(spinner()).toBeNull();
366
+ expect(lastMountOf("image").props.autoStart).toBe(true);
367
+ });
368
+
369
+ it("ignores a synthetic click", async () => {
370
+ const detectBot = await mountWrapper();
371
+
372
+ await click(checkbox(), { trusted: false, clientX: 1, clientY: 1 });
373
+
374
+ expect(detectBot).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it("starts once even if the site also asks", async () => {
378
+ const detectBot = await mountWrapper();
379
+
380
+ await click(checkbox(), { clientX: 3, clientY: 4 });
381
+ await dispatchStart();
382
+
383
+ expect(detectBot).toHaveBeenCalledTimes(1);
384
+ expect(mocks.mounts).toHaveLength(1);
385
+ expect(lastMountOf("image").props.startCoords).toEqual({ x: 3, y: 4 });
386
+ });
387
+ });
388
+ });