@prosopo/procaptcha-puzzle 2.10.40 → 2.10.42
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.
- package/.turbo/turbo-build$colon$cjs.log +4 -4
- package/.turbo/turbo-build$colon$tsc.log +19 -19
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +12 -0
- package/dist/cjs/components/ProcaptchaWidget.cjs +10 -5
- package/dist/cjs/services/Manager.cjs +3 -3
- package/dist/components/ProcaptchaWidget.d.ts.map +1 -1
- package/dist/components/ProcaptchaWidget.js +10 -5
- package/dist/components/ProcaptchaWidget.js.map +1 -1
- package/dist/services/Manager.d.ts.map +1 -1
- package/dist/services/Manager.js +3 -3
- package/dist/services/Manager.js.map +1 -1
- package/dist/tests/manager.unit.test.d.ts +2 -0
- package/dist/tests/manager.unit.test.d.ts.map +1 -0
- package/dist/tests/manager.unit.test.js +506 -0
- package/dist/tests/manager.unit.test.js.map +1 -0
- package/dist/tests/managerHarness.d.ts +20 -0
- package/dist/tests/managerHarness.d.ts.map +1 -0
- package/dist/tests/managerHarness.js +86 -0
- package/dist/tests/managerHarness.js.map +1 -0
- package/dist/tests/procaptchaPuzzle.test-d.d.ts +2 -0
- package/dist/tests/procaptchaPuzzle.test-d.d.ts.map +1 -0
- package/dist/tests/procaptchaPuzzle.test-d.js +97 -0
- package/dist/tests/procaptchaPuzzle.test-d.js.map +1 -0
- package/dist/tests/procaptchaPuzzle.unit.test.d.ts +2 -0
- package/dist/tests/procaptchaPuzzle.unit.test.d.ts.map +1 -0
- package/dist/tests/procaptchaPuzzle.unit.test.js +81 -0
- package/dist/tests/procaptchaPuzzle.unit.test.js.map +1 -0
- package/dist/tests/procaptchaWidget.unit.test.d.ts +2 -0
- package/dist/tests/procaptchaWidget.unit.test.d.ts.map +1 -0
- package/dist/tests/procaptchaWidget.unit.test.js +547 -0
- package/dist/tests/procaptchaWidget.unit.test.js.map +1 -0
- package/dist/tests/puzzleCanvas.unit.test.d.ts +2 -0
- package/dist/tests/puzzleCanvas.unit.test.d.ts.map +1 -0
- package/dist/tests/puzzleCanvas.unit.test.js +312 -0
- package/dist/tests/puzzleCanvas.unit.test.js.map +1 -0
- package/dist/tests/setup.d.ts +5 -0
- package/dist/tests/setup.d.ts.map +1 -0
- package/dist/tests/setup.js +3 -0
- package/dist/tests/setup.js.map +1 -0
- package/package.json +10 -5
- package/src/components/ProcaptchaWidget.tsx +17 -6
- package/src/services/Manager.ts +10 -6
- package/src/tests/manager.unit.test.ts +763 -0
- package/src/tests/managerHarness.ts +169 -0
- package/src/tests/procaptchaPuzzle.test-d.ts +191 -0
- package/src/tests/procaptchaPuzzle.unit.test.ts +123 -0
- package/src/tests/procaptchaWidget.unit.test.ts +789 -0
- package/src/tests/puzzleCanvas.unit.test.ts +418 -0
- package/src/tests/setup.ts +26 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +27 -0
|
@@ -0,0 +1,763 @@
|
|
|
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 { pickIpMode } from "@prosopo/procaptcha-common";
|
|
16
|
+
import {
|
|
17
|
+
ApiParams,
|
|
18
|
+
CaptchaType,
|
|
19
|
+
type ClientMetaData,
|
|
20
|
+
type EnvironmentTypes,
|
|
21
|
+
type FrictionlessState,
|
|
22
|
+
type GetPuzzleCaptchaResponse,
|
|
23
|
+
type PackedBehavioralData,
|
|
24
|
+
type ProcaptchaCallbacks,
|
|
25
|
+
type ProcaptchaClientConfigInput,
|
|
26
|
+
type ProcaptchaState,
|
|
27
|
+
type ProviderSelectRetryContext,
|
|
28
|
+
type PuzzleCaptchaSolutionResponse,
|
|
29
|
+
type PuzzleEvent,
|
|
30
|
+
type RandomProvider,
|
|
31
|
+
decodeProcaptchaOutput,
|
|
32
|
+
} from "@prosopo/types";
|
|
33
|
+
import { extractData } from "@prosopo/util";
|
|
34
|
+
import {
|
|
35
|
+
type Mock,
|
|
36
|
+
afterEach,
|
|
37
|
+
beforeEach,
|
|
38
|
+
describe,
|
|
39
|
+
expect,
|
|
40
|
+
test,
|
|
41
|
+
vi,
|
|
42
|
+
} from "vitest";
|
|
43
|
+
import { Manager } from "../services/Manager.js";
|
|
44
|
+
import {
|
|
45
|
+
OTHER_PROVIDER_URL,
|
|
46
|
+
PROVIDER_URL,
|
|
47
|
+
SITE_KEY,
|
|
48
|
+
USER_ADDRESS,
|
|
49
|
+
account,
|
|
50
|
+
accountWithoutExtension,
|
|
51
|
+
callbacks,
|
|
52
|
+
challengeResponse,
|
|
53
|
+
collector,
|
|
54
|
+
config,
|
|
55
|
+
frictionless,
|
|
56
|
+
puzzleEvents,
|
|
57
|
+
randomProvider,
|
|
58
|
+
signRawMock,
|
|
59
|
+
solutionResponse,
|
|
60
|
+
state,
|
|
61
|
+
timerHandle,
|
|
62
|
+
} from "./managerHarness.js";
|
|
63
|
+
|
|
64
|
+
// Taken from procaptcha-common rather than @prosopo/load-balancer directly, so
|
|
65
|
+
// the test does not pull a dependency into this package that the source has no
|
|
66
|
+
// use for.
|
|
67
|
+
type IpMode = ReturnType<typeof pickIpMode>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Everything the manager reaches out to is replaced here. The manager itself is
|
|
71
|
+
* a closure over its collaborators rather than a class with injection points,
|
|
72
|
+
* so the module boundary is the seam: each mock keeps the real export list
|
|
73
|
+
* intact and swaps only the call the manager makes.
|
|
74
|
+
*/
|
|
75
|
+
const mocks = vi.hoisted(() => {
|
|
76
|
+
const providerApiConstructions: { url: string; siteKey: string }[] = [];
|
|
77
|
+
const getPuzzleCaptchaChallenge =
|
|
78
|
+
vi.fn<
|
|
79
|
+
(
|
|
80
|
+
user: string,
|
|
81
|
+
dapp: string,
|
|
82
|
+
sessionId?: string,
|
|
83
|
+
simdReadings?: string,
|
|
84
|
+
) => Promise<GetPuzzleCaptchaResponse>
|
|
85
|
+
>();
|
|
86
|
+
const submitPuzzleCaptchaSolution =
|
|
87
|
+
vi.fn<
|
|
88
|
+
(
|
|
89
|
+
challenge: GetPuzzleCaptchaResponse,
|
|
90
|
+
userAccount: string,
|
|
91
|
+
dappAccount: string,
|
|
92
|
+
finalX: number,
|
|
93
|
+
finalY: number,
|
|
94
|
+
puzzleEvents: PuzzleEvent[],
|
|
95
|
+
userTimestampSignature: string,
|
|
96
|
+
behavioralData?: string,
|
|
97
|
+
salt?: string,
|
|
98
|
+
simdReadings?: string,
|
|
99
|
+
clientMetaData?: ClientMetaData,
|
|
100
|
+
) => Promise<PuzzleCaptchaSolutionResponse>
|
|
101
|
+
>();
|
|
102
|
+
|
|
103
|
+
class ProviderApiMock {
|
|
104
|
+
public getPuzzleCaptchaChallenge = getPuzzleCaptchaChallenge;
|
|
105
|
+
public submitPuzzleCaptchaSolution = submitPuzzleCaptchaSolution;
|
|
106
|
+
constructor(url: string, siteKey: string) {
|
|
107
|
+
providerApiConstructions.push({ url, siteKey });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const getAccount = vi.fn<() => Promise<unknown>>();
|
|
112
|
+
class ExtensionMock {
|
|
113
|
+
public getAccount = getAccount;
|
|
114
|
+
}
|
|
115
|
+
const extensionLoader = vi.fn<(web2: boolean) => Promise<unknown>>();
|
|
116
|
+
const getProcaptchaRandomActiveProvider =
|
|
117
|
+
vi.fn<
|
|
118
|
+
(
|
|
119
|
+
defaultEnvironment: EnvironmentTypes,
|
|
120
|
+
ipMode?: IpMode,
|
|
121
|
+
retryContext?: ProviderSelectRetryContext,
|
|
122
|
+
) => Promise<RandomProvider>
|
|
123
|
+
>();
|
|
124
|
+
const getSimdReadingsForSubmit =
|
|
125
|
+
vi.fn<(frictionlessState?: FrictionlessState) => Promise<string>>();
|
|
126
|
+
const sleep = vi.fn<(ms: number) => Promise<void>>();
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
providerApiConstructions,
|
|
130
|
+
getPuzzleCaptchaChallenge,
|
|
131
|
+
submitPuzzleCaptchaSolution,
|
|
132
|
+
ProviderApiMock,
|
|
133
|
+
getAccount,
|
|
134
|
+
ExtensionMock,
|
|
135
|
+
extensionLoader,
|
|
136
|
+
getProcaptchaRandomActiveProvider,
|
|
137
|
+
getSimdReadingsForSubmit,
|
|
138
|
+
sleep,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
vi.mock("@prosopo/api", async (importOriginal) => {
|
|
143
|
+
const actual = await importOriginal<typeof import("@prosopo/api")>();
|
|
144
|
+
return { ...actual, ProviderApi: mocks.ProviderApiMock };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
|
|
148
|
+
const actual =
|
|
149
|
+
await importOriginal<typeof import("@prosopo/procaptcha-common")>();
|
|
150
|
+
return {
|
|
151
|
+
...actual,
|
|
152
|
+
ExtensionLoader: mocks.extensionLoader,
|
|
153
|
+
getProcaptchaRandomActiveProvider: mocks.getProcaptchaRandomActiveProvider,
|
|
154
|
+
getSimdReadingsForSubmit: mocks.getSimdReadingsForSubmit,
|
|
155
|
+
// The real retry, minus its exponential backoff: the delay is covered by
|
|
156
|
+
// the procaptcha-common suite and waiting for it here would add seconds
|
|
157
|
+
// per test for behaviour this suite isn't asserting.
|
|
158
|
+
providerRetry: (
|
|
159
|
+
currentFn: () => Promise<void>,
|
|
160
|
+
retryFn: () => Promise<void>,
|
|
161
|
+
stateReset: () => void,
|
|
162
|
+
attemptCount: number,
|
|
163
|
+
retryMax: number,
|
|
164
|
+
) =>
|
|
165
|
+
actual.providerRetry(
|
|
166
|
+
currentFn,
|
|
167
|
+
retryFn,
|
|
168
|
+
stateReset,
|
|
169
|
+
attemptCount,
|
|
170
|
+
retryMax,
|
|
171
|
+
0,
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
vi.mock("@prosopo/util", async (importOriginal) => {
|
|
177
|
+
const actual = await importOriginal<typeof import("@prosopo/util")>();
|
|
178
|
+
return { ...actual, sleep: mocks.sleep };
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
interface Harness {
|
|
182
|
+
manager: ReturnType<typeof Manager>;
|
|
183
|
+
state: ProcaptchaState;
|
|
184
|
+
updates: Partial<ProcaptchaState>[];
|
|
185
|
+
events: {
|
|
186
|
+
onHuman: Mock<(token: string) => void>;
|
|
187
|
+
onFailed: Mock<() => void>;
|
|
188
|
+
onExpired: Mock<() => void>;
|
|
189
|
+
onReset: Mock<() => void>;
|
|
190
|
+
};
|
|
191
|
+
restart: Mock<() => void>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface HarnessOptions {
|
|
195
|
+
configInput?: ProcaptchaClientConfigInput;
|
|
196
|
+
initialState?: Partial<ProcaptchaState>;
|
|
197
|
+
frictionlessState?: FrictionlessState;
|
|
198
|
+
withFrictionless?: boolean;
|
|
199
|
+
honeypot?: () => string | undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const build = (options: HarnessOptions = {}): Harness => {
|
|
203
|
+
const currentState = state(options.initialState);
|
|
204
|
+
const updates: Partial<ProcaptchaState>[] = [];
|
|
205
|
+
const events = {
|
|
206
|
+
onHuman: vi.fn<(token: string) => void>(),
|
|
207
|
+
onFailed: vi.fn<() => void>(),
|
|
208
|
+
onExpired: vi.fn<() => void>(),
|
|
209
|
+
onReset: vi.fn<() => void>(),
|
|
210
|
+
};
|
|
211
|
+
const restart = vi.fn<() => void>();
|
|
212
|
+
const callbackInput: ProcaptchaCallbacks = callbacks(events);
|
|
213
|
+
const frictionlessState =
|
|
214
|
+
options.frictionlessState ??
|
|
215
|
+
(options.withFrictionless === false
|
|
216
|
+
? undefined
|
|
217
|
+
: frictionless({ restart }));
|
|
218
|
+
const manager = Manager(
|
|
219
|
+
options.configInput ?? config(),
|
|
220
|
+
currentState,
|
|
221
|
+
(next: Partial<ProcaptchaState>) => {
|
|
222
|
+
updates.push({ ...next });
|
|
223
|
+
},
|
|
224
|
+
callbackInput,
|
|
225
|
+
frictionlessState,
|
|
226
|
+
options.honeypot,
|
|
227
|
+
);
|
|
228
|
+
return { manager, state: currentState, updates, events, restart };
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** The last value the manager pushed for a given state field. */
|
|
232
|
+
const lastUpdate = <K extends keyof ProcaptchaState>(
|
|
233
|
+
harness: Harness,
|
|
234
|
+
key: K,
|
|
235
|
+
): ProcaptchaState[K] | undefined => {
|
|
236
|
+
const withKey = harness.updates.filter((update) => key in update);
|
|
237
|
+
return withKey.length === 0 ? undefined : withKey[withKey.length - 1]?.[key];
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const submitArgs = (
|
|
241
|
+
callIndex = 0,
|
|
242
|
+
): Parameters<typeof mocks.submitPuzzleCaptchaSolution> => {
|
|
243
|
+
const call = mocks.submitPuzzleCaptchaSolution.mock.calls[callIndex];
|
|
244
|
+
if (!call) throw new Error("expected a solution to have been submitted");
|
|
245
|
+
return call;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Drives a full solve: start, then submit the piece at the target. */
|
|
249
|
+
const solve = async (harness: Harness): Promise<boolean> => {
|
|
250
|
+
await harness.manager.start(11, 22);
|
|
251
|
+
return harness.manager.submitSolution(200, 80, puzzleEvents());
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const signRaw = signRawMock;
|
|
255
|
+
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
vi.clearAllMocks();
|
|
258
|
+
mocks.providerApiConstructions.length = 0;
|
|
259
|
+
signRaw.mockResolvedValue({ id: 1, signature: "0xuser-signature" });
|
|
260
|
+
mocks.getAccount.mockResolvedValue(account(signRaw));
|
|
261
|
+
mocks.extensionLoader.mockResolvedValue(mocks.ExtensionMock);
|
|
262
|
+
mocks.getProcaptchaRandomActiveProvider.mockResolvedValue(randomProvider());
|
|
263
|
+
mocks.getPuzzleCaptchaChallenge.mockResolvedValue(challengeResponse());
|
|
264
|
+
mocks.submitPuzzleCaptchaSolution.mockResolvedValue(solutionResponse());
|
|
265
|
+
mocks.getSimdReadingsForSubmit.mockResolvedValue("simd-readings");
|
|
266
|
+
mocks.sleep.mockResolvedValue(undefined);
|
|
267
|
+
// providerRetry reports every failure it swallows; the suite drives those
|
|
268
|
+
// paths deliberately and the output would bury the real failures.
|
|
269
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
270
|
+
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
vi.restoreAllMocks();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("start: the cases it refuses to run", () => {
|
|
279
|
+
test("a solve already in flight is left alone", async () => {
|
|
280
|
+
const harness = build({ initialState: { loading: true } });
|
|
281
|
+
await harness.manager.start();
|
|
282
|
+
expect(mocks.getPuzzleCaptchaChallenge).not.toHaveBeenCalled();
|
|
283
|
+
expect(harness.updates).toHaveLength(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a user already proven human is not asked again", async () => {
|
|
287
|
+
const harness = build({ initialState: { isHuman: true } });
|
|
288
|
+
await harness.manager.start();
|
|
289
|
+
expect(mocks.getPuzzleCaptchaChallenge).not.toHaveBeenCalled();
|
|
290
|
+
expect(harness.events.onHuman).not.toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("a refused start still resolves, with no challenge to hand back", async () => {
|
|
294
|
+
const harness = build({ initialState: { isHuman: true } });
|
|
295
|
+
await expect(harness.manager.start()).resolves.toBeUndefined();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("start: getting to a challenge", () => {
|
|
300
|
+
test("resets the widget first, so a stale challenge cannot linger", async () => {
|
|
301
|
+
const harness = build({ initialState: { index: 3, showModal: true } });
|
|
302
|
+
await harness.manager.start();
|
|
303
|
+
const resetAt = harness.updates.findIndex(
|
|
304
|
+
(update) => "showModal" in update,
|
|
305
|
+
);
|
|
306
|
+
expect(harness.updates[resetAt]).toMatchObject({
|
|
307
|
+
showModal: false,
|
|
308
|
+
loading: false,
|
|
309
|
+
index: 0,
|
|
310
|
+
isHuman: false,
|
|
311
|
+
});
|
|
312
|
+
expect(harness.events.onReset).toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("counts the attempt, so provider selection can rotate on a retry", async () => {
|
|
316
|
+
const harness = build({ initialState: { attemptCount: 2 } });
|
|
317
|
+
await harness.manager.start();
|
|
318
|
+
expect(lastUpdate(harness, "attemptCount")).toBe(3);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("asks the provider named by the frictionless state, when there is one", async () => {
|
|
322
|
+
const harness = build();
|
|
323
|
+
await harness.manager.start();
|
|
324
|
+
expect(mocks.getProcaptchaRandomActiveProvider).not.toHaveBeenCalled();
|
|
325
|
+
expect(mocks.providerApiConstructions).toEqual([
|
|
326
|
+
{ url: PROVIDER_URL, siteKey: SITE_KEY },
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("picks a provider itself when the frictionless state has none", async () => {
|
|
331
|
+
const harness = build({
|
|
332
|
+
frictionlessState: frictionless({ provider: undefined }),
|
|
333
|
+
});
|
|
334
|
+
await harness.manager.start();
|
|
335
|
+
expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith(
|
|
336
|
+
"production",
|
|
337
|
+
undefined,
|
|
338
|
+
{ attempt: 1, excludeUrl: undefined },
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("excludes the provider that just failed from the next pick", async () => {
|
|
343
|
+
const harness = build({
|
|
344
|
+
frictionlessState: frictionless({ provider: undefined }),
|
|
345
|
+
});
|
|
346
|
+
await harness.manager.start();
|
|
347
|
+
await harness.manager.start();
|
|
348
|
+
expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenLastCalledWith(
|
|
349
|
+
"production",
|
|
350
|
+
undefined,
|
|
351
|
+
expect.objectContaining({ excludeUrl: PROVIDER_URL }),
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("loads an extension account when there is no frictionless state", async () => {
|
|
356
|
+
const harness = build({ withFrictionless: false });
|
|
357
|
+
await harness.manager.start();
|
|
358
|
+
expect(mocks.extensionLoader).toHaveBeenCalled();
|
|
359
|
+
expect(lastUpdate(harness, "account")).toEqual({
|
|
360
|
+
account: { address: USER_ADDRESS },
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("records the site key it was configured with as the dapp account", async () => {
|
|
365
|
+
const harness = build();
|
|
366
|
+
await harness.manager.start();
|
|
367
|
+
expect(lastUpdate(harness, "dappAccount")).toBe(SITE_KEY);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("passes the session id and prefetched SIMD readings to the challenge call", async () => {
|
|
371
|
+
const getSimdReadings =
|
|
372
|
+
vi.fn<(timeoutMs?: number) => Promise<string | undefined>>();
|
|
373
|
+
getSimdReadings.mockResolvedValue("prefetched");
|
|
374
|
+
const harness = build({
|
|
375
|
+
frictionlessState: frictionless({
|
|
376
|
+
sessionId: "session-1",
|
|
377
|
+
getSimdReadings,
|
|
378
|
+
}),
|
|
379
|
+
});
|
|
380
|
+
await harness.manager.start();
|
|
381
|
+
expect(mocks.getPuzzleCaptchaChallenge).toHaveBeenCalledWith(
|
|
382
|
+
USER_ADDRESS,
|
|
383
|
+
SITE_KEY,
|
|
384
|
+
"session-1",
|
|
385
|
+
"prefetched",
|
|
386
|
+
);
|
|
387
|
+
expect(getSimdReadings).toHaveBeenCalledWith(0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("omits SIMD readings when the frictionless state cannot supply them", async () => {
|
|
391
|
+
const harness = build();
|
|
392
|
+
await harness.manager.start();
|
|
393
|
+
expect(mocks.getPuzzleCaptchaChallenge).toHaveBeenCalledWith(
|
|
394
|
+
USER_ADDRESS,
|
|
395
|
+
SITE_KEY,
|
|
396
|
+
undefined,
|
|
397
|
+
undefined,
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("hands the challenge back and drops the loading flag", async () => {
|
|
402
|
+
const harness = build();
|
|
403
|
+
await expect(harness.manager.start()).resolves.toEqual(challengeResponse());
|
|
404
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("start: when the provider will not issue a challenge", () => {
|
|
409
|
+
test("surfaces the provider's error to the widget", async () => {
|
|
410
|
+
mocks.getPuzzleCaptchaChallenge.mockResolvedValue(
|
|
411
|
+
challengeResponse({
|
|
412
|
+
error: {
|
|
413
|
+
message: "no session",
|
|
414
|
+
key: "CAPTCHA.NO_SESSION_FOUND",
|
|
415
|
+
code: 400,
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
const harness = build();
|
|
420
|
+
await harness.manager.start();
|
|
421
|
+
expect(lastUpdate(harness, "error")).toEqual({
|
|
422
|
+
message: "no session",
|
|
423
|
+
key: "CAPTCHA.NO_SESSION_FOUND",
|
|
424
|
+
});
|
|
425
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("labels an error the provider left unkeyed", async () => {
|
|
429
|
+
mocks.getPuzzleCaptchaChallenge.mockResolvedValue(
|
|
430
|
+
challengeResponse({ error: { message: "something broke", code: 500 } }),
|
|
431
|
+
);
|
|
432
|
+
const harness = build();
|
|
433
|
+
await harness.manager.start();
|
|
434
|
+
expect(lastUpdate(harness, "error")).toEqual({
|
|
435
|
+
message: "something broke",
|
|
436
|
+
key: "API.UNKNOWN_ERROR",
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("keeps no challenge, so a submit afterwards cannot pretend one exists", async () => {
|
|
441
|
+
mocks.getPuzzleCaptchaChallenge.mockResolvedValue(
|
|
442
|
+
challengeResponse({ error: { message: "no session", code: 400 } }),
|
|
443
|
+
);
|
|
444
|
+
const harness = build();
|
|
445
|
+
await expect(harness.manager.start()).resolves.toBeUndefined();
|
|
446
|
+
await expect(harness.manager.submitSolution(1, 2, [])).rejects.toThrow();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("web3 mode without a configured account address refuses to continue", async () => {
|
|
450
|
+
const harness = build({
|
|
451
|
+
configInput: config({ web2: false, userAccountAddress: "" }),
|
|
452
|
+
frictionlessState: frictionless({ provider: undefined }),
|
|
453
|
+
});
|
|
454
|
+
await harness.manager.start();
|
|
455
|
+
expect(mocks.getPuzzleCaptchaChallenge).not.toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("retries against a fresh provider when the challenge call throws", async () => {
|
|
459
|
+
mocks.getPuzzleCaptchaChallenge
|
|
460
|
+
.mockRejectedValueOnce(new Error("provider down"))
|
|
461
|
+
.mockResolvedValue(challengeResponse());
|
|
462
|
+
mocks.getProcaptchaRandomActiveProvider
|
|
463
|
+
.mockResolvedValueOnce(randomProvider())
|
|
464
|
+
.mockResolvedValue(randomProvider(OTHER_PROVIDER_URL));
|
|
465
|
+
const harness = build({
|
|
466
|
+
frictionlessState: frictionless({ provider: undefined }),
|
|
467
|
+
});
|
|
468
|
+
await harness.manager.start();
|
|
469
|
+
expect(mocks.getPuzzleCaptchaChallenge).toHaveBeenCalledTimes(2);
|
|
470
|
+
expect(mocks.providerApiConstructions.map((c) => c.url)).toEqual([
|
|
471
|
+
PROVIDER_URL,
|
|
472
|
+
OTHER_PROVIDER_URL,
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("gives up once the retry budget is spent", async () => {
|
|
477
|
+
mocks.getPuzzleCaptchaChallenge.mockRejectedValue(new Error("down"));
|
|
478
|
+
const harness = build({
|
|
479
|
+
frictionlessState: frictionless({ provider: undefined }),
|
|
480
|
+
});
|
|
481
|
+
await harness.manager.start();
|
|
482
|
+
expect(mocks.getPuzzleCaptchaChallenge.mock.calls.length).toBeLessThan(5);
|
|
483
|
+
expect(harness.events.onReset).toHaveBeenCalled();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("submitSolution: refusing to submit without a challenge", () => {
|
|
488
|
+
test("throws when start was never called", async () => {
|
|
489
|
+
const harness = build();
|
|
490
|
+
await expect(
|
|
491
|
+
harness.manager.submitSolution(1, 2, puzzleEvents()),
|
|
492
|
+
).rejects.toThrow();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("throws again after a reset has cleared the stored challenge", async () => {
|
|
496
|
+
const harness = build();
|
|
497
|
+
await harness.manager.start();
|
|
498
|
+
harness.manager.resetState();
|
|
499
|
+
await expect(
|
|
500
|
+
harness.manager.submitSolution(1, 2, puzzleEvents()),
|
|
501
|
+
).rejects.toThrow();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("refuses when the account carries no signer", async () => {
|
|
505
|
+
const harness = build({
|
|
506
|
+
frictionlessState: frictionless({ userAccount: account() }),
|
|
507
|
+
});
|
|
508
|
+
await harness.manager.start();
|
|
509
|
+
await expect(
|
|
510
|
+
harness.manager.submitSolution(1, 2, puzzleEvents()),
|
|
511
|
+
).rejects.toThrow();
|
|
512
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("refuses when the account has no extension at all", async () => {
|
|
516
|
+
const harness = build({
|
|
517
|
+
frictionlessState: frictionless({
|
|
518
|
+
userAccount: accountWithoutExtension(),
|
|
519
|
+
}),
|
|
520
|
+
});
|
|
521
|
+
await harness.manager.start();
|
|
522
|
+
await expect(
|
|
523
|
+
harness.manager.submitSolution(1, 2, puzzleEvents()),
|
|
524
|
+
).rejects.toThrow();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("clears loading and rethrows when the provider call fails", async () => {
|
|
528
|
+
mocks.submitPuzzleCaptchaSolution.mockRejectedValue(new Error("boom"));
|
|
529
|
+
const harness = build();
|
|
530
|
+
await harness.manager.start();
|
|
531
|
+
await expect(
|
|
532
|
+
harness.manager.submitSolution(1, 2, puzzleEvents()),
|
|
533
|
+
).rejects.toThrow("boom");
|
|
534
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe("submitSolution: what it sends", () => {
|
|
539
|
+
test("sends the drop position and the event trail as given", async () => {
|
|
540
|
+
const harness = build();
|
|
541
|
+
const events = puzzleEvents();
|
|
542
|
+
await harness.manager.start();
|
|
543
|
+
await harness.manager.submitSolution(201, 79, events);
|
|
544
|
+
const args = submitArgs();
|
|
545
|
+
expect(args[3]).toBe(201);
|
|
546
|
+
expect(args[4]).toBe(79);
|
|
547
|
+
expect(args[5]).toEqual(events);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("an empty event trail is submitted rather than rejected", async () => {
|
|
551
|
+
const harness = build();
|
|
552
|
+
await harness.manager.start();
|
|
553
|
+
await harness.manager.submitSolution(200, 80, []);
|
|
554
|
+
expect(submitArgs()[5]).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("signs the challenge timestamp with the user's key", async () => {
|
|
558
|
+
const harness = build();
|
|
559
|
+
await harness.manager.start();
|
|
560
|
+
await harness.manager.submitSolution(200, 80, puzzleEvents());
|
|
561
|
+
expect(signRaw).toHaveBeenCalledWith(
|
|
562
|
+
expect.objectContaining({ address: USER_ADDRESS, type: "bytes" }),
|
|
563
|
+
);
|
|
564
|
+
expect(submitArgs()[6]).toBe("0xuser-signature");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("embeds the checkbox coordinates in the salt", async () => {
|
|
568
|
+
const harness = build();
|
|
569
|
+
await solve(harness);
|
|
570
|
+
const salt = submitArgs()[8];
|
|
571
|
+
if (!salt) throw new Error("expected a salt");
|
|
572
|
+
expect(extractData(salt)).toEqual([11, 22]);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("attaches the SIMD readings gathered at submit time", async () => {
|
|
576
|
+
const harness = build();
|
|
577
|
+
await solve(harness);
|
|
578
|
+
expect(submitArgs()[9]).toBe("simd-readings");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("sends the honeypot value when the input was filled", async () => {
|
|
582
|
+
const harness = build({ honeypot: () => "trap" });
|
|
583
|
+
await solve(harness);
|
|
584
|
+
expect(submitArgs()[10]).toEqual({ hp: "trap" });
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("sends no client metadata when the honeypot is empty", async () => {
|
|
588
|
+
const harness = build({ honeypot: () => undefined });
|
|
589
|
+
await solve(harness);
|
|
590
|
+
expect(submitArgs()[10]).toBeUndefined();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("sends no client metadata when there is no honeypot at all", async () => {
|
|
594
|
+
const harness = build();
|
|
595
|
+
await solve(harness);
|
|
596
|
+
expect(submitArgs()[10]).toBeUndefined();
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("submitSolution: behavioural data", () => {
|
|
601
|
+
const withCollectors = (
|
|
602
|
+
encryptBehavioralData: (data: string) => Promise<string>,
|
|
603
|
+
): FrictionlessState =>
|
|
604
|
+
frictionless({
|
|
605
|
+
encryptBehavioralData,
|
|
606
|
+
behaviorCollector1: collector([{ x: 1, y: 2, timestamp: 3 }]),
|
|
607
|
+
deviceCapability: "high",
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("encrypts and attaches what the collectors gathered", async () => {
|
|
611
|
+
const encrypt = vi.fn<(data: string) => Promise<string>>();
|
|
612
|
+
encrypt.mockResolvedValue("encrypted");
|
|
613
|
+
const harness = build({ frictionlessState: withCollectors(encrypt) });
|
|
614
|
+
await solve(harness);
|
|
615
|
+
expect(submitArgs()[7]).toBe("encrypted");
|
|
616
|
+
const payload: unknown = JSON.parse(encrypt.mock.calls[0]?.[0] ?? "null");
|
|
617
|
+
expect(payload).toMatchObject({
|
|
618
|
+
collector1: [{ x: 1, y: 2, timestamp: 3 }],
|
|
619
|
+
collector2: [],
|
|
620
|
+
collector3: [],
|
|
621
|
+
deviceCapability: "high",
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("submits without behavioural data when encryption fails", async () => {
|
|
626
|
+
const encrypt = vi.fn<(data: string) => Promise<string>>();
|
|
627
|
+
encrypt.mockRejectedValue(new Error("no key"));
|
|
628
|
+
const harness = build({ frictionlessState: withCollectors(encrypt) });
|
|
629
|
+
await expect(solve(harness)).resolves.toBe(true);
|
|
630
|
+
expect(submitArgs()[7]).toBeUndefined();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("packs the data first when a packer is provided", async () => {
|
|
634
|
+
const packed: PackedBehavioralData = {
|
|
635
|
+
c1: [],
|
|
636
|
+
c2: [],
|
|
637
|
+
c3: [],
|
|
638
|
+
d: "high",
|
|
639
|
+
};
|
|
640
|
+
const encrypt = vi.fn<(data: string) => Promise<string>>();
|
|
641
|
+
encrypt.mockResolvedValue("encrypted");
|
|
642
|
+
const base = withCollectors(encrypt);
|
|
643
|
+
const harness = build({
|
|
644
|
+
frictionlessState: {
|
|
645
|
+
...base,
|
|
646
|
+
packBehavioralData: (): PackedBehavioralData => packed,
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
await solve(harness);
|
|
650
|
+
expect(encrypt).toHaveBeenCalledWith(JSON.stringify(packed));
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("skips the whole step when no collector is attached", async () => {
|
|
654
|
+
const encrypt = vi.fn<(data: string) => Promise<string>>();
|
|
655
|
+
const harness = build({
|
|
656
|
+
frictionlessState: frictionless({ encryptBehavioralData: encrypt }),
|
|
657
|
+
});
|
|
658
|
+
await solve(harness);
|
|
659
|
+
expect(encrypt).not.toHaveBeenCalled();
|
|
660
|
+
expect(submitArgs()[7]).toBeUndefined();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("skips the whole step when nothing can encrypt", async () => {
|
|
664
|
+
const harness = build({
|
|
665
|
+
frictionlessState: frictionless({
|
|
666
|
+
behaviorCollector1: collector([]),
|
|
667
|
+
}),
|
|
668
|
+
});
|
|
669
|
+
await solve(harness);
|
|
670
|
+
expect(submitArgs()[7]).toBeUndefined();
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe("submitSolution: the verdict", () => {
|
|
675
|
+
test("marks the user human and emits a token the provider can verify", async () => {
|
|
676
|
+
const harness = build();
|
|
677
|
+
await expect(solve(harness)).resolves.toBe(true);
|
|
678
|
+
expect(lastUpdate(harness, "isHuman")).toBe(true);
|
|
679
|
+
const token = harness.events.onHuman.mock.calls[0]?.[0];
|
|
680
|
+
if (!token) throw new Error("expected a token");
|
|
681
|
+
expect(decodeProcaptchaOutput(token)).toMatchObject({
|
|
682
|
+
[ApiParams.providerUrl]: PROVIDER_URL,
|
|
683
|
+
[ApiParams.user]: USER_ADDRESS,
|
|
684
|
+
[ApiParams.dapp]: SITE_KEY,
|
|
685
|
+
[ApiParams.captchaType]: CaptchaType.puzzle,
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("expires the human state once the solution timeout elapses", async () => {
|
|
690
|
+
vi.useFakeTimers();
|
|
691
|
+
const harness = build();
|
|
692
|
+
await solve(harness);
|
|
693
|
+
expect(lastUpdate(harness, "successfullChallengeTimeout")).toBeDefined();
|
|
694
|
+
await vi.runOnlyPendingTimersAsync();
|
|
695
|
+
expect(harness.events.onExpired).toHaveBeenCalled();
|
|
696
|
+
expect(lastUpdate(harness, "isHuman")).toBe(false);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("a rejected solution fails the widget and restarts frictionless", async () => {
|
|
700
|
+
mocks.submitPuzzleCaptchaSolution.mockResolvedValue(
|
|
701
|
+
solutionResponse({ verified: false }),
|
|
702
|
+
);
|
|
703
|
+
const harness = build();
|
|
704
|
+
await expect(solve(harness)).resolves.toBe(false);
|
|
705
|
+
expect(harness.events.onFailed).toHaveBeenCalled();
|
|
706
|
+
expect(harness.events.onHuman).not.toHaveBeenCalled();
|
|
707
|
+
expect(harness.restart).toHaveBeenCalled();
|
|
708
|
+
expect(lastUpdate(harness, "isHuman")).toBe(false);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("a rejected solution without a frictionless state still resets", async () => {
|
|
712
|
+
mocks.submitPuzzleCaptchaSolution.mockResolvedValue(
|
|
713
|
+
solutionResponse({ verified: false }),
|
|
714
|
+
);
|
|
715
|
+
const harness = build({ withFrictionless: false });
|
|
716
|
+
await expect(solve(harness)).resolves.toBe(false);
|
|
717
|
+
expect(harness.events.onReset).toHaveBeenCalled();
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe("resetState", () => {
|
|
722
|
+
test("clears the widget back to its defaults and announces the reset", () => {
|
|
723
|
+
const harness = build();
|
|
724
|
+
harness.manager.resetState();
|
|
725
|
+
expect(harness.updates).toContainEqual(
|
|
726
|
+
expect.objectContaining({
|
|
727
|
+
showModal: false,
|
|
728
|
+
loading: false,
|
|
729
|
+
index: 0,
|
|
730
|
+
isHuman: false,
|
|
731
|
+
}),
|
|
732
|
+
);
|
|
733
|
+
expect(harness.events.onReset).toHaveBeenCalledTimes(1);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("clears both timers so a stale expiry cannot fire later", () => {
|
|
737
|
+
const clear = vi.spyOn(window, "clearTimeout");
|
|
738
|
+
const harness = build({
|
|
739
|
+
initialState: {
|
|
740
|
+
timeout: timerHandle(11),
|
|
741
|
+
successfullChallengeTimeout: timerHandle(22),
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
harness.manager.resetState();
|
|
745
|
+
expect(clear).toHaveBeenCalledWith(timerHandle(11));
|
|
746
|
+
expect(clear).toHaveBeenCalledWith(timerHandle(22));
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("runs the frictionless restart it is handed", () => {
|
|
750
|
+
const restart = vi.fn<() => void>();
|
|
751
|
+
const harness = build();
|
|
752
|
+
harness.manager.resetState(restart);
|
|
753
|
+
expect(restart).toHaveBeenCalledTimes(1);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("a start after a reset re-fetches rather than reusing the old challenge", async () => {
|
|
757
|
+
const harness = build();
|
|
758
|
+
await harness.manager.start();
|
|
759
|
+
harness.manager.resetState();
|
|
760
|
+
await harness.manager.start();
|
|
761
|
+
expect(mocks.getPuzzleCaptchaChallenge).toHaveBeenCalledTimes(2);
|
|
762
|
+
});
|
|
763
|
+
});
|