@prosopo/procaptcha-pow 2.13.7 → 2.13.8

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.
@@ -1,579 +0,0 @@
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, TranslateOptions } from "@prosopo/locale";
16
- import {
17
- ModeEnum,
18
- type ProcaptchaProps,
19
- type ProcaptchaState,
20
- } from "@prosopo/types";
21
- import {
22
- type Mock,
23
- afterEach,
24
- beforeEach,
25
- describe,
26
- expect,
27
- test,
28
- vi,
29
- } from "vitest";
30
- import {
31
- type ProcaptchaPowHandle,
32
- mountProcaptchaPowWidget,
33
- } from "../components/procaptchaWidget.js";
34
- import { type Mounted, fire, mount, settle } from "./domHarness.js";
35
- import { config, frictionless } from "./managerHarness.js";
36
-
37
- /**
38
- * The widget is a thin shell over the manager: these tests cover the wiring it
39
- * owns — when a solve is triggered, what coordinates it carries, and what it
40
- * does with an invalidated session — with the manager itself stubbed out.
41
- */
42
- const mocks = vi.hoisted(() => {
43
- const start = vi.fn<(x?: number, y?: number) => Promise<void>>();
44
- const resetState = vi.fn<() => void>();
45
- const dispose = vi.fn<() => void>();
46
- const constructions: {
47
- updateState: (next: Partial<ProcaptchaState>) => void;
48
- getHoneypotValue?: () => string | undefined;
49
- }[] = [];
50
- const loadI18next = vi.fn<(a?: boolean, b?: string) => Promise<unknown>>();
51
- const translationsReady = { current: true };
52
- return {
53
- translationsReady,
54
- start,
55
- resetState,
56
- dispose,
57
- constructions,
58
- loadI18next,
59
- };
60
- });
61
-
62
- vi.mock("../services/Manager.js", () => ({
63
- Manager: (
64
- _config: unknown,
65
- _state: ProcaptchaState,
66
- updateState: (next: Partial<ProcaptchaState>) => void,
67
- _callbacks: unknown,
68
- _frictionlessState: unknown,
69
- _onEscalate: unknown,
70
- getHoneypotValue?: () => string | undefined,
71
- ) => {
72
- mocks.constructions.push({ updateState, getHoneypotValue });
73
- return {
74
- start: mocks.start,
75
- resetState: mocks.resetState,
76
- dispose: mocks.dispose,
77
- };
78
- },
79
- }));
80
-
81
- vi.mock("@prosopo/locale", async (importOriginal) => {
82
- const actual = await importOriginal<typeof import("@prosopo/locale")>();
83
- return {
84
- ...actual,
85
- loadI18next: mocks.loadI18next,
86
- createTranslator: () => ({
87
- t: (key: string) => key,
88
- isReady: () => mocks.translationsReady.current,
89
- subscribe: () => () => undefined,
90
- i18n: translatorI18n,
91
- }),
92
- };
93
- });
94
-
95
- let mounted: Mounted;
96
- let widget: ProcaptchaPowHandle | undefined;
97
-
98
- const i18nStub = (
99
- language: string,
100
- changeLanguage: Mock<(l: string) => void>,
101
- ) => ({ language, changeLanguage }) as unknown as Ti18n;
102
-
103
- // Stands in for the shared i18n instance after the widget's own language (de)
104
- // has loaded; lookups follow i18nFrontend.ts: catalogue, then defaultValue.
105
- let translatorI18n: Ti18n | undefined;
106
- const germanI18n = (): Ti18n => {
107
- const catalogue: Record<string, string> = {
108
- "API.INVALID_SITE_KEY": "Ungültiger Site-Schlüssel",
109
- };
110
- return {
111
- language: "de",
112
- isInitialized: true,
113
- t: (key: string, options?: TranslateOptions): string =>
114
- catalogue[key] ?? options?.defaultValue ?? key,
115
- changeLanguage: async (): Promise<void> => undefined,
116
- hasLoadedNamespace: (): boolean => true,
117
- on: (): void => undefined,
118
- off: (): void => undefined,
119
- };
120
- };
121
-
122
- const props = (overrides: Partial<ProcaptchaProps> = {}): ProcaptchaProps => ({
123
- config: config(),
124
- callbacks: {},
125
- i18n: undefined as unknown as Ti18n,
126
- ...overrides,
127
- });
128
-
129
- const render = (widgetProps: ProcaptchaProps): void => {
130
- widget = mountProcaptchaPowWidget(mounted.container, widgetProps);
131
- };
132
-
133
- beforeEach(() => {
134
- translatorI18n = undefined;
135
- vi.clearAllMocks();
136
- mocks.constructions.length = 0;
137
- mocks.translationsReady.current = true;
138
- mocks.start.mockResolvedValue(undefined);
139
- mocks.loadI18next.mockResolvedValue(undefined);
140
- widget = undefined;
141
- mounted = mount();
142
- });
143
-
144
- afterEach(() => {
145
- widget?.destroy();
146
- widget = undefined;
147
- mounted.unmount();
148
- vi.restoreAllMocks();
149
- });
150
-
151
- const checkbox = (): HTMLInputElement => {
152
- const element = mounted.container.querySelector<HTMLInputElement>(
153
- 'input[type="checkbox"]',
154
- );
155
- if (!element) throw new Error("expected a checkbox to be rendered");
156
- return element;
157
- };
158
-
159
- const honeypotInput = (): HTMLInputElement => {
160
- const element = document.querySelector<HTMLInputElement>(
161
- 'input[name="email_confirm"]',
162
- );
163
- if (!element) throw new Error("expected a honeypot to be rendered");
164
- return element;
165
- };
166
-
167
- const spinner = (): Element | null =>
168
- mounted.container.querySelector('[role="status"]');
169
-
170
- interface ClickOptions {
171
- trusted?: boolean;
172
- clientX?: number;
173
- clientY?: number;
174
- touches?: { clientX: number; clientY: number }[];
175
- }
176
-
177
- /** Click the real checkbox with the browser event a user would produce. */
178
- const click = (options: ClickOptions = {}): void => {
179
- fire(checkbox(), "click", options);
180
- };
181
-
182
- const setState = async (next: Partial<ProcaptchaState>): Promise<void> => {
183
- mocks.constructions[0]?.updateState(next);
184
- await settle();
185
- };
186
-
187
- describe("what the widget renders", () => {
188
- test("a visible widget shows a checkbox", () => {
189
- render(props());
190
- expect(checkbox()).toBeDefined();
191
- });
192
-
193
- test("an invisible widget shows no checkbox at all", () => {
194
- render(props({ config: config({ mode: ModeEnum.invisible }) }));
195
- expect(
196
- mounted.container.querySelector('input[type="checkbox"]'),
197
- ).toBeNull();
198
- });
199
-
200
- test("a session with a honeypot question renders the honeypot", () => {
201
- render(
202
- props({ frictionlessState: frictionless({ hp: btoa("question") }) }),
203
- );
204
- expect(honeypotInput()).toBeDefined();
205
- expect(document.body.textContent).toContain("question");
206
- });
207
-
208
- test("a session without one renders no bait at all", () => {
209
- render(props({ frictionlessState: frictionless() }));
210
- expect(document.querySelector('input[name="email_confirm"]')).toBeNull();
211
- });
212
-
213
- test("an invisible widget still renders the honeypot, as bait", () => {
214
- render(
215
- props({
216
- config: config({ mode: ModeEnum.invisible }),
217
- frictionlessState: frictionless({ hp: btoa("question") }),
218
- }),
219
- );
220
- expect(honeypotInput()).toBeDefined();
221
- });
222
-
223
- test("the manager can read the honeypot input the widget rendered", () => {
224
- render(
225
- props({ frictionlessState: frictionless({ hp: btoa("question") }) }),
226
- );
227
- honeypotInput().value = "bot@example.com";
228
- expect(mocks.constructions[0]?.getHoneypotValue?.()).toBe(
229
- "bot@example.com",
230
- );
231
- });
232
-
233
- test("a dark-themed widget still renders its checkbox", () => {
234
- render(props({ config: config({ theme: "dark" }) }));
235
- expect(checkbox()).toBeDefined();
236
- });
237
-
238
- test("the checkbox is labelled once the translations are ready", () => {
239
- render(props());
240
- expect(checkbox().getAttribute("aria-label")).toBe("WIDGET.I_AM_HUMAN");
241
- });
242
-
243
- test("the checkbox is left unlabelled until the translations load", () => {
244
- // A key like WIDGET.I_AM_HUMAN rendered raw is worse than no label.
245
- mocks.translationsReady.current = false;
246
- render(props());
247
- expect(checkbox().getAttribute("aria-label")).toBe("");
248
- });
249
-
250
- test("a page that registered no callbacks still renders", () => {
251
- // Consumers reach this component from plain JavaScript, where the prop
252
- // can simply be absent.
253
- render(
254
- props({
255
- callbacks: undefined as unknown as ProcaptchaProps["callbacks"],
256
- }),
257
- );
258
- expect(checkbox()).toBeDefined();
259
- });
260
-
261
- test("the checkbox shows the current error", async () => {
262
- render(props());
263
- await setState({
264
- error: { message: "no session", key: "API.UNKNOWN_ERROR" },
265
- });
266
- expect(mounted.container.textContent).toContain("no session");
267
- });
268
-
269
- test("the error is shown in the widget's language, not the provider's", async () => {
270
- translatorI18n = germanI18n();
271
- render(props());
272
- await setState({
273
- error: { message: "Invalid site key", key: "API.INVALID_SITE_KEY" },
274
- });
275
- expect(mounted.container.textContent).toContain(
276
- "Ungültiger Site-Schlüssel",
277
- );
278
- expect(mounted.container.textContent).not.toContain("Invalid site key");
279
- });
280
-
281
- test("an unfilled honeypot reads as nothing, not as an empty answer", () => {
282
- render(
283
- props({ frictionlessState: frictionless({ hp: btoa("question") }) }),
284
- );
285
- expect(mocks.constructions[0]?.getHoneypotValue?.()).toBeUndefined();
286
- });
287
-
288
- test("with no honeypot rendered the reader still answers safely", () => {
289
- render(props());
290
- expect(mocks.constructions[0]?.getHoneypotValue?.()).toBeUndefined();
291
- });
292
-
293
- test("the honeypot leaves the page with the widget", () => {
294
- render(
295
- props({ frictionlessState: frictionless({ hp: btoa("question") }) }),
296
- );
297
- widget?.destroy();
298
- widget = undefined;
299
- expect(document.querySelector('input[name="email_confirm"]')).toBeNull();
300
- });
301
- });
302
-
303
- describe("starting a solve", () => {
304
- test("a click starts the manager with the coordinates of the click", () => {
305
- render(props());
306
- click({ clientX: 12, clientY: 34 });
307
- expect(mocks.start).toHaveBeenCalledWith(12, 34);
308
- });
309
-
310
- test("a tap is read from the touch that produced it", () => {
311
- render(props());
312
- click({ touches: [{ clientX: 7, clientY: 9 }] });
313
- expect(mocks.start).toHaveBeenCalledWith(7, 9);
314
- });
315
-
316
- test("a tap with no touches falls back to the pointer coordinates", () => {
317
- render(props());
318
- click({ touches: [], clientX: 3, clientY: 4 });
319
- expect(mocks.start).toHaveBeenCalledWith(3, 4);
320
- });
321
-
322
- test("a synthetic click never reaches the manager", () => {
323
- // The checkbox drops untrusted events before the widget sees them, so an
324
- // automated solver cannot start a session at all. The widget's own
325
- // isTrusted guard on the coordinates is defence in depth behind it.
326
- render(props());
327
- click({ trusted: false, clientX: 12, clientY: 34 });
328
- expect(mocks.start).not.toHaveBeenCalled();
329
- });
330
-
331
- test("a keyboard activation is treated as the origin", () => {
332
- render(props());
333
- fire(checkbox(), "keydown", { key: "Enter" });
334
- expect(mocks.start).toHaveBeenCalledWith(0, 0);
335
- });
336
-
337
- test("the checkbox shows a spinner for as long as the solve runs", async () => {
338
- let release = (): void => undefined;
339
- mocks.start.mockImplementation(
340
- () =>
341
- new Promise<void>((resolve: () => void) => {
342
- release = () => resolve();
343
- }),
344
- );
345
- render(props());
346
- click();
347
- await settle();
348
- expect(spinner()).not.toBeNull();
349
- release();
350
- await settle();
351
- expect(spinner()).toBeNull();
352
- });
353
-
354
- test("a solve that rejects still clears the spinner", async () => {
355
- // Nothing else would: the promise is not awaited by the caller, so the
356
- // rejection used to escape unhandled with the spinner still turning.
357
- vi.spyOn(console, "error").mockImplementation(() => undefined);
358
- mocks.start.mockRejectedValue(new Error("provider down"));
359
- render(props({ autoStart: true }));
360
- await settle();
361
- expect(spinner()).toBeNull();
362
- });
363
-
364
- test("a click whose solve rejects clears the spinner too", async () => {
365
- vi.spyOn(console, "error").mockImplementation(() => undefined);
366
- mocks.start.mockRejectedValue(new Error("provider down"));
367
- render(props());
368
- click({ clientX: 1, clientY: 2 });
369
- await settle();
370
- expect(spinner()).toBeNull();
371
- });
372
-
373
- test("a second click while the first solve is running is ignored", async () => {
374
- let release = (): void => undefined;
375
- mocks.start.mockImplementation(
376
- () =>
377
- new Promise<void>((resolve: () => void) => {
378
- release = () => resolve();
379
- }),
380
- );
381
- render(props());
382
- click();
383
- click();
384
- expect(mocks.start).toHaveBeenCalledTimes(1);
385
- release();
386
- await settle();
387
- });
388
-
389
- test("an autoStart widget solves without waiting for a click", () => {
390
- render(props({ autoStart: true }));
391
- expect(mocks.start).toHaveBeenCalledWith(0, 0);
392
- });
393
-
394
- test("an autoStart widget resumes with the coordinates it was handed", () => {
395
- render(props({ autoStart: true, startCoords: { x: 5, y: 6 } }));
396
- expect(mocks.start).toHaveBeenCalledWith(5, 6);
397
- });
398
-
399
- test("a widget that was not asked to autoStart stays idle", () => {
400
- render(props());
401
- expect(mocks.start).not.toHaveBeenCalled();
402
- });
403
- });
404
-
405
- describe("the invisible-mode execute event", () => {
406
- const execute = (): void => {
407
- document.dispatchEvent(new Event("procaptcha:execute"));
408
- };
409
-
410
- test("starts a solve", () => {
411
- render(props({ config: config({ mode: ModeEnum.invisible }) }));
412
- execute();
413
- expect(mocks.start).toHaveBeenCalledTimes(1);
414
- });
415
-
416
- test("is ignored by a visible widget", () => {
417
- render(props());
418
- execute();
419
- expect(mocks.start).not.toHaveBeenCalled();
420
- });
421
-
422
- const executeOn = async (target: Element): Promise<void> => {
423
- target.dispatchEvent(new Event("procaptcha:execute"));
424
- await settle();
425
- };
426
-
427
- test("a targeted execute on the container runs a visible widget", async () => {
428
- const target = document.createElement("div");
429
- render(props({ container: target }));
430
- await executeOn(target);
431
- expect(mocks.start).toHaveBeenCalledTimes(1);
432
- });
433
-
434
- test("a targeted execute on the container runs an invisible widget", async () => {
435
- const target = document.createElement("div");
436
- render(
437
- props({
438
- config: config({ mode: ModeEnum.invisible }),
439
- container: target,
440
- }),
441
- );
442
- await executeOn(target);
443
- expect(mocks.start).toHaveBeenCalledTimes(1);
444
- });
445
-
446
- test("stops being listened for once the widget is gone", () => {
447
- render(props({ config: config({ mode: ModeEnum.invisible }) }));
448
- widget?.destroy();
449
- widget = undefined;
450
- execute();
451
- expect(mocks.start).not.toHaveBeenCalled();
452
- });
453
-
454
- test("a manager that throws on start does not take the page down with it", () => {
455
- vi.spyOn(console, "error").mockImplementation(() => undefined);
456
- mocks.start.mockImplementation(() => {
457
- throw new Error("no provider");
458
- });
459
- render(props({ config: config({ mode: ModeEnum.invisible }) }));
460
- expect(execute).not.toThrow();
461
- });
462
-
463
- test("a solve that rejects is reported rather than left unhandled", async () => {
464
- const reported = vi
465
- .spyOn(console, "error")
466
- .mockImplementation(() => undefined);
467
- mocks.start.mockRejectedValue(new Error("no provider"));
468
- render(props({ config: config({ mode: ModeEnum.invisible }) }));
469
- execute();
470
- await settle();
471
- expect(reported).toHaveBeenCalled();
472
- });
473
- });
474
-
475
- describe("an invalidated session", () => {
476
- const fail = async (key: string): Promise<void> => {
477
- await setState({ error: { message: "boom", key } });
478
- };
479
-
480
- test("is handed to the recovery-aware parent, with the original click", async () => {
481
- const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
482
- render(props({ onSessionInvalidated }));
483
- click({ clientX: 9, clientY: 8 });
484
- await fail("CAPTCHA.NO_SESSION_FOUND");
485
- expect(onSessionInvalidated).toHaveBeenCalledWith(9, 8);
486
- });
487
-
488
- test("is escalated once only, so a failing retry cannot loop", async () => {
489
- const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
490
- const restart = vi.fn<() => void>();
491
- render(
492
- props({
493
- onSessionInvalidated,
494
- frictionlessState: frictionless({ restart }),
495
- }),
496
- );
497
- await fail("CAPTCHA.NO_SESSION_FOUND");
498
- await setState({ error: undefined });
499
- await fail("CAPTCHA.NO_SESSION_FOUND");
500
- expect(onSessionInvalidated).toHaveBeenCalledTimes(1);
501
- await new Promise<void>((resolve: () => void) => setTimeout(resolve, 150));
502
- expect(restart).toHaveBeenCalledTimes(1);
503
- });
504
-
505
- test("without a recovery-aware parent the frictionless session restarts", async () => {
506
- const restart = vi.fn<() => void>();
507
- render(props({ frictionlessState: frictionless({ restart }) }));
508
- await fail("CAPTCHA.NO_SESSION_FOUND");
509
- expect(restart).not.toHaveBeenCalled();
510
- await new Promise<void>((resolve: () => void) => setTimeout(resolve, 150));
511
- expect(restart).toHaveBeenCalledTimes(1);
512
- });
513
-
514
- test("any other error is left for the widget to display", async () => {
515
- const onSessionInvalidated = vi.fn<(x?: number, y?: number) => void>();
516
- const restart = vi.fn<() => void>();
517
- render(
518
- props({
519
- onSessionInvalidated,
520
- frictionlessState: frictionless({ restart }),
521
- }),
522
- );
523
- await fail("API.UNKNOWN_ERROR");
524
- expect(onSessionInvalidated).not.toHaveBeenCalled();
525
- expect(restart).not.toHaveBeenCalled();
526
- });
527
-
528
- test("an error with nowhere to escalate to is simply shown", async () => {
529
- render(props());
530
- await expect(fail("CAPTCHA.NO_SESSION_FOUND")).resolves.toBeUndefined();
531
- });
532
- });
533
-
534
- describe("language", () => {
535
- test("a configured language with no i18n instance boots one", () => {
536
- render(props({ config: config({ language: "fr" }) }));
537
- expect(mocks.loadI18next).toHaveBeenCalledWith(false, "fr");
538
- });
539
-
540
- test("an existing i18n instance is switched rather than re-booted", () => {
541
- const changeLanguage = vi.fn<(l: string) => void>();
542
- render(
543
- props({
544
- config: config({ language: "fr" }),
545
- i18n: i18nStub("en", changeLanguage),
546
- }),
547
- );
548
- expect(changeLanguage).toHaveBeenCalledWith("fr");
549
- expect(mocks.loadI18next).not.toHaveBeenCalled();
550
- });
551
-
552
- test("an instance already in the right language is left alone", () => {
553
- const changeLanguage = vi.fn<(l: string) => void>();
554
- render(
555
- props({
556
- config: config({ language: "fr" }),
557
- i18n: i18nStub("fr", changeLanguage),
558
- }),
559
- );
560
- expect(changeLanguage).not.toHaveBeenCalled();
561
- });
562
-
563
- test("no configured language means the page's own choice stands", () => {
564
- const changeLanguage = vi.fn<(l: string) => void>();
565
- render(props({ i18n: i18nStub("en", changeLanguage) }));
566
- expect(changeLanguage).not.toHaveBeenCalled();
567
- expect(mocks.loadI18next).not.toHaveBeenCalled();
568
- });
569
- });
570
-
571
- describe("destroy", () => {
572
- test("disposes the manager so its expiry timers cannot outlive the widget", () => {
573
- render(props());
574
- expect(mocks.dispose).not.toHaveBeenCalled();
575
- widget?.destroy();
576
- widget = undefined;
577
- expect(mocks.dispose).toHaveBeenCalledTimes(1);
578
- });
579
- });
package/tsconfig.cjs.json DELETED
@@ -1,43 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.cjs.json",
3
- "compilerOptions": {
4
- "rootDir": "./src",
5
- "outDir": "./dist/cjs",
6
- "lib": ["es6", "dom"]
7
- },
8
- "include": [
9
- "./src/**/*.ts",
10
- "./src/**/*.json",
11
- "./src/**/*.d.ts",
12
- "./src/**/*.tsx"
13
- ],
14
- "references": [
15
- {
16
- "path": "../../dev/config/tsconfig.cjs.json"
17
- },
18
- {
19
- "path": "../api/tsconfig.cjs.json"
20
- },
21
- {
22
- "path": "../common/tsconfig.cjs.json"
23
- },
24
- {
25
- "path": "../locale/tsconfig.cjs.json"
26
- },
27
- {
28
- "path": "../procaptcha-common/tsconfig.cjs.json"
29
- },
30
- {
31
- "path": "../types/tsconfig.cjs.json"
32
- },
33
- {
34
- "path": "../util/tsconfig.cjs.json"
35
- },
36
- {
37
- "path": "../util-crypto/tsconfig.cjs.json"
38
- },
39
- {
40
- "path": "../widget-skeleton/tsconfig.cjs.json"
41
- }
42
- ]
43
- }
package/tsconfig.json DELETED
@@ -1,47 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.esm.json",
3
- "compilerOptions": {
4
- "rootDir": "./src",
5
- "outDir": "./dist",
6
- "lib": ["es6", "dom"]
7
- },
8
- "include": [
9
- "src",
10
- "src/**/*.json",
11
- "src/**/*.ts",
12
- "src/**/*.tsx",
13
- "src/**/*.d.ts"
14
- ],
15
- "references": [
16
- {
17
- "path": "../../dev/config/tsconfig.json"
18
- },
19
- {
20
- "path": "../api"
21
- },
22
- {
23
- "path": "../common"
24
- },
25
- {
26
- "path": "../fingerprint"
27
- },
28
- {
29
- "path": "../locale"
30
- },
31
- {
32
- "path": "../procaptcha-common"
33
- },
34
- {
35
- "path": "../types"
36
- },
37
- {
38
- "path": "../util"
39
- },
40
- {
41
- "path": "../util-crypto"
42
- },
43
- {
44
- "path": "../widget-skeleton"
45
- }
46
- ]
47
- }