@prosopo/procaptcha-pow 2.10.24 → 2.10.26
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 +5 -5
- package/.turbo/turbo-build$colon$tsc.log +19 -19
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +16 -0
- package/dist/cjs/components/ProcaptchaWidget.cjs +11 -3
- package/dist/cjs/services/Manager.cjs +1 -1
- package/dist/components/ProcaptchaWidget.d.ts.map +1 -1
- package/dist/components/ProcaptchaWidget.js +11 -3
- package/dist/components/ProcaptchaWidget.js.map +1 -1
- package/dist/services/Manager.d.ts.map +1 -1
- package/dist/services/Manager.js +1 -1
- 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 +752 -0
- package/dist/tests/manager.unit.test.js.map +1 -0
- package/dist/tests/managerHarness.d.ts +17 -0
- package/dist/tests/managerHarness.d.ts.map +1 -0
- package/dist/tests/managerHarness.js +70 -0
- package/dist/tests/managerHarness.js.map +1 -0
- package/dist/tests/procaptchaPoW.unit.test.d.ts +2 -0
- package/dist/tests/procaptchaPoW.unit.test.d.ts.map +1 -0
- package/dist/tests/procaptchaPoW.unit.test.js +79 -0
- package/dist/tests/procaptchaPoW.unit.test.js.map +1 -0
- package/dist/tests/procaptchaPow.test-d.d.ts +2 -0
- package/dist/tests/procaptchaPow.test-d.d.ts.map +1 -0
- package/dist/tests/procaptchaPow.test-d.js +62 -0
- package/dist/tests/procaptchaPow.test-d.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 +410 -0
- package/dist/tests/procaptchaWidget.unit.test.js.map +1 -0
- package/package.json +10 -5
- package/src/components/ProcaptchaWidget.tsx +22 -5
- package/src/services/Manager.ts +4 -1
- package/src/tests/manager.unit.test.ts +1105 -0
- package/src/tests/managerHarness.ts +135 -0
- package/src/tests/procaptchaPoW.unit.test.ts +120 -0
- package/src/tests/procaptchaPow.test-d.ts +122 -0
- package/src/tests/procaptchaWidget.unit.test.ts +571 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +25 -0
|
@@ -0,0 +1,1105 @@
|
|
|
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 { FingerprintProof } from "@prosopo/fingerprint";
|
|
16
|
+
import type { pickIpMode } from "@prosopo/procaptcha-common";
|
|
17
|
+
import {
|
|
18
|
+
ApiParams,
|
|
19
|
+
type BehavioralData,
|
|
20
|
+
CaptchaType,
|
|
21
|
+
type ClickEventPoint,
|
|
22
|
+
type ClientMetaData,
|
|
23
|
+
type EnvironmentTypes,
|
|
24
|
+
type FrictionlessState,
|
|
25
|
+
type GetPowCaptchaResponse,
|
|
26
|
+
type MouseMovementPoint,
|
|
27
|
+
type PackedBehavioralData,
|
|
28
|
+
type PowCaptchaSolutionResponse,
|
|
29
|
+
type ProcaptchaCallbacks,
|
|
30
|
+
type ProcaptchaClientConfigInput,
|
|
31
|
+
type ProcaptchaEscalationHandler,
|
|
32
|
+
type ProcaptchaState,
|
|
33
|
+
type ProviderSelectRetryContext,
|
|
34
|
+
type RandomProvider,
|
|
35
|
+
type TouchEventPoint,
|
|
36
|
+
decodeProcaptchaOutput,
|
|
37
|
+
} from "@prosopo/types";
|
|
38
|
+
import { extractData } from "@prosopo/util";
|
|
39
|
+
import {
|
|
40
|
+
type Mock,
|
|
41
|
+
afterEach,
|
|
42
|
+
beforeEach,
|
|
43
|
+
describe,
|
|
44
|
+
expect,
|
|
45
|
+
test,
|
|
46
|
+
vi,
|
|
47
|
+
} from "vitest";
|
|
48
|
+
import { Manager } from "../services/Manager.js";
|
|
49
|
+
|
|
50
|
+
// Taken from procaptcha-common rather than @prosopo/load-balancer directly, so
|
|
51
|
+
// the test does not pull a dependency into this package that the source has no
|
|
52
|
+
// use for.
|
|
53
|
+
type IpMode = ReturnType<typeof pickIpMode>;
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
OTHER_PROVIDER_URL,
|
|
57
|
+
PROVIDER_URL,
|
|
58
|
+
SITE_KEY,
|
|
59
|
+
USER_ADDRESS,
|
|
60
|
+
account,
|
|
61
|
+
accountWithoutExtension,
|
|
62
|
+
callbacks,
|
|
63
|
+
challengeResponse,
|
|
64
|
+
config,
|
|
65
|
+
frictionless,
|
|
66
|
+
randomProvider,
|
|
67
|
+
signRawMock,
|
|
68
|
+
solutionResponse,
|
|
69
|
+
state,
|
|
70
|
+
} from "./managerHarness.js";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Everything the manager reaches out to is replaced here. The manager itself is
|
|
74
|
+
* a closure over its collaborators rather than a class with injection points,
|
|
75
|
+
* so the module boundary is the seam: each mock keeps the real export list
|
|
76
|
+
* intact and swaps only the call the manager makes.
|
|
77
|
+
*/
|
|
78
|
+
const mocks = vi.hoisted(() => {
|
|
79
|
+
const providerApiConstructions: { url: string; siteKey: string }[] = [];
|
|
80
|
+
const getPowCaptchaChallenge =
|
|
81
|
+
vi.fn<
|
|
82
|
+
(
|
|
83
|
+
user: string,
|
|
84
|
+
dapp: string,
|
|
85
|
+
sessionId?: string,
|
|
86
|
+
simdReadings?: string,
|
|
87
|
+
) => Promise<GetPowCaptchaResponse>
|
|
88
|
+
>();
|
|
89
|
+
const submitPowCaptchaSolution =
|
|
90
|
+
vi.fn<
|
|
91
|
+
(
|
|
92
|
+
challenge: GetPowCaptchaResponse,
|
|
93
|
+
userAccount: string,
|
|
94
|
+
dappAccount: string,
|
|
95
|
+
nonce: number,
|
|
96
|
+
userTimestampSignature: string,
|
|
97
|
+
behavioralData?: string,
|
|
98
|
+
salt?: string,
|
|
99
|
+
simdReadings?: string,
|
|
100
|
+
clientMetaData?: ClientMetaData,
|
|
101
|
+
fingerprintProof?: string,
|
|
102
|
+
) => Promise<PowCaptchaSolutionResponse>
|
|
103
|
+
>();
|
|
104
|
+
|
|
105
|
+
class ProviderApiMock {
|
|
106
|
+
public getPowCaptchaChallenge = getPowCaptchaChallenge;
|
|
107
|
+
public submitPowCaptchaSolution = submitPowCaptchaSolution;
|
|
108
|
+
constructor(url: string, siteKey: string) {
|
|
109
|
+
providerApiConstructions.push({ url, siteKey });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const getAccount = vi.fn<() => Promise<unknown>>();
|
|
114
|
+
class ExtensionMock {
|
|
115
|
+
public getAccount = getAccount;
|
|
116
|
+
}
|
|
117
|
+
const extensionLoader = vi.fn<(web2: boolean) => Promise<unknown>>();
|
|
118
|
+
const getProcaptchaRandomActiveProvider =
|
|
119
|
+
vi.fn<
|
|
120
|
+
(
|
|
121
|
+
defaultEnvironment: EnvironmentTypes,
|
|
122
|
+
ipMode?: IpMode,
|
|
123
|
+
retryContext?: ProviderSelectRetryContext,
|
|
124
|
+
) => Promise<RandomProvider>
|
|
125
|
+
>();
|
|
126
|
+
const solvePoW =
|
|
127
|
+
vi.fn<(data: string, difficulty: number) => Promise<number>>();
|
|
128
|
+
const sleep = vi.fn<(ms: number) => Promise<void>>();
|
|
129
|
+
const getFingerprintProof =
|
|
130
|
+
vi.fn<(keys: readonly string[]) => Promise<FingerprintProof>>();
|
|
131
|
+
const encodeFingerprintProof = vi.fn<(proof: FingerprintProof) => string>();
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
providerApiConstructions,
|
|
135
|
+
getPowCaptchaChallenge,
|
|
136
|
+
submitPowCaptchaSolution,
|
|
137
|
+
ProviderApiMock,
|
|
138
|
+
getAccount,
|
|
139
|
+
ExtensionMock,
|
|
140
|
+
extensionLoader,
|
|
141
|
+
getProcaptchaRandomActiveProvider,
|
|
142
|
+
solvePoW,
|
|
143
|
+
sleep,
|
|
144
|
+
getFingerprintProof,
|
|
145
|
+
encodeFingerprintProof,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
vi.mock("@prosopo/api", async (importOriginal) => {
|
|
150
|
+
const actual = await importOriginal<typeof import("@prosopo/api")>();
|
|
151
|
+
return { ...actual, ProviderApi: mocks.ProviderApiMock };
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
|
|
155
|
+
const actual =
|
|
156
|
+
await importOriginal<typeof import("@prosopo/procaptcha-common")>();
|
|
157
|
+
return {
|
|
158
|
+
...actual,
|
|
159
|
+
ExtensionLoader: mocks.extensionLoader,
|
|
160
|
+
getProcaptchaRandomActiveProvider: mocks.getProcaptchaRandomActiveProvider,
|
|
161
|
+
// The real retry, minus its exponential backoff: the delay is covered by
|
|
162
|
+
// the procaptcha-common suite and waiting for it here would add seconds
|
|
163
|
+
// per test for behaviour this suite isn't asserting.
|
|
164
|
+
providerRetry: (
|
|
165
|
+
currentFn: () => Promise<void>,
|
|
166
|
+
retryFn: () => Promise<void>,
|
|
167
|
+
stateReset: () => void,
|
|
168
|
+
attemptCount: number,
|
|
169
|
+
retryMax: number,
|
|
170
|
+
) =>
|
|
171
|
+
actual.providerRetry(
|
|
172
|
+
currentFn,
|
|
173
|
+
retryFn,
|
|
174
|
+
stateReset,
|
|
175
|
+
attemptCount,
|
|
176
|
+
retryMax,
|
|
177
|
+
0,
|
|
178
|
+
),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
vi.mock("@prosopo/util", async (importOriginal) => {
|
|
183
|
+
const actual = await importOriginal<typeof import("@prosopo/util")>();
|
|
184
|
+
return { ...actual, solvePoW: mocks.solvePoW, sleep: mocks.sleep };
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
vi.mock("@prosopo/fingerprint", async (importOriginal) => {
|
|
188
|
+
const actual = await importOriginal<typeof import("@prosopo/fingerprint")>();
|
|
189
|
+
return {
|
|
190
|
+
...actual,
|
|
191
|
+
getFingerprintProof: mocks.getFingerprintProof,
|
|
192
|
+
encodeFingerprintProof: mocks.encodeFingerprintProof,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
interface Harness {
|
|
197
|
+
manager: ReturnType<typeof Manager>;
|
|
198
|
+
state: ProcaptchaState;
|
|
199
|
+
updates: Partial<ProcaptchaState>[];
|
|
200
|
+
events: {
|
|
201
|
+
onHuman: Mock<(token: string) => void>;
|
|
202
|
+
onFailed: Mock<() => void>;
|
|
203
|
+
onExpired: Mock<() => void>;
|
|
204
|
+
onReset: Mock<() => void>;
|
|
205
|
+
};
|
|
206
|
+
onEscalate: Mock<ProcaptchaEscalationHandler>;
|
|
207
|
+
restart: Mock<() => void>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface HarnessOptions {
|
|
211
|
+
configInput?: ProcaptchaClientConfigInput;
|
|
212
|
+
initialState?: Partial<ProcaptchaState>;
|
|
213
|
+
frictionlessState?: FrictionlessState;
|
|
214
|
+
withFrictionless?: boolean;
|
|
215
|
+
honeypot?: () => string | undefined;
|
|
216
|
+
withEscalationHandler?: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const FINGERPRINT_PROOF: FingerprintProof = {
|
|
220
|
+
v: 1,
|
|
221
|
+
root: "0xroot",
|
|
222
|
+
disclosures: [],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const build = (options: HarnessOptions = {}): Harness => {
|
|
226
|
+
const currentState = state(options.initialState);
|
|
227
|
+
const updates: Partial<ProcaptchaState>[] = [];
|
|
228
|
+
const events = {
|
|
229
|
+
onHuman: vi.fn<(token: string) => void>(),
|
|
230
|
+
onFailed: vi.fn<() => void>(),
|
|
231
|
+
onExpired: vi.fn<() => void>(),
|
|
232
|
+
onReset: vi.fn<() => void>(),
|
|
233
|
+
};
|
|
234
|
+
const restart = vi.fn<() => void>();
|
|
235
|
+
const onEscalate = vi.fn<ProcaptchaEscalationHandler>();
|
|
236
|
+
const callbackInput: ProcaptchaCallbacks = callbacks(events);
|
|
237
|
+
const frictionlessState =
|
|
238
|
+
options.frictionlessState ??
|
|
239
|
+
(options.withFrictionless === false
|
|
240
|
+
? undefined
|
|
241
|
+
: frictionless({ restart }));
|
|
242
|
+
const manager = Manager(
|
|
243
|
+
options.configInput ?? config(),
|
|
244
|
+
currentState,
|
|
245
|
+
(next: Partial<ProcaptchaState>) => {
|
|
246
|
+
updates.push({ ...next });
|
|
247
|
+
},
|
|
248
|
+
callbackInput,
|
|
249
|
+
frictionlessState,
|
|
250
|
+
options.withEscalationHandler === false ? undefined : onEscalate,
|
|
251
|
+
options.honeypot,
|
|
252
|
+
);
|
|
253
|
+
return {
|
|
254
|
+
manager,
|
|
255
|
+
state: currentState,
|
|
256
|
+
updates,
|
|
257
|
+
events,
|
|
258
|
+
onEscalate,
|
|
259
|
+
restart,
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** The last value the manager pushed for a given state field. */
|
|
264
|
+
const lastUpdate = <K extends keyof ProcaptchaState>(
|
|
265
|
+
harness: Harness,
|
|
266
|
+
key: K,
|
|
267
|
+
): ProcaptchaState[K] | undefined => {
|
|
268
|
+
const withKey = harness.updates.filter((update) => key in update);
|
|
269
|
+
return withKey.length === 0 ? undefined : withKey[withKey.length - 1]?.[key];
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const submitArgs = (
|
|
273
|
+
callIndex = 0,
|
|
274
|
+
): Parameters<typeof mocks.submitPowCaptchaSolution> => {
|
|
275
|
+
const call = mocks.submitPowCaptchaSolution.mock.calls[callIndex];
|
|
276
|
+
if (!call) throw new Error("expected a solution to have been submitted");
|
|
277
|
+
return call;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const signRaw = signRawMock;
|
|
281
|
+
|
|
282
|
+
beforeEach(() => {
|
|
283
|
+
vi.clearAllMocks();
|
|
284
|
+
mocks.providerApiConstructions.length = 0;
|
|
285
|
+
signRaw.mockResolvedValue({ id: 1, signature: "0xuser-signature" });
|
|
286
|
+
mocks.getAccount.mockResolvedValue(account(signRaw));
|
|
287
|
+
mocks.extensionLoader.mockResolvedValue(mocks.ExtensionMock);
|
|
288
|
+
mocks.getProcaptchaRandomActiveProvider.mockResolvedValue(randomProvider());
|
|
289
|
+
mocks.getPowCaptchaChallenge.mockResolvedValue(challengeResponse());
|
|
290
|
+
mocks.submitPowCaptchaSolution.mockResolvedValue(solutionResponse());
|
|
291
|
+
mocks.solvePoW.mockResolvedValue(42);
|
|
292
|
+
mocks.sleep.mockResolvedValue(undefined);
|
|
293
|
+
mocks.getFingerprintProof.mockResolvedValue(FINGERPRINT_PROOF);
|
|
294
|
+
mocks.encodeFingerprintProof.mockReturnValue("encoded-proof");
|
|
295
|
+
// providerRetry reports every failure it swallows; the suite drives those
|
|
296
|
+
// paths deliberately and the output would bury the real failures.
|
|
297
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
298
|
+
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
afterEach(() => {
|
|
302
|
+
vi.useRealTimers();
|
|
303
|
+
Reflect.deleteProperty(globalThis, "__prosopoFingerprintProofTest__");
|
|
304
|
+
vi.restoreAllMocks();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("start: the cases it refuses to run", () => {
|
|
308
|
+
test("a solve already in flight is left alone", () => {
|
|
309
|
+
const harness = build({ initialState: { loading: true } });
|
|
310
|
+
return harness.manager.start().then(() => {
|
|
311
|
+
expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
|
|
312
|
+
expect(harness.updates).toHaveLength(0);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("a user already proven human is not asked again", async () => {
|
|
317
|
+
const harness = build({ initialState: { isHuman: true } });
|
|
318
|
+
await harness.manager.start();
|
|
319
|
+
expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
|
|
320
|
+
expect(harness.events.onHuman).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("start: getting to a challenge", () => {
|
|
325
|
+
test("resets the widget before it starts, so a stale challenge cannot linger", async () => {
|
|
326
|
+
const harness = build({ initialState: { index: 3, showModal: true } });
|
|
327
|
+
await harness.manager.start();
|
|
328
|
+
const resetAt = harness.updates.findIndex(
|
|
329
|
+
(update) => "showModal" in update,
|
|
330
|
+
);
|
|
331
|
+
expect(harness.updates[resetAt]).toMatchObject({
|
|
332
|
+
showModal: false,
|
|
333
|
+
loading: false,
|
|
334
|
+
index: 0,
|
|
335
|
+
isHuman: false,
|
|
336
|
+
});
|
|
337
|
+
// and it happens before the widget announces it is working
|
|
338
|
+
expect(resetAt).toBeLessThan(
|
|
339
|
+
harness.updates.findIndex((update) => update.loading === true),
|
|
340
|
+
);
|
|
341
|
+
expect(harness.events.onReset).toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("the pre-start reset does not restart the frictionless session", async () => {
|
|
345
|
+
// A restart here would tear down the session the challenge is about to be
|
|
346
|
+
// requested against.
|
|
347
|
+
const harness = build();
|
|
348
|
+
await harness.manager.start();
|
|
349
|
+
expect(harness.restart).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("marks the widget as loading and counts the attempt", async () => {
|
|
353
|
+
const harness = build();
|
|
354
|
+
await harness.manager.start();
|
|
355
|
+
expect(harness.updates).toContainEqual({ loading: true });
|
|
356
|
+
expect(harness.updates).toContainEqual({ attemptCount: 1 });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("takes the account from the frictionless state when there is one", async () => {
|
|
360
|
+
const harness = build();
|
|
361
|
+
await harness.manager.start();
|
|
362
|
+
expect(mocks.extensionLoader).not.toHaveBeenCalled();
|
|
363
|
+
expect(lastUpdate(harness, "account")).toEqual({
|
|
364
|
+
account: { address: USER_ADDRESS },
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("without a frictionless state it loads an extension for the configured mode", async () => {
|
|
369
|
+
const harness = build({ withFrictionless: false });
|
|
370
|
+
await harness.manager.start();
|
|
371
|
+
expect(mocks.extensionLoader).toHaveBeenCalledWith(true);
|
|
372
|
+
expect(mocks.getAccount).toHaveBeenCalledTimes(1);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("a web3 config loads the web3 extension", async () => {
|
|
376
|
+
const harness = build({
|
|
377
|
+
withFrictionless: false,
|
|
378
|
+
configInput: config({ web2: false, userAccountAddress: USER_ADDRESS }),
|
|
379
|
+
});
|
|
380
|
+
await harness.manager.start();
|
|
381
|
+
expect(mocks.extensionLoader).toHaveBeenCalledWith(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("snapshots the site key into the state", async () => {
|
|
385
|
+
const harness = build();
|
|
386
|
+
await harness.manager.start();
|
|
387
|
+
expect(harness.updates).toContainEqual({ dappAccount: SITE_KEY });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("lets the UI catch up with the loading flag before the network work", async () => {
|
|
391
|
+
const harness = build();
|
|
392
|
+
await harness.manager.start();
|
|
393
|
+
expect(mocks.sleep).toHaveBeenCalledWith(100);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("web3 without an account address never reaches a provider", async () => {
|
|
397
|
+
const harness = build({
|
|
398
|
+
withFrictionless: false,
|
|
399
|
+
configInput: config({ web2: false }),
|
|
400
|
+
});
|
|
401
|
+
await harness.manager.start();
|
|
402
|
+
expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("a missing site key stops the solve rather than talking to a provider anonymously", async () => {
|
|
406
|
+
const harness = build({ configInput: config({ account: {} }) });
|
|
407
|
+
await harness.manager.start();
|
|
408
|
+
expect(mocks.getPowCaptchaChallenge).not.toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("start: choosing a provider", () => {
|
|
413
|
+
test("uses the provider the frictionless session already picked", async () => {
|
|
414
|
+
const harness = build();
|
|
415
|
+
await harness.manager.start();
|
|
416
|
+
expect(mocks.getProcaptchaRandomActiveProvider).not.toHaveBeenCalled();
|
|
417
|
+
expect(mocks.providerApiConstructions).toEqual([
|
|
418
|
+
{ url: PROVIDER_URL, siteKey: SITE_KEY },
|
|
419
|
+
]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("otherwise selects one for the configured environment", async () => {
|
|
423
|
+
const harness = build({ withFrictionless: false });
|
|
424
|
+
await harness.manager.start();
|
|
425
|
+
expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith(
|
|
426
|
+
"production",
|
|
427
|
+
undefined,
|
|
428
|
+
{ attempt: 1, excludeUrl: undefined },
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("passes the ipv4 preference through to selection", async () => {
|
|
433
|
+
const harness = build({
|
|
434
|
+
withFrictionless: false,
|
|
435
|
+
configInput: config({ ipv4: true }),
|
|
436
|
+
});
|
|
437
|
+
await harness.manager.start();
|
|
438
|
+
expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith(
|
|
439
|
+
"production",
|
|
440
|
+
"ipv4",
|
|
441
|
+
expect.anything(),
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("passes the ipv6 preference through to selection", async () => {
|
|
446
|
+
const harness = build({
|
|
447
|
+
withFrictionless: false,
|
|
448
|
+
configInput: config({ ipv6: true }),
|
|
449
|
+
});
|
|
450
|
+
await harness.manager.start();
|
|
451
|
+
expect(mocks.getProcaptchaRandomActiveProvider).toHaveBeenCalledWith(
|
|
452
|
+
"production",
|
|
453
|
+
"ipv6",
|
|
454
|
+
expect.anything(),
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("excludes the provider that just failed from the retry", async () => {
|
|
459
|
+
// Retrying against the provider that errored is the one choice guaranteed
|
|
460
|
+
// not to help.
|
|
461
|
+
const harness = build({ withFrictionless: false });
|
|
462
|
+
mocks.getProcaptchaRandomActiveProvider
|
|
463
|
+
.mockResolvedValueOnce(randomProvider())
|
|
464
|
+
.mockResolvedValueOnce(randomProvider(OTHER_PROVIDER_URL));
|
|
465
|
+
mocks.getPowCaptchaChallenge
|
|
466
|
+
.mockRejectedValueOnce(new Error("provider down"))
|
|
467
|
+
.mockResolvedValue(challengeResponse());
|
|
468
|
+
await harness.manager.start();
|
|
469
|
+
expect(mocks.getProcaptchaRandomActiveProvider.mock.calls[1]?.[2]).toEqual({
|
|
470
|
+
// the second attempt, so selection falls back to the provider list
|
|
471
|
+
attempt: 2,
|
|
472
|
+
excludeUrl: PROVIDER_URL,
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
describe("start: the challenge request", () => {
|
|
478
|
+
test("identifies the user, the site and the session", async () => {
|
|
479
|
+
const harness = build({
|
|
480
|
+
frictionlessState: frictionless({ sessionId: "session-1" }),
|
|
481
|
+
});
|
|
482
|
+
await harness.manager.start();
|
|
483
|
+
expect(mocks.getPowCaptchaChallenge).toHaveBeenCalledWith(
|
|
484
|
+
USER_ADDRESS,
|
|
485
|
+
SITE_KEY,
|
|
486
|
+
"session-1",
|
|
487
|
+
undefined,
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("attaches SIMD readings only if they have already resolved", async () => {
|
|
492
|
+
// timeoutMs of 0 means "whatever is ready now" — waiting here would delay
|
|
493
|
+
// the challenge for a signal that has later attach points anyway.
|
|
494
|
+
const getSimdReadings = vi.fn<(ms?: number) => Promise<string | undefined>>(
|
|
495
|
+
async () => "readings",
|
|
496
|
+
);
|
|
497
|
+
const harness = build({
|
|
498
|
+
frictionlessState: frictionless({ getSimdReadings }),
|
|
499
|
+
});
|
|
500
|
+
await harness.manager.start();
|
|
501
|
+
expect(getSimdReadings).toHaveBeenCalledWith(0);
|
|
502
|
+
expect(mocks.getPowCaptchaChallenge.mock.calls[0]?.[3]).toBe("readings");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("a session without a readings source simply omits them", async () => {
|
|
506
|
+
const harness = build();
|
|
507
|
+
await harness.manager.start();
|
|
508
|
+
expect(mocks.getPowCaptchaChallenge.mock.calls[0]?.[3]).toBeUndefined();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("an error in the response is surfaced and the solve stops", async () => {
|
|
512
|
+
const harness = build();
|
|
513
|
+
mocks.getPowCaptchaChallenge.mockResolvedValue(
|
|
514
|
+
challengeResponse({
|
|
515
|
+
error: {
|
|
516
|
+
message: "no session",
|
|
517
|
+
key: "CAPTCHA.NO_SESSION_FOUND",
|
|
518
|
+
code: 400,
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
await harness.manager.start();
|
|
523
|
+
expect(lastUpdate(harness, "error")).toEqual({
|
|
524
|
+
message: "no session",
|
|
525
|
+
key: "CAPTCHA.NO_SESSION_FOUND",
|
|
526
|
+
});
|
|
527
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
528
|
+
expect(mocks.solvePoW).not.toHaveBeenCalled();
|
|
529
|
+
expect(mocks.submitPowCaptchaSolution).not.toHaveBeenCalled();
|
|
530
|
+
expect(harness.events.onFailed).not.toHaveBeenCalled();
|
|
531
|
+
expect(harness.events.onHuman).not.toHaveBeenCalled();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("an error with no key is still reported under a known key", async () => {
|
|
535
|
+
// The widget switches on the key; an undefined one would fall through
|
|
536
|
+
// every branch that handles a specific failure.
|
|
537
|
+
const harness = build();
|
|
538
|
+
mocks.getPowCaptchaChallenge.mockResolvedValue(
|
|
539
|
+
challengeResponse({ error: { message: "boom", code: 500 } }),
|
|
540
|
+
);
|
|
541
|
+
await harness.manager.start();
|
|
542
|
+
expect(lastUpdate(harness, "error")).toEqual({
|
|
543
|
+
message: "boom",
|
|
544
|
+
key: "API.UNKNOWN_ERROR",
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe("start: solving and signing", () => {
|
|
550
|
+
test("solves the challenge the provider issued at its difficulty", async () => {
|
|
551
|
+
const harness = build();
|
|
552
|
+
await harness.manager.start();
|
|
553
|
+
expect(mocks.solvePoW).toHaveBeenCalledWith(
|
|
554
|
+
challengeResponse().challenge,
|
|
555
|
+
2,
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("embeds the click coordinates in the salt", async () => {
|
|
560
|
+
const harness = build();
|
|
561
|
+
await harness.manager.start(120, 45);
|
|
562
|
+
const salt = submitArgs()[6];
|
|
563
|
+
expect(salt).toBeDefined();
|
|
564
|
+
expect(extractData(String(salt))).toEqual([120, 45]);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("a solve with no click still carries a salt, of the origin", async () => {
|
|
568
|
+
const harness = build();
|
|
569
|
+
await harness.manager.start();
|
|
570
|
+
expect(extractData(String(submitArgs()[6]))).toEqual([0, 0]);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("signs the challenge timestamp as the user", async () => {
|
|
574
|
+
const harness = build();
|
|
575
|
+
await harness.manager.start();
|
|
576
|
+
expect(signRaw).toHaveBeenCalledWith({
|
|
577
|
+
address: USER_ADDRESS,
|
|
578
|
+
// stringToHex of the challenge timestamp
|
|
579
|
+
data: "0x31373030303030303030303030",
|
|
580
|
+
type: "bytes",
|
|
581
|
+
});
|
|
582
|
+
expect(submitArgs()[4]).toBe("0xuser-signature");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("an account with no extension cannot prove ownership, so nothing is submitted", async () => {
|
|
586
|
+
const harness = build({
|
|
587
|
+
frictionlessState: frictionless({
|
|
588
|
+
userAccount: accountWithoutExtension(),
|
|
589
|
+
}),
|
|
590
|
+
});
|
|
591
|
+
await harness.manager.start();
|
|
592
|
+
expect(mocks.submitPowCaptchaSolution).not.toHaveBeenCalled();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("an extension that cannot sign raw data is treated the same way", async () => {
|
|
596
|
+
const harness = build({
|
|
597
|
+
frictionlessState: frictionless({ userAccount: account(undefined) }),
|
|
598
|
+
});
|
|
599
|
+
await harness.manager.start();
|
|
600
|
+
expect(mocks.submitPowCaptchaSolution).not.toHaveBeenCalled();
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("start: behavioural data", () => {
|
|
605
|
+
// The three collectors differ only in the point type they hand back, so
|
|
606
|
+
// each fixture builds the same stub around its own `getData`.
|
|
607
|
+
const collectorOf = <T>(
|
|
608
|
+
getData: () => T[],
|
|
609
|
+
): {
|
|
610
|
+
start: () => void;
|
|
611
|
+
stop: () => void;
|
|
612
|
+
clear: () => void;
|
|
613
|
+
getData: () => T[];
|
|
614
|
+
} => ({
|
|
615
|
+
start: () => undefined,
|
|
616
|
+
stop: () => undefined,
|
|
617
|
+
clear: () => undefined,
|
|
618
|
+
getData,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const collector = (points: [number, number][]) =>
|
|
622
|
+
collectorOf<MouseMovementPoint>(() =>
|
|
623
|
+
points.map(([x, y]) => ({ x, y, timestamp: 0 })),
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const touchCollector = (points: [number, number][]) =>
|
|
627
|
+
collectorOf<TouchEventPoint>(() =>
|
|
628
|
+
points.map(([x, y]) => ({
|
|
629
|
+
x,
|
|
630
|
+
y,
|
|
631
|
+
timestamp: 0,
|
|
632
|
+
eventType: "touchstart" as const,
|
|
633
|
+
touchCount: 1,
|
|
634
|
+
})),
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const clickCollector = (points: [number, number][]) =>
|
|
638
|
+
collectorOf<ClickEventPoint>(() =>
|
|
639
|
+
points.map(([x, y]) => ({
|
|
640
|
+
x,
|
|
641
|
+
y,
|
|
642
|
+
timestamp: 0,
|
|
643
|
+
eventType: "click" as const,
|
|
644
|
+
button: 0,
|
|
645
|
+
})),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
test("is encrypted and submitted when a collector and a cipher are present", async () => {
|
|
649
|
+
const encryptBehavioralData = vi.fn<(data: string) => Promise<string>>(
|
|
650
|
+
async () => "cipher",
|
|
651
|
+
);
|
|
652
|
+
const harness = build({
|
|
653
|
+
frictionlessState: frictionless({
|
|
654
|
+
encryptBehavioralData,
|
|
655
|
+
behaviorCollector1: collector([[1, 2]]),
|
|
656
|
+
deviceCapability: "high",
|
|
657
|
+
}),
|
|
658
|
+
});
|
|
659
|
+
await harness.manager.start();
|
|
660
|
+
const payload: unknown = JSON.parse(
|
|
661
|
+
encryptBehavioralData.mock.calls[0]?.[0] ?? "null",
|
|
662
|
+
);
|
|
663
|
+
expect(payload).toMatchObject({
|
|
664
|
+
collector1: [{ x: 1, y: 2, timestamp: 0 }],
|
|
665
|
+
collector2: [],
|
|
666
|
+
collector3: [],
|
|
667
|
+
deviceCapability: "high",
|
|
668
|
+
});
|
|
669
|
+
expect(submitArgs()[5]).toBe("cipher");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("a session that never reported a device capability is labelled unknown", async () => {
|
|
673
|
+
const encryptBehavioralData = vi.fn<(data: string) => Promise<string>>(
|
|
674
|
+
async () => "cipher",
|
|
675
|
+
);
|
|
676
|
+
const harness = build({
|
|
677
|
+
frictionlessState: frictionless({
|
|
678
|
+
encryptBehavioralData,
|
|
679
|
+
behaviorCollector3: clickCollector([]),
|
|
680
|
+
}),
|
|
681
|
+
});
|
|
682
|
+
await harness.manager.start();
|
|
683
|
+
expect(
|
|
684
|
+
JSON.parse(encryptBehavioralData.mock.calls[0]?.[0] ?? "null"),
|
|
685
|
+
).toMatchObject({ deviceCapability: "unknown" });
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("is packed first when the session knows how to pack it", async () => {
|
|
689
|
+
const encryptBehavioralData = vi.fn<(data: string) => Promise<string>>(
|
|
690
|
+
async () => "cipher",
|
|
691
|
+
);
|
|
692
|
+
const packBehavioralData = vi.fn<
|
|
693
|
+
(data: BehavioralData) => PackedBehavioralData
|
|
694
|
+
>(() => ({ c1: [], c2: [], c3: [], d: "packed" }));
|
|
695
|
+
const harness = build({
|
|
696
|
+
frictionlessState: frictionless({
|
|
697
|
+
encryptBehavioralData,
|
|
698
|
+
packBehavioralData,
|
|
699
|
+
behaviorCollector2: touchCollector([[3, 4]]),
|
|
700
|
+
}),
|
|
701
|
+
});
|
|
702
|
+
await harness.manager.start();
|
|
703
|
+
expect(packBehavioralData).toHaveBeenCalledTimes(1);
|
|
704
|
+
expect(encryptBehavioralData).toHaveBeenCalledWith(
|
|
705
|
+
JSON.stringify({ c1: [], c2: [], c3: [], d: "packed" }),
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("no collector means nothing is collected, even with a cipher available", async () => {
|
|
710
|
+
const encryptBehavioralData = vi.fn<(data: string) => Promise<string>>(
|
|
711
|
+
async () => "cipher",
|
|
712
|
+
);
|
|
713
|
+
const harness = build({
|
|
714
|
+
frictionlessState: frictionless({ encryptBehavioralData }),
|
|
715
|
+
});
|
|
716
|
+
await harness.manager.start();
|
|
717
|
+
expect(encryptBehavioralData).not.toHaveBeenCalled();
|
|
718
|
+
expect(submitArgs()[5]).toBeUndefined();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("no cipher means the collectors are not even read", async () => {
|
|
722
|
+
const behaviorCollector1 = collector([[1, 1]]);
|
|
723
|
+
const getData = vi.spyOn(behaviorCollector1, "getData");
|
|
724
|
+
const harness = build({
|
|
725
|
+
frictionlessState: frictionless({ behaviorCollector1 }),
|
|
726
|
+
});
|
|
727
|
+
await harness.manager.start();
|
|
728
|
+
expect(getData).not.toHaveBeenCalled();
|
|
729
|
+
expect(submitArgs()[5]).toBeUndefined();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("a failure to encrypt does not cost the user their solve", async () => {
|
|
733
|
+
// Behavioural data is an analytics signal; losing it must not turn a
|
|
734
|
+
// solved captcha into a failed one.
|
|
735
|
+
const harness = build({
|
|
736
|
+
frictionlessState: frictionless({
|
|
737
|
+
encryptBehavioralData: async () => {
|
|
738
|
+
throw new Error("no key");
|
|
739
|
+
},
|
|
740
|
+
behaviorCollector1: collector([[1, 2]]),
|
|
741
|
+
}),
|
|
742
|
+
});
|
|
743
|
+
await harness.manager.start();
|
|
744
|
+
expect(submitArgs()[5]).toBeUndefined();
|
|
745
|
+
expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("a collector that throws is handled the same way", async () => {
|
|
749
|
+
const harness = build({
|
|
750
|
+
frictionlessState: frictionless({
|
|
751
|
+
encryptBehavioralData: async () => "cipher",
|
|
752
|
+
behaviorCollector1: {
|
|
753
|
+
start: () => undefined,
|
|
754
|
+
stop: () => undefined,
|
|
755
|
+
clear: () => undefined,
|
|
756
|
+
getData: () => {
|
|
757
|
+
throw new Error("detached");
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
}),
|
|
761
|
+
});
|
|
762
|
+
await harness.manager.start();
|
|
763
|
+
expect(submitArgs()[5]).toBeUndefined();
|
|
764
|
+
expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe("start: the rest of the submission", () => {
|
|
769
|
+
test("waits for SIMD readings before submitting", async () => {
|
|
770
|
+
const getSimdReadings = vi.fn<(ms?: number) => Promise<string | undefined>>(
|
|
771
|
+
async (ms?: number) => (ms === 0 ? undefined : "late-readings"),
|
|
772
|
+
);
|
|
773
|
+
const harness = build({
|
|
774
|
+
frictionlessState: frictionless({ getSimdReadings }),
|
|
775
|
+
});
|
|
776
|
+
await harness.manager.start();
|
|
777
|
+
expect(submitArgs()[7]).toBe("late-readings");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("a filled honeypot is reported with the solution", async () => {
|
|
781
|
+
const harness = build({ honeypot: () => "bot@example.com" });
|
|
782
|
+
await harness.manager.start();
|
|
783
|
+
expect(submitArgs()[8]).toEqual({ hp: "bot@example.com" });
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("an untouched honeypot adds nothing to the payload", async () => {
|
|
787
|
+
const harness = build({ honeypot: () => "" });
|
|
788
|
+
await harness.manager.start();
|
|
789
|
+
expect(submitArgs()[8]).toBeUndefined();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("no honeypot reader at all is fine", async () => {
|
|
793
|
+
const harness = build();
|
|
794
|
+
await harness.manager.start();
|
|
795
|
+
expect(submitArgs()[8]).toBeUndefined();
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("discloses only the keys the validator checks", async () => {
|
|
799
|
+
const harness = build();
|
|
800
|
+
await harness.manager.start();
|
|
801
|
+
expect(mocks.getFingerprintProof).toHaveBeenCalledTimes(1);
|
|
802
|
+
expect(submitArgs()[9]).toBe("encoded-proof");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("an unavailable fingerprint does not block the submission", async () => {
|
|
806
|
+
const harness = build();
|
|
807
|
+
mocks.getFingerprintProof.mockRejectedValue(new Error("unavailable"));
|
|
808
|
+
await harness.manager.start();
|
|
809
|
+
expect(submitArgs()[9]).toBeUndefined();
|
|
810
|
+
expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test("a proof that cannot be encoded is dropped rather than raised", async () => {
|
|
814
|
+
const harness = build();
|
|
815
|
+
mocks.encodeFingerprintProof.mockImplementation(() => {
|
|
816
|
+
throw new Error("bad proof");
|
|
817
|
+
});
|
|
818
|
+
await harness.manager.start();
|
|
819
|
+
expect(submitArgs()[9]).toBeUndefined();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("the test hook can omit the proof", async () => {
|
|
823
|
+
const harness = build();
|
|
824
|
+
Reflect.set(globalThis, "__prosopoFingerprintProofTest__", "omit");
|
|
825
|
+
await harness.manager.start();
|
|
826
|
+
expect(submitArgs()[9]).toBeUndefined();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("the test hook can send a proof the provider will reject", async () => {
|
|
830
|
+
const harness = build();
|
|
831
|
+
Reflect.set(globalThis, "__prosopoFingerprintProofTest__", "malformed");
|
|
832
|
+
await harness.manager.start();
|
|
833
|
+
expect(submitArgs()[9]).toBe("invalid-fingerprint-proof");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("an empty hook value leaves the real proof alone", async () => {
|
|
837
|
+
const harness = build();
|
|
838
|
+
Reflect.set(globalThis, "__prosopoFingerprintProofTest__", "");
|
|
839
|
+
await harness.manager.start();
|
|
840
|
+
expect(submitArgs()[9]).toBe("encoded-proof");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("a hook value that is not a string leaves the real proof alone", async () => {
|
|
844
|
+
const harness = build();
|
|
845
|
+
Reflect.set(globalThis, "__prosopoFingerprintProofTest__", 1);
|
|
846
|
+
await harness.manager.start();
|
|
847
|
+
expect(submitArgs()[9]).toBe("encoded-proof");
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("submits the challenge, the solver's nonce and both accounts", async () => {
|
|
851
|
+
const harness = build();
|
|
852
|
+
await harness.manager.start();
|
|
853
|
+
const [challenge, user, dapp, nonce] = submitArgs();
|
|
854
|
+
expect(challenge).toEqual(challengeResponse());
|
|
855
|
+
expect(user).toBe(USER_ADDRESS);
|
|
856
|
+
expect(dapp).toBe(SITE_KEY);
|
|
857
|
+
expect(nonce).toBe(42);
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
describe("start: what the provider decides", () => {
|
|
862
|
+
test("a verified solution makes the user human and issues a token", async () => {
|
|
863
|
+
const harness = build();
|
|
864
|
+
await harness.manager.start();
|
|
865
|
+
expect(lastUpdate(harness, "isHuman")).toBe(true);
|
|
866
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
867
|
+
const token = harness.events.onHuman.mock.calls[0]?.[0];
|
|
868
|
+
expect(token).toBeDefined();
|
|
869
|
+
const output = decodeProcaptchaOutput(String(token));
|
|
870
|
+
expect(output).toMatchObject({
|
|
871
|
+
[ApiParams.providerUrl]: PROVIDER_URL,
|
|
872
|
+
[ApiParams.user]: USER_ADDRESS,
|
|
873
|
+
[ApiParams.dapp]: SITE_KEY,
|
|
874
|
+
[ApiParams.challenge]: challengeResponse().challenge,
|
|
875
|
+
[ApiParams.nonce]: 42,
|
|
876
|
+
[ApiParams.timestamp]: challengeResponse().timestamp,
|
|
877
|
+
[ApiParams.captchaType]: CaptchaType.pow,
|
|
878
|
+
});
|
|
879
|
+
expect(harness.events.onFailed).not.toHaveBeenCalled();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("the token carries both signatures the provider will need to verify it", async () => {
|
|
883
|
+
const harness = build();
|
|
884
|
+
await harness.manager.start();
|
|
885
|
+
const output = decodeProcaptchaOutput(
|
|
886
|
+
String(harness.events.onHuman.mock.calls[0]?.[0]),
|
|
887
|
+
);
|
|
888
|
+
expect(output[ApiParams.signature]).toEqual({
|
|
889
|
+
[ApiParams.provider]: { [ApiParams.challenge]: "0xprovider-challenge" },
|
|
890
|
+
[ApiParams.user]: { [ApiParams.timestamp]: "0xuser-signature" },
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("the human state expires, and the widget goes back to the start", async () => {
|
|
895
|
+
vi.useFakeTimers();
|
|
896
|
+
const harness = build();
|
|
897
|
+
await harness.manager.start();
|
|
898
|
+
expect(lastUpdate(harness, "successfullChallengeTimeout")).toBeDefined();
|
|
899
|
+
harness.events.onReset.mockClear();
|
|
900
|
+
vi.runOnlyPendingTimers();
|
|
901
|
+
expect(harness.events.onExpired).toHaveBeenCalledTimes(1);
|
|
902
|
+
expect(lastUpdate(harness, "isHuman")).toBe(false);
|
|
903
|
+
expect(harness.events.onReset).toHaveBeenCalledTimes(1);
|
|
904
|
+
expect(harness.restart).toHaveBeenCalledTimes(1);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test("an unverified solution fails the user and resets the session", async () => {
|
|
908
|
+
const harness = build();
|
|
909
|
+
mocks.submitPowCaptchaSolution.mockResolvedValue(
|
|
910
|
+
solutionResponse({ verified: false }),
|
|
911
|
+
);
|
|
912
|
+
await harness.manager.start();
|
|
913
|
+
expect(harness.events.onFailed).toHaveBeenCalledTimes(1);
|
|
914
|
+
expect(lastUpdate(harness, "isHuman")).toBe(false);
|
|
915
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
916
|
+
expect(harness.restart).toHaveBeenCalledTimes(1);
|
|
917
|
+
expect(harness.events.onHuman).not.toHaveBeenCalled();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("an escalation hands over without declaring success or failure", async () => {
|
|
921
|
+
const harness = build();
|
|
922
|
+
mocks.submitPowCaptchaSolution.mockResolvedValue(
|
|
923
|
+
solutionResponse({
|
|
924
|
+
verified: true,
|
|
925
|
+
escalation: {
|
|
926
|
+
[ApiParams.captchaType]: CaptchaType.image,
|
|
927
|
+
[ApiParams.sessionId]: "escalated-session",
|
|
928
|
+
},
|
|
929
|
+
}),
|
|
930
|
+
);
|
|
931
|
+
await harness.manager.start(10, 20);
|
|
932
|
+
expect(harness.onEscalate).toHaveBeenCalledWith(
|
|
933
|
+
CaptchaType.image,
|
|
934
|
+
"escalated-session",
|
|
935
|
+
{ x: 10, y: 20 },
|
|
936
|
+
);
|
|
937
|
+
expect(harness.events.onHuman).not.toHaveBeenCalled();
|
|
938
|
+
expect(harness.events.onFailed).not.toHaveBeenCalled();
|
|
939
|
+
expect(lastUpdate(harness, "loading")).toBe(false);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("a puzzle escalation is handed over the same way", async () => {
|
|
943
|
+
const harness = build();
|
|
944
|
+
mocks.submitPowCaptchaSolution.mockResolvedValue(
|
|
945
|
+
solutionResponse({
|
|
946
|
+
verified: false,
|
|
947
|
+
escalation: {
|
|
948
|
+
[ApiParams.captchaType]: CaptchaType.puzzle,
|
|
949
|
+
[ApiParams.sessionId]: "escalated-session",
|
|
950
|
+
},
|
|
951
|
+
}),
|
|
952
|
+
);
|
|
953
|
+
await harness.manager.start(0, 7);
|
|
954
|
+
expect(harness.onEscalate).toHaveBeenCalledWith(
|
|
955
|
+
CaptchaType.puzzle,
|
|
956
|
+
"escalated-session",
|
|
957
|
+
{ x: 0, y: 7 },
|
|
958
|
+
);
|
|
959
|
+
expect(harness.events.onFailed).not.toHaveBeenCalled();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("an escalation with no real click forwards no coordinates", async () => {
|
|
963
|
+
// (0, 0) is what an autoStart or an untrusted event produces; passing it
|
|
964
|
+
// on would seed the next widget's salt with a click that never happened.
|
|
965
|
+
const harness = build();
|
|
966
|
+
mocks.submitPowCaptchaSolution.mockResolvedValue(
|
|
967
|
+
solutionResponse({
|
|
968
|
+
escalation: {
|
|
969
|
+
[ApiParams.captchaType]: CaptchaType.image,
|
|
970
|
+
[ApiParams.sessionId]: "escalated-session",
|
|
971
|
+
},
|
|
972
|
+
}),
|
|
973
|
+
);
|
|
974
|
+
await harness.manager.start();
|
|
975
|
+
expect(harness.onEscalate).toHaveBeenCalledWith(
|
|
976
|
+
CaptchaType.image,
|
|
977
|
+
"escalated-session",
|
|
978
|
+
undefined,
|
|
979
|
+
);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test("an escalation nobody is listening for does not throw", async () => {
|
|
983
|
+
const harness = build({ withEscalationHandler: false });
|
|
984
|
+
mocks.submitPowCaptchaSolution.mockResolvedValue(
|
|
985
|
+
solutionResponse({
|
|
986
|
+
escalation: {
|
|
987
|
+
[ApiParams.captchaType]: CaptchaType.image,
|
|
988
|
+
[ApiParams.sessionId]: "escalated-session",
|
|
989
|
+
},
|
|
990
|
+
}),
|
|
991
|
+
);
|
|
992
|
+
await expect(harness.manager.start()).resolves.toBeUndefined();
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
describe("start: retrying a failed provider", () => {
|
|
997
|
+
test("a failure is retried against a fresh provider", async () => {
|
|
998
|
+
const harness = build({ withFrictionless: false });
|
|
999
|
+
mocks.getPowCaptchaChallenge
|
|
1000
|
+
.mockRejectedValueOnce(new Error("provider down"))
|
|
1001
|
+
.mockResolvedValue(challengeResponse());
|
|
1002
|
+
await harness.manager.start();
|
|
1003
|
+
expect(mocks.getPowCaptchaChallenge).toHaveBeenCalledTimes(2);
|
|
1004
|
+
expect(harness.events.onHuman).toHaveBeenCalledTimes(1);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test("the retry keeps the coordinates of the original click", async () => {
|
|
1008
|
+
// Losing them mid-retry means an eventual escalation seeds the image
|
|
1009
|
+
// widget's salt with a click at the origin instead of the real one.
|
|
1010
|
+
const harness = build({ withFrictionless: false });
|
|
1011
|
+
mocks.getPowCaptchaChallenge
|
|
1012
|
+
.mockRejectedValueOnce(new Error("provider down"))
|
|
1013
|
+
.mockResolvedValue(challengeResponse());
|
|
1014
|
+
mocks.submitPowCaptchaSolution.mockResolvedValue(
|
|
1015
|
+
solutionResponse({
|
|
1016
|
+
escalation: {
|
|
1017
|
+
[ApiParams.captchaType]: CaptchaType.image,
|
|
1018
|
+
[ApiParams.sessionId]: "escalated-session",
|
|
1019
|
+
},
|
|
1020
|
+
}),
|
|
1021
|
+
);
|
|
1022
|
+
await harness.manager.start(33, 44);
|
|
1023
|
+
expect(harness.onEscalate).toHaveBeenCalledWith(
|
|
1024
|
+
CaptchaType.image,
|
|
1025
|
+
"escalated-session",
|
|
1026
|
+
{ x: 33, y: 44 },
|
|
1027
|
+
);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("a widget that has already used its attempts gives up instead of looping", async () => {
|
|
1031
|
+
const harness = build({
|
|
1032
|
+
withFrictionless: false,
|
|
1033
|
+
initialState: { attemptCount: 3 },
|
|
1034
|
+
});
|
|
1035
|
+
mocks.getPowCaptchaChallenge.mockRejectedValue(new Error("provider down"));
|
|
1036
|
+
await harness.manager.start();
|
|
1037
|
+
expect(mocks.getPowCaptchaChallenge).toHaveBeenCalledTimes(1);
|
|
1038
|
+
expect(harness.events.onReset).toHaveBeenCalled();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("a failure in the middle of a solve still resets the widget", async () => {
|
|
1042
|
+
const harness = build({
|
|
1043
|
+
withFrictionless: false,
|
|
1044
|
+
initialState: { attemptCount: 3 },
|
|
1045
|
+
});
|
|
1046
|
+
mocks.submitPowCaptchaSolution.mockRejectedValue(new Error("500"));
|
|
1047
|
+
await harness.manager.start();
|
|
1048
|
+
expect(harness.events.onHuman).not.toHaveBeenCalled();
|
|
1049
|
+
expect(harness.events.onReset).toHaveBeenCalled();
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
describe("the account the config ends up carrying", () => {
|
|
1054
|
+
test("an account picked mid-solve wins over the one the page was configured with", async () => {
|
|
1055
|
+
// The user can switch accounts in their extension between the click and
|
|
1056
|
+
// the submission; the solve must belong to the account that signed it.
|
|
1057
|
+
const harness = build({
|
|
1058
|
+
configInput: config({ userAccountAddress: "stale-address" }),
|
|
1059
|
+
});
|
|
1060
|
+
await harness.manager.start();
|
|
1061
|
+
expect(mocks.getPowCaptchaChallenge.mock.calls[0]?.[0]).toBe(USER_ADDRESS);
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
describe("resetState", () => {
|
|
1066
|
+
test("clears both timers and tells the page the widget was reset", () => {
|
|
1067
|
+
const harness = build({
|
|
1068
|
+
initialState: {
|
|
1069
|
+
timeout: setTimeout(() => undefined, 1000),
|
|
1070
|
+
successfullChallengeTimeout: setTimeout(() => undefined, 1000),
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
1073
|
+
const clear = vi.spyOn(window, "clearTimeout");
|
|
1074
|
+
harness.manager.resetState();
|
|
1075
|
+
expect(clear).toHaveBeenCalledTimes(2);
|
|
1076
|
+
expect(harness.updates).toContainEqual({ timeout: undefined });
|
|
1077
|
+
expect(harness.updates).toContainEqual({
|
|
1078
|
+
successfullChallengeTimeout: undefined,
|
|
1079
|
+
});
|
|
1080
|
+
expect(harness.events.onReset).toHaveBeenCalledTimes(1);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test("restarts the frictionless session only when asked to", () => {
|
|
1084
|
+
const harness = build();
|
|
1085
|
+
harness.manager.resetState();
|
|
1086
|
+
expect(harness.restart).not.toHaveBeenCalled();
|
|
1087
|
+
harness.manager.resetState(harness.restart);
|
|
1088
|
+
expect(harness.restart).toHaveBeenCalledTimes(1);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
test("puts every field back to its default", () => {
|
|
1092
|
+
const harness = build({
|
|
1093
|
+
initialState: { index: 4, isHuman: true, showModal: true },
|
|
1094
|
+
});
|
|
1095
|
+
harness.manager.resetState();
|
|
1096
|
+
expect(harness.state).toMatchObject({
|
|
1097
|
+
showModal: false,
|
|
1098
|
+
loading: false,
|
|
1099
|
+
index: 0,
|
|
1100
|
+
isHuman: false,
|
|
1101
|
+
challenge: undefined,
|
|
1102
|
+
account: undefined,
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
});
|