@prosopo/procaptcha-frictionless 2.15.0 → 2.15.1
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 +9 -9
- package/.turbo/turbo-build$colon$tsc.log +24 -24
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +31 -0
- package/dist/ProcaptchaFrictionless.d.ts.map +1 -1
- package/dist/ProcaptchaFrictionless.js +17 -7
- package/dist/ProcaptchaFrictionless.js.map +1 -1
- package/dist/cjs/ProcaptchaFrictionless.cjs +17 -7
- package/dist/cjs/sessionInvalidatedRecovery.cjs +16 -9
- package/dist/sessionInvalidatedRecovery.d.ts +3 -1
- package/dist/sessionInvalidatedRecovery.d.ts.map +1 -1
- package/dist/sessionInvalidatedRecovery.js +16 -9
- package/dist/sessionInvalidatedRecovery.js.map +1 -1
- package/dist/tests/escalationHandoff.integration.test.d.ts +5 -0
- package/dist/tests/escalationHandoff.integration.test.d.ts.map +1 -0
- package/dist/tests/escalationHandoff.integration.test.js +216 -0
- package/dist/tests/escalationHandoff.integration.test.js.map +1 -0
- package/dist/tests/sessionInvalidatedRecovery.test.js +41 -21
- package/dist/tests/sessionInvalidatedRecovery.test.js.map +1 -1
- package/dist/tests/sessionInvalidatedRemint.test.d.ts +5 -0
- package/dist/tests/sessionInvalidatedRemint.test.d.ts.map +1 -0
- package/dist/tests/sessionInvalidatedRemint.test.js +150 -0
- package/dist/tests/sessionInvalidatedRemint.test.js.map +1 -0
- package/package.json +2 -2
- package/src/ProcaptchaFrictionless.tsx +58 -15
- package/src/sessionInvalidatedRecovery.ts +30 -10
- package/src/tests/escalationHandoff.integration.test.tsx +354 -0
- package/src/tests/sessionInvalidatedRecovery.test.ts +49 -21
- package/src/tests/sessionInvalidatedRemint.test.tsx +245 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
normaliseRetryCoords,
|
|
43
43
|
} from "./sessionInvalidatedRecovery.js";
|
|
44
44
|
|
|
45
|
+
const NO_SESSION_FOUND_KEY = "CAPTCHA.NO_SESSION_FOUND";
|
|
46
|
+
|
|
45
47
|
// Each session uses exactly one solver — chosen by the /frictionless response.
|
|
46
48
|
const ProcaptchaLoader = async () =>
|
|
47
49
|
(await import("@prosopo/procaptcha-react")).Procaptcha;
|
|
@@ -94,7 +96,7 @@ const defaultLoadingState = (
|
|
|
94
96
|
attemptCount: number,
|
|
95
97
|
): FrictionlessLoadingState => ({
|
|
96
98
|
loading: false,
|
|
97
|
-
attemptCount
|
|
99
|
+
attemptCount,
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
export const ProcaptchaFrictionless = ({
|
|
@@ -113,10 +115,22 @@ export const ProcaptchaFrictionless = ({
|
|
|
113
115
|
// to click the checkbox a second time and the checkbox click position is
|
|
114
116
|
// preserved in the eventual solution salt.
|
|
115
117
|
const pendingRetryCoordsRef = useRef<RetryCoords | null>(null);
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
118
|
+
// Bounded outer guard so a persistently broken session doesn't loop. Each
|
|
119
|
+
// re-mint costs a /frictionless round trip, so the count is capped — but
|
|
120
|
+
// it is a count rather than a one-shot, because a widget legitimately
|
|
121
|
+
// mints a new session every time the user presses reload. `onReload`
|
|
122
|
+
// clears it for the same reason. When the budget is spent we fall over
|
|
123
|
+
// visibly (see `onSessionInvalidated`) instead of leaving the user on a
|
|
124
|
+
// dead "No session found" checkbox.
|
|
125
|
+
const sessionInvalidatedAttemptsRef = useRef(0);
|
|
126
|
+
// Escalation sessions we have already mounted a widget for. The provider
|
|
127
|
+
// mints exactly one escalation session per PoW solution and consumes it on
|
|
128
|
+
// the first challenge fetch, so a repeat handoff for the same id can only
|
|
129
|
+
// produce a widget that 400s with NO_SESSION_FOUND. The PoW manager fires
|
|
130
|
+
// `onEscalate` from inside its `providerRetry`-wrapped `submit()`, so a
|
|
131
|
+
// throw anywhere after the handoff re-runs submit and escalates a second
|
|
132
|
+
// time on the same envelope.
|
|
133
|
+
const escalatedSessionIdsRef = useRef(new Set<string>());
|
|
120
134
|
// Bumped on every mount so the replacement widget gets a fresh React
|
|
121
135
|
// `key`. Without it a re-render for the same captcha type reconciles onto
|
|
122
136
|
// the existing element, and the inner widget keeps the manager it built on
|
|
@@ -161,9 +175,14 @@ export const ProcaptchaFrictionless = ({
|
|
|
161
175
|
),
|
|
162
176
|
);
|
|
163
177
|
|
|
178
|
+
// `??`, not `||`: every caller that wants the counter back at zero passes
|
|
179
|
+
// literal 0, and `0 || current` silently kept the old count. `start()` then
|
|
180
|
+
// tripped its own `attemptCount >= 5` fall-over after five *cumulative*
|
|
181
|
+
// runs in a widget lifetime — five reload presses were enough to strand the
|
|
182
|
+
// user on the error placeholder even though every one of them succeeded.
|
|
164
183
|
const resetState = (attemptCount?: number) => {
|
|
165
184
|
stateRef.current = defaultLoadingState(
|
|
166
|
-
attemptCount
|
|
185
|
+
attemptCount ?? stateRef.current.attemptCount,
|
|
167
186
|
);
|
|
168
187
|
};
|
|
169
188
|
|
|
@@ -212,6 +231,12 @@ export const ProcaptchaFrictionless = ({
|
|
|
212
231
|
newSessionId: string,
|
|
213
232
|
coords?: RetryCoords,
|
|
214
233
|
) => {
|
|
234
|
+
// Idempotent per escalation session — see `escalatedSessionIdsRef`.
|
|
235
|
+
// Without this a re-run of the PoW widget's `submit()` mounts a
|
|
236
|
+
// second widget against the session the first one already spent,
|
|
237
|
+
// which the provider answers with 400 CAPTCHA.NO_SESSION_FOUND.
|
|
238
|
+
if (escalatedSessionIdsRef.current.has(newSessionId)) return;
|
|
239
|
+
escalatedSessionIdsRef.current.add(newSessionId);
|
|
215
240
|
void renderForCaptchaType(
|
|
216
241
|
next,
|
|
217
242
|
{
|
|
@@ -225,23 +250,38 @@ export const ProcaptchaFrictionless = ({
|
|
|
225
250
|
|
|
226
251
|
// The provider returned NO_SESSION_FOUND on the inner widget's
|
|
227
252
|
// challenge fetch — the sessionId minted upstream is no longer usable
|
|
228
|
-
// (
|
|
229
|
-
//
|
|
253
|
+
// (a duplicate /captcha/{type} POST consumed it first, or the widget
|
|
254
|
+
// re-sent an id it had already spent). Re-run the frictionless flow to
|
|
230
255
|
// mint a fresh session, then re-mount the inner widget with the
|
|
231
256
|
// preserved checkbox click coords so the user is not asked to click a
|
|
232
|
-
// second time.
|
|
233
|
-
//
|
|
234
|
-
//
|
|
257
|
+
// second time.
|
|
258
|
+
//
|
|
259
|
+
// The inner widget cannot recover on its own here: it always takes
|
|
260
|
+
// this branch and returns before its own `frictionlessState.restart()`
|
|
261
|
+
// fallback, and its guard ref is fresh on every re-mount. So whatever
|
|
262
|
+
// this handler declines to do, nothing else does — hence the terminal
|
|
263
|
+
// `fallOverWithStyle` rather than a silent return once the retry
|
|
264
|
+
// budget is spent.
|
|
235
265
|
const onSessionInvalidated = (x?: number, y?: number) => {
|
|
236
266
|
const { shouldRestart } = handleSessionInvalidated(
|
|
237
267
|
x,
|
|
238
268
|
y,
|
|
239
|
-
|
|
269
|
+
sessionInvalidatedAttemptsRef,
|
|
240
270
|
pendingRetryCoordsRef,
|
|
241
271
|
);
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
272
|
+
if (shouldRestart) {
|
|
273
|
+
resetState(0);
|
|
274
|
+
void start();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Budget spent. Surface the error on the checkbox and let
|
|
278
|
+
// `fallOverWithStyle`'s NO_SESSION_FOUND branch schedule the
|
|
279
|
+
// 10-second full restart, so the user always has a way back.
|
|
280
|
+
const message = i18n.isInitialized
|
|
281
|
+
? i18n.t(NO_SESSION_FOUND_KEY)
|
|
282
|
+
: "No session found";
|
|
283
|
+
events.onError(new Error(message));
|
|
284
|
+
fallOverWithStyle(message, NO_SESSION_FOUND_KEY);
|
|
245
285
|
};
|
|
246
286
|
|
|
247
287
|
// The user pressed reload on the challenge. The provider consumed this
|
|
@@ -253,6 +293,9 @@ export const ProcaptchaFrictionless = ({
|
|
|
253
293
|
const onReload = (x?: number, y?: number) => {
|
|
254
294
|
pendingRetryCoordsRef.current = normaliseRetryCoords(x, y);
|
|
255
295
|
nextMountAutoStartRef.current = true;
|
|
296
|
+
// A reload mints a genuinely new session, so the invalidation
|
|
297
|
+
// budget for the *previous* one shouldn't count against it.
|
|
298
|
+
sessionInvalidatedAttemptsRef.current = 0;
|
|
256
299
|
resetState(0);
|
|
257
300
|
void start();
|
|
258
301
|
};
|
|
@@ -49,23 +49,43 @@ export const normaliseRetryCoords = (
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* How many times a single outer widget will re-mint a session in response to
|
|
53
|
+
* `CAPTCHA.NO_SESSION_FOUND` on the inner widget before giving up and handing
|
|
54
|
+
* over to the terminal fallback.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* This used to be one-shot per outer widget lifetime, which stranded users:
|
|
57
|
+
* the inner widget always takes the `onSessionInvalidated` branch (its own
|
|
58
|
+
* guard ref is fresh on every re-mount, because the outer widget bumps its
|
|
59
|
+
* mount key) and returns without touching its own `restart()` fallback. Once
|
|
60
|
+
* the outer one-shot was spent nothing at all handled the second failure, so
|
|
61
|
+
* the checkbox sat on "No session found" forever. A widget legitimately mints
|
|
62
|
+
* many sessions over its lifetime — every reload press is a new one — so a
|
|
63
|
+
* single lifetime-wide attempt is far too coarse a bound.
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_SESSION_INVALIDATED_RETRIES = 3;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Semantics of the outer recovery handler. Returns whether the caller should
|
|
69
|
+
* proceed to re-run the frictionless flow (`start()`), and mutates the passed
|
|
70
|
+
* refs to record the attempt + pending coords.
|
|
71
|
+
*
|
|
72
|
+
* Bounded rather than one-shot: a persistently broken session still stops
|
|
73
|
+
* looping, but the caller is told (`exhausted`) so it can fall back visibly
|
|
74
|
+
* instead of silently doing nothing.
|
|
58
75
|
*/
|
|
59
76
|
export const handleSessionInvalidated = (
|
|
60
77
|
x: number | undefined,
|
|
61
78
|
y: number | undefined,
|
|
62
|
-
|
|
79
|
+
attemptsRef: MutableRef<number>,
|
|
63
80
|
pendingCoordsRef: MutableRef<RetryCoords | null>,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
maxAttempts: number = MAX_SESSION_INVALIDATED_RETRIES,
|
|
82
|
+
): { shouldRestart: boolean; exhausted: boolean } => {
|
|
83
|
+
if (attemptsRef.current >= maxAttempts) {
|
|
84
|
+
return { shouldRestart: false, exhausted: true };
|
|
85
|
+
}
|
|
86
|
+
attemptsRef.current += 1;
|
|
67
87
|
pendingCoordsRef.current = normaliseRetryCoords(x, y);
|
|
68
|
-
return { shouldRestart: true };
|
|
88
|
+
return { shouldRestart: true, exhausted: false };
|
|
69
89
|
};
|
|
70
90
|
|
|
71
91
|
/**
|
|
@@ -0,0 +1,354 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Post-PoW escalation, end to end on the client: the real
|
|
17
|
+
* ProcaptchaFrictionless wrapper hands off to the real image widget and its
|
|
18
|
+
* real Manager. Only the network is stubbed, and the stub enforces the
|
|
19
|
+
* provider's actual contract — `checkAndRemoveSession` consumes a session the
|
|
20
|
+
* moment it issues a challenge, so a second `/captcha/image` on the same
|
|
21
|
+
* sessionId is answered with 400 CAPTCHA.NO_SESSION_FOUND.
|
|
22
|
+
*
|
|
23
|
+
* Observed in production on provider 3.8.5:
|
|
24
|
+
*
|
|
25
|
+
* 11:27:49.910 POST /pow/solution 200 escalation envelope returned
|
|
26
|
+
* 11:27:50.263 POST /captcha/image 200 escalation session issued
|
|
27
|
+
* 11:27:55.030 POST /captcha/image 400 CAPTCHA.NO_SESSION_FOUND, same session
|
|
28
|
+
*
|
|
29
|
+
* The PoW manager fires `onEscalate` from inside its `providerRetry`-wrapped
|
|
30
|
+
* `submit()`, so a throw after the handoff re-runs submit and escalates a
|
|
31
|
+
* second time on the same envelope.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Ti18n } from "@prosopo/locale";
|
|
35
|
+
import {
|
|
36
|
+
type Account,
|
|
37
|
+
ApiParams,
|
|
38
|
+
type BotDetectionFunction,
|
|
39
|
+
type CaptchaResponseBody,
|
|
40
|
+
CaptchaType,
|
|
41
|
+
ModeEnum,
|
|
42
|
+
type ProcaptchaClientConfigInput,
|
|
43
|
+
type ProcaptchaProps,
|
|
44
|
+
type RandomProvider,
|
|
45
|
+
} from "@prosopo/types";
|
|
46
|
+
import { type ReactElement, act, createElement } from "react";
|
|
47
|
+
import { type Root, createRoot } from "react-dom/client";
|
|
48
|
+
import {
|
|
49
|
+
type Mock,
|
|
50
|
+
afterEach,
|
|
51
|
+
beforeEach,
|
|
52
|
+
describe,
|
|
53
|
+
expect,
|
|
54
|
+
it,
|
|
55
|
+
vi,
|
|
56
|
+
} from "vitest";
|
|
57
|
+
|
|
58
|
+
declare global {
|
|
59
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
63
|
+
|
|
64
|
+
const ESCALATION_SESSION_ID = "provider-session-escalated";
|
|
65
|
+
const FIRST_SESSION_ID = "provider-session-1";
|
|
66
|
+
|
|
67
|
+
// The provider's one-shot session contract, as a stub: the first challenge
|
|
68
|
+
// fetch for a sessionId succeeds, every later one fails the way
|
|
69
|
+
// `checkAndRemoveSession` makes it fail.
|
|
70
|
+
const mocks = vi.hoisted(() => {
|
|
71
|
+
const challengeRequests: (string | undefined)[] = [];
|
|
72
|
+
const consumed = new Set<string>();
|
|
73
|
+
|
|
74
|
+
const body = (): CaptchaResponseBody => ({
|
|
75
|
+
captchas: [
|
|
76
|
+
{
|
|
77
|
+
captchaId: "captcha-id-1",
|
|
78
|
+
captchaContentId: "captcha-content-id-1",
|
|
79
|
+
datasetId: "dataset-id",
|
|
80
|
+
salt: "0xsalt",
|
|
81
|
+
target: "cars",
|
|
82
|
+
items: [],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
requestHash: "0xrequest-hash",
|
|
86
|
+
timestamp: "1700000000000",
|
|
87
|
+
signature: { provider: { requestHash: "0xprovider-request-hash" } },
|
|
88
|
+
status: "ok",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const getCaptchaChallenge = async (
|
|
92
|
+
_userAccount: string,
|
|
93
|
+
_provider: unknown,
|
|
94
|
+
sessionId?: string,
|
|
95
|
+
): Promise<CaptchaResponseBody> => {
|
|
96
|
+
challengeRequests.push(sessionId);
|
|
97
|
+
if (sessionId && consumed.has(sessionId)) {
|
|
98
|
+
// A 400 carrying a JSON body is *returned*, not thrown —
|
|
99
|
+
// HttpClientBase only throws when the failure isn't JSON. This is
|
|
100
|
+
// the envelope the provider's ProsopoApiError serialises to, and
|
|
101
|
+
// `error.key` is what the widget's recovery path keys off.
|
|
102
|
+
return {
|
|
103
|
+
...body(),
|
|
104
|
+
captchas: [],
|
|
105
|
+
error: {
|
|
106
|
+
key: "CAPTCHA.NO_SESSION_FOUND",
|
|
107
|
+
message: "No session found",
|
|
108
|
+
code: 400,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (sessionId) consumed.add(sessionId);
|
|
113
|
+
return body();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
class ProviderApiMock {
|
|
117
|
+
public getCaptchaChallenge = getCaptchaChallenge;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { challengeRequests, consumed, ProviderApiMock };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
vi.mock("@prosopo/api", async (importOriginal) => {
|
|
124
|
+
const actual = await importOriginal<typeof import("@prosopo/api")>();
|
|
125
|
+
return { ...actual, ProviderApi: mocks.ProviderApiMock };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// The image Manager constructs an extension before it can ask for a challenge
|
|
129
|
+
// (it then reads the account off the frictionless state, and nothing in this
|
|
130
|
+
// flow signs anything). `ExtensionLoader` resolves to the class, so the mock
|
|
131
|
+
// has to as well.
|
|
132
|
+
class ExtensionMock {
|
|
133
|
+
public getAccount = async (): Promise<Account> => ({
|
|
134
|
+
account: { address: "user-address" },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
|
|
139
|
+
const actual =
|
|
140
|
+
await importOriginal<typeof import("@prosopo/procaptcha-common")>();
|
|
141
|
+
return {
|
|
142
|
+
...actual,
|
|
143
|
+
isSecureBrowserContext: () => true,
|
|
144
|
+
ExtensionLoader: async (_web2: boolean) => ExtensionMock,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
vi.mock("@prosopo/locale", async (importOriginal) => {
|
|
149
|
+
const actual = await importOriginal<typeof import("@prosopo/locale")>();
|
|
150
|
+
return {
|
|
151
|
+
...actual,
|
|
152
|
+
useTranslation: () => ({ t: (key: string) => key, ready: true }),
|
|
153
|
+
loadI18next: async () => undefined,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The PoW widget is the escalation *source*, so it stays a stub — the test
|
|
158
|
+
// drives its `onEscalate` callback directly. The image widget below it is real.
|
|
159
|
+
const powMounts: { props: ProcaptchaProps }[] = [];
|
|
160
|
+
vi.mock("@prosopo/procaptcha-pow", () => ({
|
|
161
|
+
ProcaptchaPow: (props: ProcaptchaProps) => {
|
|
162
|
+
powMounts.push({ props });
|
|
163
|
+
return createElement("div", { "data-widget": "pow" });
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
vi.mock("@prosopo/procaptcha-puzzle", () => ({
|
|
167
|
+
ProcaptchaPuzzle: () => createElement("div", { "data-widget": "puzzle" }),
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
const { ProcaptchaFrictionless } = await import("../ProcaptchaFrictionless.js");
|
|
171
|
+
|
|
172
|
+
const provider: RandomProvider = {
|
|
173
|
+
providerAccount: "provider-account",
|
|
174
|
+
provider: { url: "https://provider.test" },
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const userAccount: Account = { account: { address: "user-address" } };
|
|
178
|
+
|
|
179
|
+
const config = (): ProcaptchaClientConfigInput => ({
|
|
180
|
+
account: { address: "5siteKey" },
|
|
181
|
+
userAccountAddress: "",
|
|
182
|
+
web2: true,
|
|
183
|
+
mode: ModeEnum.visible,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const i18nStub = {
|
|
187
|
+
isInitialized: true,
|
|
188
|
+
language: "en",
|
|
189
|
+
t: (key: string) => key,
|
|
190
|
+
changeLanguage: vi.fn(),
|
|
191
|
+
} as unknown as Ti18n;
|
|
192
|
+
|
|
193
|
+
const detectBot: Mock<BotDetectionFunction> = vi.fn(async () => ({
|
|
194
|
+
captchaType: CaptchaType.pow,
|
|
195
|
+
sessionId: FIRST_SESSION_ID,
|
|
196
|
+
status: "ok",
|
|
197
|
+
provider,
|
|
198
|
+
userAccount,
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
let container: HTMLDivElement;
|
|
202
|
+
let root: Root;
|
|
203
|
+
|
|
204
|
+
const lastPowMount = () => {
|
|
205
|
+
const mount = powMounts.at(-1);
|
|
206
|
+
if (!mount) throw new Error("expected the pow widget to have mounted");
|
|
207
|
+
return mount;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Flush React work until the challenge traffic has been quiet for four
|
|
212
|
+
* consecutive polls, so the negative assertions know nothing further is on its
|
|
213
|
+
* way. Paired with `waitForChallenge` below rather than used alone: starting
|
|
214
|
+
* from an already-quiet count, a pure quiescence check can declare "settled"
|
|
215
|
+
* before a request that is still coming has been issued.
|
|
216
|
+
*/
|
|
217
|
+
const settle = async (): Promise<void> => {
|
|
218
|
+
let quiet = 0;
|
|
219
|
+
let previous = mocks.challengeRequests.length;
|
|
220
|
+
for (let poll = 0; poll < 40 && quiet < 4; poll++) {
|
|
221
|
+
await act(async () => {
|
|
222
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
223
|
+
});
|
|
224
|
+
const current = mocks.challengeRequests.length;
|
|
225
|
+
quiet = current === previous ? quiet + 1 : 0;
|
|
226
|
+
previous = current;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const challengeRequestCount = (sessionId: string): number =>
|
|
231
|
+
mocks.challengeRequests.filter((id) => id === sessionId).length;
|
|
232
|
+
|
|
233
|
+
/** Flush until the image Manager has issued its challenge fetch. */
|
|
234
|
+
const waitForChallenge = async (sessionId: string): Promise<void> => {
|
|
235
|
+
for (
|
|
236
|
+
let poll = 0;
|
|
237
|
+
poll < 60 && challengeRequestCount(sessionId) === 0;
|
|
238
|
+
poll++
|
|
239
|
+
) {
|
|
240
|
+
await act(async () => {
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The PoW solution came back with an escalation envelope. `expectRequest`
|
|
248
|
+
* distinguishes a handoff that should reach the network from one the wrapper
|
|
249
|
+
* is expected to swallow — waiting for the request in the first case is what
|
|
250
|
+
* keeps a late fetch from leaking into the next assertion.
|
|
251
|
+
*/
|
|
252
|
+
const escalate = async (
|
|
253
|
+
sessionId: string,
|
|
254
|
+
expectRequest = true,
|
|
255
|
+
): Promise<void> => {
|
|
256
|
+
const { onEscalate } = lastPowMount().props;
|
|
257
|
+
await act(async () => {
|
|
258
|
+
onEscalate?.(CaptchaType.image, sessionId, { x: 120, y: 340 });
|
|
259
|
+
});
|
|
260
|
+
if (expectRequest) await waitForChallenge(sessionId);
|
|
261
|
+
await settle();
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
beforeEach(async () => {
|
|
265
|
+
powMounts.length = 0;
|
|
266
|
+
mocks.challengeRequests.length = 0;
|
|
267
|
+
mocks.consumed.clear();
|
|
268
|
+
detectBot.mockClear();
|
|
269
|
+
container = document.createElement("div");
|
|
270
|
+
document.body.appendChild(container);
|
|
271
|
+
act(() => {
|
|
272
|
+
root = createRoot(container);
|
|
273
|
+
});
|
|
274
|
+
await act(async () => {
|
|
275
|
+
root.render(
|
|
276
|
+
createElement(ProcaptchaFrictionless, {
|
|
277
|
+
config: config(),
|
|
278
|
+
callbacks: {},
|
|
279
|
+
restart: vi.fn<() => void>(),
|
|
280
|
+
i18n: i18nStub,
|
|
281
|
+
detectBot,
|
|
282
|
+
}) as ReactElement,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
afterEach(() => {
|
|
288
|
+
act(() => {
|
|
289
|
+
root.unmount();
|
|
290
|
+
});
|
|
291
|
+
container.remove();
|
|
292
|
+
vi.clearAllMocks();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("post-PoW escalation handoff", () => {
|
|
296
|
+
it("fetches the image challenge exactly once for the escalation session", async () => {
|
|
297
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
298
|
+
|
|
299
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("does not re-request the challenge when the same escalation fires twice", async () => {
|
|
303
|
+
// The PoW manager escalates from inside providerRetry, so a retried
|
|
304
|
+
// submit() replays the same envelope. Before the guard this mounted a
|
|
305
|
+
// second image widget against a session the first one had already
|
|
306
|
+
// spent, and the provider answered 400 CAPTCHA.NO_SESSION_FOUND.
|
|
307
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
308
|
+
await escalate(ESCALATION_SESSION_ID, false);
|
|
309
|
+
|
|
310
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("still follows a genuinely new escalation session", async () => {
|
|
314
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
315
|
+
await escalate("provider-session-escalated-2");
|
|
316
|
+
|
|
317
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
318
|
+
expect(challengeRequestCount("provider-session-escalated-2")).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("recovers rather than stranding the user when the escalation session is already gone", async () => {
|
|
322
|
+
// Something else consumed the escalation session first (a duplicate
|
|
323
|
+
// POST from a mount storm). The wrapper must re-mint via /frictionless
|
|
324
|
+
// rather than leaving a dead "No session found" checkbox.
|
|
325
|
+
mocks.consumed.add(ESCALATION_SESSION_ID);
|
|
326
|
+
const detectBotCallsBefore = detectBot.mock.calls.length;
|
|
327
|
+
|
|
328
|
+
await escalate(ESCALATION_SESSION_ID);
|
|
329
|
+
|
|
330
|
+
expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
|
|
331
|
+
expect(detectBot.mock.calls.length).toBeGreaterThan(detectBotCallsBefore);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("the one-shot session contract this suite stubs", () => {
|
|
336
|
+
it("issues a challenge on the first fetch", async () => {
|
|
337
|
+
const api = new mocks.ProviderApiMock();
|
|
338
|
+
|
|
339
|
+
const challenge = await api.getCaptchaChallenge("user", provider, "fresh");
|
|
340
|
+
|
|
341
|
+
expect(challenge[ApiParams.captchas]).toHaveLength(1);
|
|
342
|
+
expect(challenge[ApiParams.error]).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("matches the provider on a second fetch: NO_SESSION_FOUND in the body, not a throw", async () => {
|
|
346
|
+
const api = new mocks.ProviderApiMock();
|
|
347
|
+
await api.getCaptchaChallenge("user", provider, "session-x");
|
|
348
|
+
|
|
349
|
+
const repeat = await api.getCaptchaChallenge("user", provider, "session-x");
|
|
350
|
+
|
|
351
|
+
expect(repeat[ApiParams.error]?.key).toBe("CAPTCHA.NO_SESSION_FOUND");
|
|
352
|
+
expect(repeat[ApiParams.captchas]).toHaveLength(0);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { describe, expect, it } from "vitest";
|
|
16
16
|
import {
|
|
17
|
+
MAX_SESSION_INVALIDATED_RETRIES,
|
|
17
18
|
type MutableRef,
|
|
18
19
|
type RetryCoords,
|
|
19
20
|
consumeRetryMountProps,
|
|
@@ -25,70 +26,97 @@ const ref = <T>(initial: T): MutableRef<T> => ({ current: initial });
|
|
|
25
26
|
|
|
26
27
|
describe("handleSessionInvalidated", () => {
|
|
27
28
|
it("records both coords and signals a restart on the first fire", () => {
|
|
28
|
-
const
|
|
29
|
+
const attemptsRef = ref(0);
|
|
29
30
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
30
31
|
|
|
31
|
-
const result = handleSessionInvalidated(120, 340,
|
|
32
|
+
const result = handleSessionInvalidated(120, 340, attemptsRef, coordsRef);
|
|
32
33
|
|
|
33
|
-
expect(result).toEqual({ shouldRestart: true });
|
|
34
|
-
expect(
|
|
34
|
+
expect(result).toEqual({ shouldRestart: true, exhausted: false });
|
|
35
|
+
expect(attemptsRef.current).toBe(1);
|
|
35
36
|
expect(coordsRef.current).toEqual({ x: 120, y: 340 });
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
it("treats (0, 0) as 'no coords' — that's the autoStart / untrusted-event default, not a real click", () => {
|
|
39
|
-
const
|
|
40
|
+
const attemptsRef = ref(0);
|
|
40
41
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
41
42
|
|
|
42
|
-
const result = handleSessionInvalidated(0, 0,
|
|
43
|
+
const result = handleSessionInvalidated(0, 0, attemptsRef, coordsRef);
|
|
43
44
|
|
|
44
|
-
expect(result).toEqual({ shouldRestart: true });
|
|
45
|
+
expect(result).toEqual({ shouldRestart: true, exhausted: false });
|
|
45
46
|
expect(coordsRef.current).toBeNull();
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
it("carries a real click even if only one axis is at the origin", () => {
|
|
49
|
-
const
|
|
50
|
+
const attemptsRef = ref(0);
|
|
50
51
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
51
52
|
|
|
52
|
-
handleSessionInvalidated(0, 340,
|
|
53
|
+
handleSessionInvalidated(0, 340, attemptsRef, coordsRef);
|
|
53
54
|
|
|
54
55
|
expect(coordsRef.current).toEqual({ x: 0, y: 340 });
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
it("stores no coords when x or y is undefined (autoStart / non-trusted event)", () => {
|
|
58
|
-
const
|
|
59
|
+
const attemptsRef = ref(0);
|
|
59
60
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
60
61
|
|
|
61
62
|
const result = handleSessionInvalidated(
|
|
62
63
|
undefined,
|
|
63
64
|
undefined,
|
|
64
|
-
|
|
65
|
+
attemptsRef,
|
|
65
66
|
coordsRef,
|
|
66
67
|
);
|
|
67
68
|
|
|
68
|
-
expect(result).toEqual({ shouldRestart: true });
|
|
69
|
-
expect(
|
|
69
|
+
expect(result).toEqual({ shouldRestart: true, exhausted: false });
|
|
70
|
+
expect(attemptsRef.current).toBe(1);
|
|
70
71
|
expect(coordsRef.current).toBeNull();
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
it("stores no coords when only one axis is present — never emit NaN into the salt", () => {
|
|
74
|
-
const
|
|
75
|
+
const attemptsRef = ref(0);
|
|
75
76
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
76
77
|
|
|
77
|
-
handleSessionInvalidated(120, undefined,
|
|
78
|
+
handleSessionInvalidated(120, undefined, attemptsRef, coordsRef);
|
|
78
79
|
|
|
79
80
|
expect(coordsRef.current).toBeNull();
|
|
80
81
|
});
|
|
81
82
|
|
|
82
|
-
it("
|
|
83
|
-
const
|
|
83
|
+
it("keeps re-minting up to the retry budget — a reload press is a legitimate new session", () => {
|
|
84
|
+
const attemptsRef = ref(0);
|
|
84
85
|
const coordsRef = ref<RetryCoords | null>(null);
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
for (let i = 0; i < MAX_SESSION_INVALIDATED_RETRIES; i++) {
|
|
88
|
+
expect(
|
|
89
|
+
handleSessionInvalidated(100 + i, 200 + i, attemptsRef, coordsRef),
|
|
90
|
+
).toEqual({ shouldRestart: true, exhausted: false });
|
|
91
|
+
}
|
|
88
92
|
|
|
89
|
-
expect(
|
|
90
|
-
|
|
93
|
+
expect(attemptsRef.current).toBe(MAX_SESSION_INVALIDATED_RETRIES);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("reports exhausted once the budget is spent so the caller can fall over visibly", () => {
|
|
97
|
+
const attemptsRef = ref(MAX_SESSION_INVALIDATED_RETRIES);
|
|
98
|
+
const coordsRef = ref<RetryCoords | null>({ x: 100, y: 200 });
|
|
99
|
+
|
|
100
|
+
const result = handleSessionInvalidated(500, 600, attemptsRef, coordsRef);
|
|
101
|
+
|
|
102
|
+
expect(result).toEqual({ shouldRestart: false, exhausted: true });
|
|
103
|
+
// An exhausted call must not overwrite the pending attempt's coords.
|
|
91
104
|
expect(coordsRef.current).toEqual({ x: 100, y: 200 });
|
|
105
|
+
expect(attemptsRef.current).toBe(MAX_SESSION_INVALIDATED_RETRIES);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("honours a caller-supplied budget", () => {
|
|
109
|
+
const attemptsRef = ref(0);
|
|
110
|
+
const coordsRef = ref<RetryCoords | null>(null);
|
|
111
|
+
|
|
112
|
+
expect(handleSessionInvalidated(1, 2, attemptsRef, coordsRef, 1)).toEqual({
|
|
113
|
+
shouldRestart: true,
|
|
114
|
+
exhausted: false,
|
|
115
|
+
});
|
|
116
|
+
expect(handleSessionInvalidated(3, 4, attemptsRef, coordsRef, 1)).toEqual({
|
|
117
|
+
shouldRestart: false,
|
|
118
|
+
exhausted: true,
|
|
119
|
+
});
|
|
92
120
|
});
|
|
93
121
|
});
|
|
94
122
|
|