@prosopo/procaptcha-pow 2.10.23 → 2.10.25

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.
Files changed (47) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +12 -10
  2. package/.turbo/turbo-build$colon$tsc.log +19 -19
  3. package/.turbo/turbo-build.log +14 -11
  4. package/CHANGELOG.md +46 -0
  5. package/dist/_virtual/_rolldown/runtime.js +3 -0
  6. package/dist/cjs/components/ProcaptchaPoW.cjs +6 -9
  7. package/dist/cjs/components/ProcaptchaWidget.cjs +117 -127
  8. package/dist/cjs/index.cjs +2 -4
  9. package/dist/cjs/services/Manager.cjs +193 -277
  10. package/dist/components/ProcaptchaPoW.js +6 -8
  11. package/dist/components/ProcaptchaWidget.d.ts.map +1 -1
  12. package/dist/components/ProcaptchaWidget.js +117 -127
  13. package/dist/components/ProcaptchaWidget.js.map +1 -1
  14. package/dist/index.js +2 -4
  15. package/dist/services/Manager.d.ts.map +1 -1
  16. package/dist/services/Manager.js +189 -274
  17. package/dist/services/Manager.js.map +1 -1
  18. package/dist/tests/manager.unit.test.d.ts +2 -0
  19. package/dist/tests/manager.unit.test.d.ts.map +1 -0
  20. package/dist/tests/manager.unit.test.js +752 -0
  21. package/dist/tests/manager.unit.test.js.map +1 -0
  22. package/dist/tests/managerHarness.d.ts +17 -0
  23. package/dist/tests/managerHarness.d.ts.map +1 -0
  24. package/dist/tests/managerHarness.js +70 -0
  25. package/dist/tests/managerHarness.js.map +1 -0
  26. package/dist/tests/procaptchaPoW.unit.test.d.ts +2 -0
  27. package/dist/tests/procaptchaPoW.unit.test.d.ts.map +1 -0
  28. package/dist/tests/procaptchaPoW.unit.test.js +79 -0
  29. package/dist/tests/procaptchaPoW.unit.test.js.map +1 -0
  30. package/dist/tests/procaptchaPow.test-d.d.ts +2 -0
  31. package/dist/tests/procaptchaPow.test-d.d.ts.map +1 -0
  32. package/dist/tests/procaptchaPow.test-d.js +62 -0
  33. package/dist/tests/procaptchaPow.test-d.js.map +1 -0
  34. package/dist/tests/procaptchaWidget.unit.test.d.ts +2 -0
  35. package/dist/tests/procaptchaWidget.unit.test.d.ts.map +1 -0
  36. package/dist/tests/procaptchaWidget.unit.test.js +410 -0
  37. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -0
  38. package/package.json +18 -13
  39. package/src/components/ProcaptchaWidget.tsx +22 -5
  40. package/src/services/Manager.ts +9 -5
  41. package/src/tests/manager.unit.test.ts +1105 -0
  42. package/src/tests/managerHarness.ts +135 -0
  43. package/src/tests/procaptchaPoW.unit.test.ts +120 -0
  44. package/src/tests/procaptchaPow.test-d.ts +122 -0
  45. package/src/tests/procaptchaWidget.unit.test.ts +571 -0
  46. package/tsconfig.tsbuildinfo +1 -1
  47. package/vite.test.config.ts +25 -0
@@ -0,0 +1,752 @@
1
+ import { ApiParams, CaptchaType, decodeProcaptchaOutput, } from "@prosopo/types";
2
+ import { extractData } from "@prosopo/util";
3
+ import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest";
4
+ import { Manager } from "../services/Manager.js";
5
+ import { OTHER_PROVIDER_URL, PROVIDER_URL, SITE_KEY, USER_ADDRESS, account, accountWithoutExtension, callbacks, challengeResponse, config, frictionless, randomProvider, signRawMock, solutionResponse, state, } from "./managerHarness.js";
6
+ const mocks = vi.hoisted(() => {
7
+ const providerApiConstructions = [];
8
+ const getPowCaptchaChallenge = vi.fn();
9
+ const submitPowCaptchaSolution = vi.fn();
10
+ class ProviderApiMock {
11
+ constructor(url, siteKey) {
12
+ this.getPowCaptchaChallenge = getPowCaptchaChallenge;
13
+ this.submitPowCaptchaSolution = submitPowCaptchaSolution;
14
+ providerApiConstructions.push({ url, siteKey });
15
+ }
16
+ }
17
+ const getAccount = vi.fn();
18
+ class ExtensionMock {
19
+ constructor() {
20
+ this.getAccount = getAccount;
21
+ }
22
+ }
23
+ const extensionLoader = vi.fn();
24
+ const getProcaptchaRandomActiveProvider = vi.fn();
25
+ const solvePoW = vi.fn();
26
+ const sleep = vi.fn();
27
+ const getFingerprintProof = vi.fn();
28
+ const encodeFingerprintProof = vi.fn();
29
+ return {
30
+ providerApiConstructions,
31
+ getPowCaptchaChallenge,
32
+ submitPowCaptchaSolution,
33
+ ProviderApiMock,
34
+ getAccount,
35
+ ExtensionMock,
36
+ extensionLoader,
37
+ getProcaptchaRandomActiveProvider,
38
+ solvePoW,
39
+ sleep,
40
+ getFingerprintProof,
41
+ encodeFingerprintProof,
42
+ };
43
+ });
44
+ vi.mock("@prosopo/api", async (importOriginal) => {
45
+ const actual = await importOriginal();
46
+ return { ...actual, ProviderApi: mocks.ProviderApiMock };
47
+ });
48
+ vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
49
+ const actual = await importOriginal();
50
+ return {
51
+ ...actual,
52
+ ExtensionLoader: mocks.extensionLoader,
53
+ getProcaptchaRandomActiveProvider: mocks.getProcaptchaRandomActiveProvider,
54
+ providerRetry: (currentFn, retryFn, stateReset, attemptCount, retryMax) => actual.providerRetry(currentFn, retryFn, stateReset, attemptCount, retryMax, 0),
55
+ };
56
+ });
57
+ vi.mock("@prosopo/util", async (importOriginal) => {
58
+ const actual = await importOriginal();
59
+ return { ...actual, solvePoW: mocks.solvePoW, sleep: mocks.sleep };
60
+ });
61
+ vi.mock("@prosopo/fingerprint", async (importOriginal) => {
62
+ const actual = await importOriginal();
63
+ return {
64
+ ...actual,
65
+ getFingerprintProof: mocks.getFingerprintProof,
66
+ encodeFingerprintProof: mocks.encodeFingerprintProof,
67
+ };
68
+ });
69
+ const FINGERPRINT_PROOF = {
70
+ v: 1,
71
+ root: "0xroot",
72
+ disclosures: [],
73
+ };
74
+ const build = (options = {}) => {
75
+ const currentState = state(options.initialState);
76
+ const updates = [];
77
+ const events = {
78
+ onHuman: vi.fn(),
79
+ onFailed: vi.fn(),
80
+ onExpired: vi.fn(),
81
+ onReset: vi.fn(),
82
+ };
83
+ const restart = vi.fn();
84
+ const onEscalate = vi.fn();
85
+ const callbackInput = callbacks(events);
86
+ const frictionlessState = options.frictionlessState ??
87
+ (options.withFrictionless === false
88
+ ? undefined
89
+ : frictionless({ restart }));
90
+ const manager = Manager(options.configInput ?? config(), currentState, (next) => {
91
+ updates.push({ ...next });
92
+ }, callbackInput, frictionlessState, options.withEscalationHandler === false ? undefined : onEscalate, options.honeypot);
93
+ return {
94
+ manager,
95
+ state: currentState,
96
+ updates,
97
+ events,
98
+ onEscalate,
99
+ restart,
100
+ };
101
+ };
102
+ const lastUpdate = (harness, key) => {
103
+ const withKey = harness.updates.filter((update) => key in update);
104
+ return withKey.length === 0 ? undefined : withKey[withKey.length - 1]?.[key];
105
+ };
106
+ const submitArgs = (callIndex = 0) => {
107
+ const call = mocks.submitPowCaptchaSolution.mock.calls[callIndex];
108
+ if (!call)
109
+ throw new Error("expected a solution to have been submitted");
110
+ return call;
111
+ };
112
+ const signRaw = signRawMock;
113
+ beforeEach(() => {
114
+ vi.clearAllMocks();
115
+ mocks.providerApiConstructions.length = 0;
116
+ signRaw.mockResolvedValue({ id: 1, signature: "0xuser-signature" });
117
+ mocks.getAccount.mockResolvedValue(account(signRaw));
118
+ mocks.extensionLoader.mockResolvedValue(mocks.ExtensionMock);
119
+ mocks.getProcaptchaRandomActiveProvider.mockResolvedValue(randomProvider());
120
+ mocks.getPowCaptchaChallenge.mockResolvedValue(challengeResponse());
121
+ mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse());
122
+ mocks.solvePoW.mockResolvedValue(42);
123
+ mocks.sleep.mockResolvedValue(undefined);
124
+ mocks.getFingerprintProof.mockResolvedValue(FINGERPRINT_PROOF);
125
+ mocks.encodeFingerprintProof.mockReturnValue("encoded-proof");
126
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
127
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
128
+ });
129
+ afterEach(() => {
130
+ vi.useRealTimers();
131
+ Reflect.deleteProperty(globalThis, "__prosopoFingerprintProofTest__");
132
+ vi.restoreAllMocks();
133
+ });
134
+ describe("start: the cases it refuses to run", () => {
135
+ test("a solve already in flight is left alone", () => {
136
+ const harness = build({ initialState: { loading: true } });
137
+ return harness.manager.start().then(() => {
138
+ expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
139
+ expect(harness.updates).toHaveLength(0);
140
+ });
141
+ });
142
+ test("a user already proven human is not asked again", async () => {
143
+ const harness = build({ initialState: { isHuman: true } });
144
+ await harness.manager.start();
145
+ expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
146
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
147
+ });
148
+ });
149
+ describe("start: getting to a challenge", () => {
150
+ test("resets the widget before it starts, so a stale challenge cannot linger", async () => {
151
+ const harness = build({ initialState: { index: 3, showModal: true } });
152
+ await harness.manager.start();
153
+ const resetAt = harness.updates.findIndex((update) => "showModal" in update);
154
+ expect(harness.updates[resetAt]).toMatchObject({
155
+ showModal: false,
156
+ loading: false,
157
+ index: 0,
158
+ isHuman: false,
159
+ });
160
+ expect(resetAt).toBeLessThan(harness.updates.findIndex((update) => update.loading === true));
161
+ expect(harness.events.onReset).toHaveBeenCalled();
162
+ });
163
+ test("the pre-start reset does not restart the frictionless session", async () => {
164
+ const harness = build();
165
+ await harness.manager.start();
166
+ expect(harness.restart).not.toHaveBeenCalled();
167
+ });
168
+ test("marks the widget as loading and counts the attempt", async () => {
169
+ const harness = build();
170
+ await harness.manager.start();
171
+ expect(harness.updates).toContainEqual({ loading: true });
172
+ expect(harness.updates).toContainEqual({ attemptCount: 1 });
173
+ });
174
+ test("takes the account from the frictionless state when there is one", async () => {
175
+ const harness = build();
176
+ await harness.manager.start();
177
+ expect(mocks.extensionLoader).not.toHaveBeenCalled();
178
+ expect(lastUpdate(harness, "account")).toEqual({
179
+ account: { address: USER_ADDRESS },
180
+ });
181
+ });
182
+ test("without a frictionless state it loads an extension for the configured mode", async () => {
183
+ const harness = build({ withFrictionless: false });
184
+ await harness.manager.start();
185
+ expect(mocks.extensionLoader).toHaveBeenCalledWith(true);
186
+ expect(mocks.getAccount).toHaveBeenCalledTimes(1);
187
+ });
188
+ test("a web3 config loads the web3 extension", async () => {
189
+ const harness = build({
190
+ withFrictionless: false,
191
+ configInput: config({ web2: false, userAccountAddress: USER_ADDRESS }),
192
+ });
193
+ await harness.manager.start();
194
+ expect(mocks.extensionLoader).toHaveBeenCalledWith(false);
195
+ });
196
+ test("snapshots the site key into the state", async () => {
197
+ const harness = build();
198
+ await harness.manager.start();
199
+ expect(harness.updates).toContainEqual({ dappAccount: SITE_KEY });
200
+ });
201
+ test("lets the UI catch up with the loading flag before the network work", async () => {
202
+ const harness = build();
203
+ await harness.manager.start();
204
+ expect(mocks.sleep).toHaveBeenCalledWith(100);
205
+ });
206
+ test("web3 without an account address never reaches a provider", async () => {
207
+ const harness = build({
208
+ withFrictionless: false,
209
+ configInput: config({ web2: false }),
210
+ });
211
+ await harness.manager.start();
212
+ expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
213
+ });
214
+ test("a missing site key stops the solve rather than talking to a provider anonymously", async () => {
215
+ const harness = build({ configInput: config({ account: {} }) });
216
+ await harness.manager.start();
217
+ expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
218
+ });
219
+ });
220
+ describe("start: choosing a provider", () => {
221
+ test("uses the provider the frictionless session already picked", async () => {
222
+ const harness = build();
223
+ await harness.manager.start();
224
+ expect(mocks.getProcaptchaRandomActiveProvider).not.toHaveBeenCalled();
225
+ expect(mocks.providerApiConstructions).toEqual([
226
+ { url: PROVIDER_URL, siteKey: SITE_KEY },
227
+ ]);
228
+ });
229
+ test("otherwise selects one for the configured environment", async () => {
230
+ const harness = build({ withFrictionless: false });
231
+ await harness.manager.start();
232
+ expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith("production", undefined, { attempt: 1, excludeUrl: undefined });
233
+ });
234
+ test("passes the ipv4 preference through to selection", async () => {
235
+ const harness = build({
236
+ withFrictionless: false,
237
+ configInput: config({ ipv4: true }),
238
+ });
239
+ await harness.manager.start();
240
+ expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith("production", "ipv4", expect.anything());
241
+ });
242
+ test("passes the ipv6 preference through to selection", async () => {
243
+ const harness = build({
244
+ withFrictionless: false,
245
+ configInput: config({ ipv6: true }),
246
+ });
247
+ await harness.manager.start();
248
+ expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith("production", "ipv6", expect.anything());
249
+ });
250
+ test("excludes the provider that just failed from the retry", async () => {
251
+ const harness = build({ withFrictionless: false });
252
+ mocks.getProcaptchaRandomActiveProvider
253
+ .mockResolvedValueOnce(randomProvider())
254
+ .mockResolvedValueOnce(randomProvider(OTHER_PROVIDER_URL));
255
+ mocks.getPowCaptchaChallenge
256
+ .mockRejectedValueOnce(new Error("provider down"))
257
+ .mockResolvedValue(challengeResponse());
258
+ await harness.manager.start();
259
+ expect(mocks.getProcaptchaRandomActiveProvider.mock.calls[1]?.[2]).toEqual({
260
+ attempt: 2,
261
+ excludeUrl: PROVIDER_URL,
262
+ });
263
+ });
264
+ });
265
+ describe("start: the challenge request", () => {
266
+ test("identifies the user, the site and the session", async () => {
267
+ const harness = build({
268
+ frictionlessState: frictionless({ sessionId: "session-1" }),
269
+ });
270
+ await harness.manager.start();
271
+ expect(mocks.getPowCaptchaChallenge).toHaveBeenCalledWith(USER_ADDRESS, SITE_KEY, "session-1", undefined);
272
+ });
273
+ test("attaches SIMD readings only if they have already resolved", async () => {
274
+ const getSimdReadings = vi.fn(async () => "readings");
275
+ const harness = build({
276
+ frictionlessState: frictionless({ getSimdReadings }),
277
+ });
278
+ await harness.manager.start();
279
+ expect(getSimdReadings).toHaveBeenCalledWith(0);
280
+ expect(mocks.getPowCaptchaChallenge.mock.calls[0]?.[3]).toBe("readings");
281
+ });
282
+ test("a session without a readings source simply omits them", async () => {
283
+ const harness = build();
284
+ await harness.manager.start();
285
+ expect(mocks.getPowCaptchaChallenge.mock.calls[0]?.[3]).toBeUndefined();
286
+ });
287
+ test("an error in the response is surfaced and the solve stops", async () => {
288
+ const harness = build();
289
+ mocks.getPowCaptchaChallenge.mockResolvedValue(challengeResponse({
290
+ error: {
291
+ message: "no session",
292
+ key: "CAPTCHA.NO_SESSION_FOUND",
293
+ code: 400,
294
+ },
295
+ }));
296
+ await harness.manager.start();
297
+ expect(lastUpdate(harness, "error")).toEqual({
298
+ message: "no session",
299
+ key: "CAPTCHA.NO_SESSION_FOUND",
300
+ });
301
+ expect(lastUpdate(harness, "loading")).toBe(false);
302
+ expect(mocks.solvePoW).not.toHaveBeenCalled();
303
+ expect(mocks.submitPowCaptchaSolution).not.toHaveBeenCalled();
304
+ expect(harness.events.onFailed).not.toHaveBeenCalled();
305
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
306
+ });
307
+ test("an error with no key is still reported under a known key", async () => {
308
+ const harness = build();
309
+ mocks.getPowCaptchaChallenge.mockResolvedValue(challengeResponse({ error: { message: "boom", code: 500 } }));
310
+ await harness.manager.start();
311
+ expect(lastUpdate(harness, "error")).toEqual({
312
+ message: "boom",
313
+ key: "API.UNKNOWN_ERROR",
314
+ });
315
+ });
316
+ });
317
+ describe("start: solving and signing", () => {
318
+ test("solves the challenge the provider issued at its difficulty", async () => {
319
+ const harness = build();
320
+ await harness.manager.start();
321
+ expect(mocks.solvePoW).toHaveBeenCalledWith(challengeResponse().challenge, 2);
322
+ });
323
+ test("embeds the click coordinates in the salt", async () => {
324
+ const harness = build();
325
+ await harness.manager.start(120, 45);
326
+ const salt = submitArgs()[6];
327
+ expect(salt).toBeDefined();
328
+ expect(extractData(String(salt))).toEqual([120, 45]);
329
+ });
330
+ test("a solve with no click still carries a salt, of the origin", async () => {
331
+ const harness = build();
332
+ await harness.manager.start();
333
+ expect(extractData(String(submitArgs()[6]))).toEqual([0, 0]);
334
+ });
335
+ test("signs the challenge timestamp as the user", async () => {
336
+ const harness = build();
337
+ await harness.manager.start();
338
+ expect(signRaw).toHaveBeenCalledWith({
339
+ address: USER_ADDRESS,
340
+ data: "0x31373030303030303030303030",
341
+ type: "bytes",
342
+ });
343
+ expect(submitArgs()[4]).toBe("0xuser-signature");
344
+ });
345
+ test("an account with no extension cannot prove ownership, so nothing is submitted", async () => {
346
+ const harness = build({
347
+ frictionlessState: frictionless({
348
+ userAccount: accountWithoutExtension(),
349
+ }),
350
+ });
351
+ await harness.manager.start();
352
+ expect(mocks.submitPowCaptchaSolution).not.toHaveBeenCalled();
353
+ });
354
+ test("an extension that cannot sign raw data is treated the same way", async () => {
355
+ const harness = build({
356
+ frictionlessState: frictionless({ userAccount: account(undefined) }),
357
+ });
358
+ await harness.manager.start();
359
+ expect(mocks.submitPowCaptchaSolution).not.toHaveBeenCalled();
360
+ });
361
+ });
362
+ describe("start: behavioural data", () => {
363
+ const collectorOf = (getData) => ({
364
+ start: () => undefined,
365
+ stop: () => undefined,
366
+ clear: () => undefined,
367
+ getData,
368
+ });
369
+ const collector = (points) => collectorOf(() => points.map(([x, y]) => ({ x, y, timestamp: 0 })));
370
+ const touchCollector = (points) => collectorOf(() => points.map(([x, y]) => ({
371
+ x,
372
+ y,
373
+ timestamp: 0,
374
+ eventType: "touchstart",
375
+ touchCount: 1,
376
+ })));
377
+ const clickCollector = (points) => collectorOf(() => points.map(([x, y]) => ({
378
+ x,
379
+ y,
380
+ timestamp: 0,
381
+ eventType: "click",
382
+ button: 0,
383
+ })));
384
+ test("is encrypted and submitted when a collector and a cipher are present", async () => {
385
+ const encryptBehavioralData = vi.fn(async () => "cipher");
386
+ const harness = build({
387
+ frictionlessState: frictionless({
388
+ encryptBehavioralData,
389
+ behaviorCollector1: collector([[1, 2]]),
390
+ deviceCapability: "high",
391
+ }),
392
+ });
393
+ await harness.manager.start();
394
+ const payload = JSON.parse(encryptBehavioralData.mock.calls[0]?.[0] ?? "null");
395
+ expect(payload).toMatchObject({
396
+ collector1: [{ x: 1, y: 2, timestamp: 0 }],
397
+ collector2: [],
398
+ collector3: [],
399
+ deviceCapability: "high",
400
+ });
401
+ expect(submitArgs()[5]).toBe("cipher");
402
+ });
403
+ test("a session that never reported a device capability is labelled unknown", async () => {
404
+ const encryptBehavioralData = vi.fn(async () => "cipher");
405
+ const harness = build({
406
+ frictionlessState: frictionless({
407
+ encryptBehavioralData,
408
+ behaviorCollector3: clickCollector([]),
409
+ }),
410
+ });
411
+ await harness.manager.start();
412
+ expect(JSON.parse(encryptBehavioralData.mock.calls[0]?.[0] ?? "null")).toMatchObject({ deviceCapability: "unknown" });
413
+ });
414
+ test("is packed first when the session knows how to pack it", async () => {
415
+ const encryptBehavioralData = vi.fn(async () => "cipher");
416
+ const packBehavioralData = vi.fn(() => ({ c1: [], c2: [], c3: [], d: "packed" }));
417
+ const harness = build({
418
+ frictionlessState: frictionless({
419
+ encryptBehavioralData,
420
+ packBehavioralData,
421
+ behaviorCollector2: touchCollector([[3, 4]]),
422
+ }),
423
+ });
424
+ await harness.manager.start();
425
+ expect(packBehavioralData).toHaveBeenCalledTimes(1);
426
+ expect(encryptBehavioralData).toHaveBeenCalledWith(JSON.stringify({ c1: [], c2: [], c3: [], d: "packed" }));
427
+ });
428
+ test("no collector means nothing is collected, even with a cipher available", async () => {
429
+ const encryptBehavioralData = vi.fn(async () => "cipher");
430
+ const harness = build({
431
+ frictionlessState: frictionless({ encryptBehavioralData }),
432
+ });
433
+ await harness.manager.start();
434
+ expect(encryptBehavioralData).not.toHaveBeenCalled();
435
+ expect(submitArgs()[5]).toBeUndefined();
436
+ });
437
+ test("no cipher means the collectors are not even read", async () => {
438
+ const behaviorCollector1 = collector([[1, 1]]);
439
+ const getData = vi.spyOn(behaviorCollector1, "getData");
440
+ const harness = build({
441
+ frictionlessState: frictionless({ behaviorCollector1 }),
442
+ });
443
+ await harness.manager.start();
444
+ expect(getData).not.toHaveBeenCalled();
445
+ expect(submitArgs()[5]).toBeUndefined();
446
+ });
447
+ test("a failure to encrypt does not cost the user their solve", async () => {
448
+ const harness = build({
449
+ frictionlessState: frictionless({
450
+ encryptBehavioralData: async () => {
451
+ throw new Error("no key");
452
+ },
453
+ behaviorCollector1: collector([[1, 2]]),
454
+ }),
455
+ });
456
+ await harness.manager.start();
457
+ expect(submitArgs()[5]).toBeUndefined();
458
+ expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
459
+ });
460
+ test("a collector that throws is handled the same way", async () => {
461
+ const harness = build({
462
+ frictionlessState: frictionless({
463
+ encryptBehavioralData: async () => "cipher",
464
+ behaviorCollector1: {
465
+ start: () => undefined,
466
+ stop: () => undefined,
467
+ clear: () => undefined,
468
+ getData: () => {
469
+ throw new Error("detached");
470
+ },
471
+ },
472
+ }),
473
+ });
474
+ await harness.manager.start();
475
+ expect(submitArgs()[5]).toBeUndefined();
476
+ expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
477
+ });
478
+ });
479
+ describe("start: the rest of the submission", () => {
480
+ test("waits for SIMD readings before submitting", async () => {
481
+ const getSimdReadings = vi.fn(async (ms) => (ms === 0 ? undefined : "late-readings"));
482
+ const harness = build({
483
+ frictionlessState: frictionless({ getSimdReadings }),
484
+ });
485
+ await harness.manager.start();
486
+ expect(submitArgs()[7]).toBe("late-readings");
487
+ });
488
+ test("a filled honeypot is reported with the solution", async () => {
489
+ const harness = build({ honeypot: () => "bot@example.com" });
490
+ await harness.manager.start();
491
+ expect(submitArgs()[8]).toEqual({ hp: "bot@example.com" });
492
+ });
493
+ test("an untouched honeypot adds nothing to the payload", async () => {
494
+ const harness = build({ honeypot: () => "" });
495
+ await harness.manager.start();
496
+ expect(submitArgs()[8]).toBeUndefined();
497
+ });
498
+ test("no honeypot reader at all is fine", async () => {
499
+ const harness = build();
500
+ await harness.manager.start();
501
+ expect(submitArgs()[8]).toBeUndefined();
502
+ });
503
+ test("discloses only the keys the validator checks", async () => {
504
+ const harness = build();
505
+ await harness.manager.start();
506
+ expect(mocks.getFingerprintProof).toHaveBeenCalledTimes(1);
507
+ expect(submitArgs()[9]).toBe("encoded-proof");
508
+ });
509
+ test("an unavailable fingerprint does not block the submission", async () => {
510
+ const harness = build();
511
+ mocks.getFingerprintProof.mockRejectedValue(new Error("unavailable"));
512
+ await harness.manager.start();
513
+ expect(submitArgs()[9]).toBeUndefined();
514
+ expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
515
+ });
516
+ test("a proof that cannot be encoded is dropped rather than raised", async () => {
517
+ const harness = build();
518
+ mocks.encodeFingerprintProof.mockImplementation(() => {
519
+ throw new Error("bad proof");
520
+ });
521
+ await harness.manager.start();
522
+ expect(submitArgs()[9]).toBeUndefined();
523
+ });
524
+ test("the test hook can omit the proof", async () => {
525
+ const harness = build();
526
+ Reflect.set(globalThis, "__prosopoFingerprintProofTest__", "omit");
527
+ await harness.manager.start();
528
+ expect(submitArgs()[9]).toBeUndefined();
529
+ });
530
+ test("the test hook can send a proof the provider will reject", async () => {
531
+ const harness = build();
532
+ Reflect.set(globalThis, "__prosopoFingerprintProofTest__", "malformed");
533
+ await harness.manager.start();
534
+ expect(submitArgs()[9]).toBe("invalid-fingerprint-proof");
535
+ });
536
+ test("an empty hook value leaves the real proof alone", async () => {
537
+ const harness = build();
538
+ Reflect.set(globalThis, "__prosopoFingerprintProofTest__", "");
539
+ await harness.manager.start();
540
+ expect(submitArgs()[9]).toBe("encoded-proof");
541
+ });
542
+ test("a hook value that is not a string leaves the real proof alone", async () => {
543
+ const harness = build();
544
+ Reflect.set(globalThis, "__prosopoFingerprintProofTest__", 1);
545
+ await harness.manager.start();
546
+ expect(submitArgs()[9]).toBe("encoded-proof");
547
+ });
548
+ test("submits the challenge, the solver's nonce and both accounts", async () => {
549
+ const harness = build();
550
+ await harness.manager.start();
551
+ const [challenge, user, dapp, nonce] = submitArgs();
552
+ expect(challenge).toEqual(challengeResponse());
553
+ expect(user).toBe(USER_ADDRESS);
554
+ expect(dapp).toBe(SITE_KEY);
555
+ expect(nonce).toBe(42);
556
+ });
557
+ });
558
+ describe("start: what the provider decides", () => {
559
+ test("a verified solution makes the user human and issues a token", async () => {
560
+ const harness = build();
561
+ await harness.manager.start();
562
+ expect(lastUpdate(harness, "isHuman")).toBe(true);
563
+ expect(lastUpdate(harness, "loading")).toBe(false);
564
+ const token = harness.events.onHuman.mock.calls[0]?.[0];
565
+ expect(token).toBeDefined();
566
+ const output = decodeProcaptchaOutput(String(token));
567
+ expect(output).toMatchObject({
568
+ [ApiParams.providerUrl]: PROVIDER_URL,
569
+ [ApiParams.user]: USER_ADDRESS,
570
+ [ApiParams.dapp]: SITE_KEY,
571
+ [ApiParams.challenge]: challengeResponse().challenge,
572
+ [ApiParams.nonce]: 42,
573
+ [ApiParams.timestamp]: challengeResponse().timestamp,
574
+ [ApiParams.captchaType]: CaptchaType.pow,
575
+ });
576
+ expect(harness.events.onFailed).not.toHaveBeenCalled();
577
+ });
578
+ test("the token carries both signatures the provider will need to verify it", async () => {
579
+ const harness = build();
580
+ await harness.manager.start();
581
+ const output = decodeProcaptchaOutput(String(harness.events.onHuman.mock.calls[0]?.[0]));
582
+ expect(output[ApiParams.signature]).toEqual({
583
+ [ApiParams.provider]: { [ApiParams.challenge]: "0xprovider-challenge" },
584
+ [ApiParams.user]: { [ApiParams.timestamp]: "0xuser-signature" },
585
+ });
586
+ });
587
+ test("the human state expires, and the widget goes back to the start", async () => {
588
+ vi.useFakeTimers();
589
+ const harness = build();
590
+ await harness.manager.start();
591
+ expect(lastUpdate(harness, "successfullChallengeTimeout")).toBeDefined();
592
+ harness.events.onReset.mockClear();
593
+ vi.runOnlyPendingTimers();
594
+ expect(harness.events.onExpired).toHaveBeenCalledTimes(1);
595
+ expect(lastUpdate(harness, "isHuman")).toBe(false);
596
+ expect(harness.events.onReset).toHaveBeenCalledTimes(1);
597
+ expect(harness.restart).toHaveBeenCalledTimes(1);
598
+ });
599
+ test("an unverified solution fails the user and resets the session", async () => {
600
+ const harness = build();
601
+ mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse({ verified: false }));
602
+ await harness.manager.start();
603
+ expect(harness.events.onFailed).toHaveBeenCalledTimes(1);
604
+ expect(lastUpdate(harness, "isHuman")).toBe(false);
605
+ expect(lastUpdate(harness, "loading")).toBe(false);
606
+ expect(harness.restart).toHaveBeenCalledTimes(1);
607
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
608
+ });
609
+ test("an escalation hands over without declaring success or failure", async () => {
610
+ const harness = build();
611
+ mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse({
612
+ verified: true,
613
+ escalation: {
614
+ [ApiParams.captchaType]: CaptchaType.image,
615
+ [ApiParams.sessionId]: "escalated-session",
616
+ },
617
+ }));
618
+ await harness.manager.start(10, 20);
619
+ expect(harness.onEscalate).toHaveBeenCalledWith(CaptchaType.image, "escalated-session", { x: 10, y: 20 });
620
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
621
+ expect(harness.events.onFailed).not.toHaveBeenCalled();
622
+ expect(lastUpdate(harness, "loading")).toBe(false);
623
+ });
624
+ test("a puzzle escalation is handed over the same way", async () => {
625
+ const harness = build();
626
+ mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse({
627
+ verified: false,
628
+ escalation: {
629
+ [ApiParams.captchaType]: CaptchaType.puzzle,
630
+ [ApiParams.sessionId]: "escalated-session",
631
+ },
632
+ }));
633
+ await harness.manager.start(0, 7);
634
+ expect(harness.onEscalate).toHaveBeenCalledWith(CaptchaType.puzzle, "escalated-session", { x: 0, y: 7 });
635
+ expect(harness.events.onFailed).not.toHaveBeenCalled();
636
+ });
637
+ test("an escalation with no real click forwards no coordinates", async () => {
638
+ const harness = build();
639
+ mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse({
640
+ escalation: {
641
+ [ApiParams.captchaType]: CaptchaType.image,
642
+ [ApiParams.sessionId]: "escalated-session",
643
+ },
644
+ }));
645
+ await harness.manager.start();
646
+ expect(harness.onEscalate).toHaveBeenCalledWith(CaptchaType.image, "escalated-session", undefined);
647
+ });
648
+ test("an escalation nobody is listening for does not throw", async () => {
649
+ const harness = build({ withEscalationHandler: false });
650
+ mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse({
651
+ escalation: {
652
+ [ApiParams.captchaType]: CaptchaType.image,
653
+ [ApiParams.sessionId]: "escalated-session",
654
+ },
655
+ }));
656
+ await expect(harness.manager.start()).resolves.toBeUndefined();
657
+ });
658
+ });
659
+ describe("start: retrying a failed provider", () => {
660
+ test("a failure is retried against a fresh provider", async () => {
661
+ const harness = build({ withFrictionless: false });
662
+ mocks.getPowCaptchaChallenge
663
+ .mockRejectedValueOnce(new Error("provider down"))
664
+ .mockResolvedValue(challengeResponse());
665
+ await harness.manager.start();
666
+ expect(mocks.getPowCaptchaChallenge).toHaveBeenCalledTimes(2);
667
+ expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
668
+ });
669
+ test("the retry keeps the coordinates of the original click", async () => {
670
+ const harness = build({ withFrictionless: false });
671
+ mocks.getPowCaptchaChallenge
672
+ .mockRejectedValueOnce(new Error("provider down"))
673
+ .mockResolvedValue(challengeResponse());
674
+ mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse({
675
+ escalation: {
676
+ [ApiParams.captchaType]: CaptchaType.image,
677
+ [ApiParams.sessionId]: "escalated-session",
678
+ },
679
+ }));
680
+ await harness.manager.start(33, 44);
681
+ expect(harness.onEscalate).toHaveBeenCalledWith(CaptchaType.image, "escalated-session", { x: 33, y: 44 });
682
+ });
683
+ test("a widget that has already used its attempts gives up instead of looping", async () => {
684
+ const harness = build({
685
+ withFrictionless: false,
686
+ initialState: { attemptCount: 3 },
687
+ });
688
+ mocks.getPowCaptchaChallenge.mockRejectedValue(new Error("provider down"));
689
+ await harness.manager.start();
690
+ expect(mocks.getPowCaptchaChallenge).toHaveBeenCalledTimes(1);
691
+ expect(harness.events.onReset).toHaveBeenCalled();
692
+ });
693
+ test("a failure in the middle of a solve still resets the widget", async () => {
694
+ const harness = build({
695
+ withFrictionless: false,
696
+ initialState: { attemptCount: 3 },
697
+ });
698
+ mocks.submitPowCaptchaSolution.mockRejectedValue(new Error("500"));
699
+ await harness.manager.start();
700
+ expect(harness.events.onHuman).not.toHaveBeenCalled();
701
+ expect(harness.events.onReset).toHaveBeenCalled();
702
+ });
703
+ });
704
+ describe("the account the config ends up carrying", () => {
705
+ test("an account picked mid-solve wins over the one the page was configured with", async () => {
706
+ const harness = build({
707
+ configInput: config({ userAccountAddress: "stale-address" }),
708
+ });
709
+ await harness.manager.start();
710
+ expect(mocks.getPowCaptchaChallenge.mock.calls[0]?.[0]).toBe(USER_ADDRESS);
711
+ });
712
+ });
713
+ describe("resetState", () => {
714
+ test("clears both timers and tells the page the widget was reset", () => {
715
+ const harness = build({
716
+ initialState: {
717
+ timeout: setTimeout(() => undefined, 1000),
718
+ successfullChallengeTimeout: setTimeout(() => undefined, 1000),
719
+ },
720
+ });
721
+ const clear = vi.spyOn(window, "clearTimeout");
722
+ harness.manager.resetState();
723
+ expect(clear).toHaveBeenCalledTimes(2);
724
+ expect(harness.updates).toContainEqual({ timeout: undefined });
725
+ expect(harness.updates).toContainEqual({
726
+ successfullChallengeTimeout: undefined,
727
+ });
728
+ expect(harness.events.onReset).toHaveBeenCalledTimes(1);
729
+ });
730
+ test("restarts the frictionless session only when asked to", () => {
731
+ const harness = build();
732
+ harness.manager.resetState();
733
+ expect(harness.restart).not.toHaveBeenCalled();
734
+ harness.manager.resetState(harness.restart);
735
+ expect(harness.restart).toHaveBeenCalledTimes(1);
736
+ });
737
+ test("puts every field back to its default", () => {
738
+ const harness = build({
739
+ initialState: { index: 4, isHuman: true, showModal: true },
740
+ });
741
+ harness.manager.resetState();
742
+ expect(harness.state).toMatchObject({
743
+ showModal: false,
744
+ loading: false,
745
+ index: 0,
746
+ isHuman: false,
747
+ challenge: undefined,
748
+ account: undefined,
749
+ });
750
+ });
751
+ });
752
+ //# sourceMappingURL=manager.unit.test.js.map