@prosopo/procaptcha-frictionless 2.14.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.
Files changed (30) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +9 -9
  2. package/.turbo/turbo-build$colon$tsc.log +24 -24
  3. package/.turbo/turbo-build.log +6 -6
  4. package/CHANGELOG.md +59 -0
  5. package/dist/ProcaptchaFrictionless.d.ts.map +1 -1
  6. package/dist/ProcaptchaFrictionless.js +22 -9
  7. package/dist/ProcaptchaFrictionless.js.map +1 -1
  8. package/dist/cjs/ProcaptchaFrictionless.cjs +22 -9
  9. package/dist/cjs/sessionInvalidatedRecovery.cjs +16 -9
  10. package/dist/sessionInvalidatedRecovery.d.ts +3 -1
  11. package/dist/sessionInvalidatedRecovery.d.ts.map +1 -1
  12. package/dist/sessionInvalidatedRecovery.js +16 -9
  13. package/dist/sessionInvalidatedRecovery.js.map +1 -1
  14. package/dist/tests/escalationHandoff.integration.test.d.ts +5 -0
  15. package/dist/tests/escalationHandoff.integration.test.d.ts.map +1 -0
  16. package/dist/tests/escalationHandoff.integration.test.js +216 -0
  17. package/dist/tests/escalationHandoff.integration.test.js.map +1 -0
  18. package/dist/tests/sessionInvalidatedRecovery.test.js +41 -21
  19. package/dist/tests/sessionInvalidatedRecovery.test.js.map +1 -1
  20. package/dist/tests/sessionInvalidatedRemint.test.d.ts +5 -0
  21. package/dist/tests/sessionInvalidatedRemint.test.d.ts.map +1 -0
  22. package/dist/tests/sessionInvalidatedRemint.test.js +150 -0
  23. package/dist/tests/sessionInvalidatedRemint.test.js.map +1 -0
  24. package/package.json +9 -9
  25. package/src/ProcaptchaFrictionless.tsx +61 -15
  26. package/src/sessionInvalidatedRecovery.ts +30 -10
  27. package/src/tests/escalationHandoff.integration.test.tsx +354 -0
  28. package/src/tests/sessionInvalidatedRecovery.test.ts +49 -21
  29. package/src/tests/sessionInvalidatedRemint.test.tsx +245 -0
  30. 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: attemptCount || 0,
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
- // One-shot outer guard so a persistently broken session doesn't loop.
117
- // After we've retried once, a second NO_SESSION_FOUND falls back to the
118
- // inner widget's own `frictionlessState.restart()` path.
119
- const sessionInvalidatedFiredRef = useRef(false);
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 || stateRef.current.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
- // (usually because a duplicate /captcha/{type} POST from a WebView
229
- // mount storm consumed it first). Re-run the frictionless flow to
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. One-shot per outer widget lifetime — if the retry
233
- // also fails, fall through to the inner widget's existing
234
- // `frictionlessState.restart()` path.
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
- sessionInvalidatedFiredRef,
269
+ sessionInvalidatedAttemptsRef,
240
270
  pendingRetryCoordsRef,
241
271
  );
242
- if (!shouldRestart) return;
243
- resetState(0);
244
- void start();
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
  };
@@ -287,6 +330,7 @@ export const ProcaptchaFrictionless = ({
287
330
  autoStart={resumedAutoStart}
288
331
  startCoords={startCoords}
289
332
  onSessionInvalidated={onSessionInvalidated}
333
+ container={container}
290
334
  onReload={onReload}
291
335
  />,
292
336
  );
@@ -302,6 +346,7 @@ export const ProcaptchaFrictionless = ({
302
346
  autoStart={resumedAutoStart}
303
347
  startCoords={startCoords}
304
348
  onSessionInvalidated={onSessionInvalidated}
349
+ container={container}
305
350
  />,
306
351
  );
307
352
  } else {
@@ -317,6 +362,7 @@ export const ProcaptchaFrictionless = ({
317
362
  autoStart={resumedAutoStart}
318
363
  startCoords={startCoords}
319
364
  onSessionInvalidated={onSessionInvalidated}
365
+ container={container}
320
366
  />,
321
367
  );
322
368
  }
@@ -49,23 +49,43 @@ export const normaliseRetryCoords = (
49
49
  };
50
50
 
51
51
  /**
52
- * Semantics of the outer recovery handler. Returns whether the caller
53
- * should proceed to re-run the frictionless flow (`start()`), and mutates
54
- * the passed refs to record the one-shot fire + pending coords.
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
- * Second calls are ignored (one-shot per outer widget lifetime) so a
57
- * persistently broken session doesn't loop.
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
- firedRef: MutableRef<boolean>,
79
+ attemptsRef: MutableRef<number>,
63
80
  pendingCoordsRef: MutableRef<RetryCoords | null>,
64
- ): { shouldRestart: boolean } => {
65
- if (firedRef.current) return { shouldRestart: false };
66
- firedRef.current = true;
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 firedRef = ref(false);
29
+ const attemptsRef = ref(0);
29
30
  const coordsRef = ref<RetryCoords | null>(null);
30
31
 
31
- const result = handleSessionInvalidated(120, 340, firedRef, coordsRef);
32
+ const result = handleSessionInvalidated(120, 340, attemptsRef, coordsRef);
32
33
 
33
- expect(result).toEqual({ shouldRestart: true });
34
- expect(firedRef.current).toBe(true);
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 firedRef = ref(false);
40
+ const attemptsRef = ref(0);
40
41
  const coordsRef = ref<RetryCoords | null>(null);
41
42
 
42
- const result = handleSessionInvalidated(0, 0, firedRef, coordsRef);
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 firedRef = ref(false);
50
+ const attemptsRef = ref(0);
50
51
  const coordsRef = ref<RetryCoords | null>(null);
51
52
 
52
- handleSessionInvalidated(0, 340, firedRef, coordsRef);
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 firedRef = ref(false);
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
- firedRef,
65
+ attemptsRef,
65
66
  coordsRef,
66
67
  );
67
68
 
68
- expect(result).toEqual({ shouldRestart: true });
69
- expect(firedRef.current).toBe(true);
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 firedRef = ref(false);
75
+ const attemptsRef = ref(0);
75
76
  const coordsRef = ref<RetryCoords | null>(null);
76
77
 
77
- handleSessionInvalidated(120, undefined, firedRef, coordsRef);
78
+ handleSessionInvalidated(120, undefined, attemptsRef, coordsRef);
78
79
 
79
80
  expect(coordsRef.current).toBeNull();
80
81
  });
81
82
 
82
- it("is one-shot per outer widget lifetime — a second call is a no-op", () => {
83
- const firedRef = ref(false);
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
- handleSessionInvalidated(100, 200, firedRef, coordsRef);
87
- const second = handleSessionInvalidated(500, 600, firedRef, coordsRef);
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(second).toEqual({ shouldRestart: false });
90
- // The second call must not overwrite the first attempt's coords.
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