@prosopo/procaptcha-frictionless 2.16.2 → 2.16.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$cjs.log +4 -4
- package/.turbo/turbo-build$colon$tsc.log +24 -24
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +73 -0
- package/dist/ProcaptchaFrictionless.d.ts.map +1 -1
- package/dist/ProcaptchaFrictionless.js +34 -2
- package/dist/ProcaptchaFrictionless.js.map +1 -1
- package/dist/cjs/ProcaptchaFrictionless.cjs +33 -1
- package/dist/cjs/frictionlessResultGuard.cjs +43 -6
- package/dist/frictionlessResultGuard.d.ts +1 -0
- package/dist/frictionlessResultGuard.d.ts.map +1 -1
- package/dist/frictionlessResultGuard.js +43 -6
- package/dist/frictionlessResultGuard.js.map +1 -1
- package/dist/tests/executeBeforeMount.test.d.ts +5 -0
- package/dist/tests/executeBeforeMount.test.d.ts.map +1 -0
- package/dist/tests/executeBeforeMount.test.js +152 -0
- package/dist/tests/executeBeforeMount.test.js.map +1 -0
- package/dist/tests/frictionlessResultGuard.test.js +59 -2
- package/dist/tests/frictionlessResultGuard.test.js.map +1 -1
- package/dist/tests/manualStart.test.js +1 -1
- package/dist/tests/manualStart.test.js.map +1 -1
- package/package.json +12 -12
- package/src/ProcaptchaFrictionless.tsx +60 -0
- package/src/frictionlessResultGuard.ts +49 -3
- package/src/tests/executeBeforeMount.test.tsx +231 -0
- package/src/tests/frictionlessResultGuard.test.ts +77 -2
- package/src/tests/manualStart.test.tsx +1 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
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 { Ti18n } from "@prosopo/locale";
|
|
16
|
+
import {
|
|
17
|
+
type Account,
|
|
18
|
+
type BotDetectionFunction,
|
|
19
|
+
type BotDetectionFunctionResult,
|
|
20
|
+
CaptchaType,
|
|
21
|
+
ModeEnum,
|
|
22
|
+
type ProcaptchaClientConfigInput,
|
|
23
|
+
type ProcaptchaProps,
|
|
24
|
+
type RandomProvider,
|
|
25
|
+
StartModeEnum,
|
|
26
|
+
} from "@prosopo/types";
|
|
27
|
+
import { type ReactElement, act, createElement, useEffect } from "react";
|
|
28
|
+
import { type Root, createRoot } from "react-dom/client";
|
|
29
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
30
|
+
|
|
31
|
+
declare global {
|
|
32
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
36
|
+
|
|
37
|
+
const EXECUTE_EVENT = "procaptcha:execute";
|
|
38
|
+
|
|
39
|
+
const mocks = vi.hoisted(() => ({
|
|
40
|
+
executes: [] as ("container" | "document")[],
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Listens the way the real inner widgets do: on the container in either
|
|
44
|
+
// mode, and on document only when invisible.
|
|
45
|
+
const ListeningWidget = (props: ProcaptchaProps) => {
|
|
46
|
+
const { container } = props;
|
|
47
|
+
const invisible = props.config.mode === "invisible";
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const onContainer = () => mocks.executes.push("container");
|
|
50
|
+
const onDocument = () => mocks.executes.push("document");
|
|
51
|
+
container?.addEventListener(EXECUTE_EVENT, onContainer);
|
|
52
|
+
if (invisible) document.addEventListener(EXECUTE_EVENT, onDocument);
|
|
53
|
+
return () => {
|
|
54
|
+
container?.removeEventListener(EXECUTE_EVENT, onContainer);
|
|
55
|
+
if (invisible) document.removeEventListener(EXECUTE_EVENT, onDocument);
|
|
56
|
+
};
|
|
57
|
+
}, [container, invisible]);
|
|
58
|
+
return createElement("div", { "data-widget": "image" });
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
vi.mock("@prosopo/procaptcha-react", () => ({ Procaptcha: ListeningWidget }));
|
|
62
|
+
vi.mock("@prosopo/procaptcha-pow", () => ({ ProcaptchaPow: ListeningWidget }));
|
|
63
|
+
vi.mock("@prosopo/procaptcha-puzzle", () => ({
|
|
64
|
+
ProcaptchaPuzzle: ListeningWidget,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
|
|
68
|
+
const actual =
|
|
69
|
+
await importOriginal<typeof import("@prosopo/procaptcha-common")>();
|
|
70
|
+
return { ...actual, isSecureBrowserContext: () => true };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const { ProcaptchaFrictionless } = await import("../ProcaptchaFrictionless.js");
|
|
74
|
+
|
|
75
|
+
const config = (
|
|
76
|
+
overrides: Partial<ProcaptchaClientConfigInput> = {},
|
|
77
|
+
): ProcaptchaClientConfigInput => ({
|
|
78
|
+
account: { address: "5siteKey" },
|
|
79
|
+
userAccountAddress: "",
|
|
80
|
+
web2: true,
|
|
81
|
+
mode: ModeEnum.invisible,
|
|
82
|
+
startMode: StartModeEnum.auto,
|
|
83
|
+
...overrides,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const i18nStub = {
|
|
87
|
+
isInitialized: true,
|
|
88
|
+
language: "en",
|
|
89
|
+
t: (key: string) => key,
|
|
90
|
+
changeLanguage: vi.fn(),
|
|
91
|
+
} as unknown as Ti18n;
|
|
92
|
+
|
|
93
|
+
const detectionResult = (): BotDetectionFunctionResult => ({
|
|
94
|
+
status: "ok",
|
|
95
|
+
captchaType: CaptchaType.image,
|
|
96
|
+
sessionId: "provider-session",
|
|
97
|
+
provider: { provider: { url: "https://provider.test" } } as RandomProvider,
|
|
98
|
+
userAccount: { account: { address: "5FakeUserAccountAddress" } } as Account,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
interface HeldDetection {
|
|
102
|
+
detectBot: ReturnType<typeof vi.fn<BotDetectionFunction>>;
|
|
103
|
+
finish: () => Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const heldDetection = (): HeldDetection => {
|
|
107
|
+
let resolve: ((result: BotDetectionFunctionResult) => void) | undefined;
|
|
108
|
+
const detectBot = vi.fn<BotDetectionFunction>().mockImplementation(
|
|
109
|
+
() =>
|
|
110
|
+
new Promise<BotDetectionFunctionResult>((r) => {
|
|
111
|
+
resolve = r;
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
const finish = async () => {
|
|
115
|
+
await act(async () => {
|
|
116
|
+
resolve?.(detectionResult());
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
return { detectBot, finish };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
let host: HTMLDivElement;
|
|
123
|
+
let container: HTMLDivElement;
|
|
124
|
+
let root: Root;
|
|
125
|
+
|
|
126
|
+
const mountWrapper = async (
|
|
127
|
+
detectBot: BotDetectionFunction,
|
|
128
|
+
overrides: Partial<ProcaptchaClientConfigInput> = {},
|
|
129
|
+
): Promise<void> => {
|
|
130
|
+
await act(async () => {
|
|
131
|
+
root.render(
|
|
132
|
+
createElement(ProcaptchaFrictionless, {
|
|
133
|
+
config: config(overrides),
|
|
134
|
+
callbacks: {},
|
|
135
|
+
restart: vi.fn(),
|
|
136
|
+
i18n: i18nStub,
|
|
137
|
+
detectBot,
|
|
138
|
+
container,
|
|
139
|
+
}) as ReactElement,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const dispatch = async (target: EventTarget): Promise<void> => {
|
|
145
|
+
await act(async () => {
|
|
146
|
+
target.dispatchEvent(new CustomEvent(EXECUTE_EVENT));
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
mocks.executes.length = 0;
|
|
152
|
+
host = document.createElement("div");
|
|
153
|
+
container = document.createElement("div");
|
|
154
|
+
document.body.append(host, container);
|
|
155
|
+
act(() => {
|
|
156
|
+
root = createRoot(host);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
act(() => {
|
|
162
|
+
root.unmount();
|
|
163
|
+
});
|
|
164
|
+
host.remove();
|
|
165
|
+
container.remove();
|
|
166
|
+
vi.clearAllMocks();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("execute() before the inner widget is listening", () => {
|
|
170
|
+
it("replays a bare execute() once the invisible widget mounts", async () => {
|
|
171
|
+
const { detectBot, finish } = heldDetection();
|
|
172
|
+
await mountWrapper(detectBot);
|
|
173
|
+
|
|
174
|
+
await dispatch(document);
|
|
175
|
+
expect(mocks.executes).toEqual([]);
|
|
176
|
+
|
|
177
|
+
await finish();
|
|
178
|
+
|
|
179
|
+
expect(mocks.executes).toEqual(["container"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("replays a targeted execute() in visible mode", async () => {
|
|
183
|
+
const { detectBot, finish } = heldDetection();
|
|
184
|
+
await mountWrapper(detectBot, { mode: ModeEnum.visible });
|
|
185
|
+
|
|
186
|
+
await dispatch(container);
|
|
187
|
+
await finish();
|
|
188
|
+
|
|
189
|
+
expect(mocks.executes).toEqual(["container"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("replays repeated early calls only once", async () => {
|
|
193
|
+
const { detectBot, finish } = heldDetection();
|
|
194
|
+
await mountWrapper(detectBot);
|
|
195
|
+
|
|
196
|
+
await dispatch(document);
|
|
197
|
+
await dispatch(document);
|
|
198
|
+
await finish();
|
|
199
|
+
|
|
200
|
+
expect(mocks.executes).toEqual(["container"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("does not start the widget when execute() was never called", async () => {
|
|
204
|
+
const { detectBot, finish } = heldDetection();
|
|
205
|
+
await mountWrapper(detectBot);
|
|
206
|
+
|
|
207
|
+
await finish();
|
|
208
|
+
|
|
209
|
+
expect(mocks.executes).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("leaves an execute() after mount to the widget alone", async () => {
|
|
213
|
+
const { detectBot, finish } = heldDetection();
|
|
214
|
+
await mountWrapper(detectBot);
|
|
215
|
+
await finish();
|
|
216
|
+
|
|
217
|
+
await dispatch(document);
|
|
218
|
+
|
|
219
|
+
expect(mocks.executes).toEqual(["document"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("ignores a bare execute() in visible mode, as the widgets do", async () => {
|
|
223
|
+
const { detectBot, finish } = heldDetection();
|
|
224
|
+
await mountWrapper(detectBot, { mode: ModeEnum.visible });
|
|
225
|
+
|
|
226
|
+
await dispatch(document);
|
|
227
|
+
await finish();
|
|
228
|
+
|
|
229
|
+
expect(mocks.executes).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -51,7 +51,12 @@ describe("evaluateFrictionlessResult", () => {
|
|
|
51
51
|
captchaType: "pow",
|
|
52
52
|
error: { message: "Boom", key: "API.SOMETHING" },
|
|
53
53
|
}),
|
|
54
|
-
).toEqual({
|
|
54
|
+
).toEqual({
|
|
55
|
+
kind: "error",
|
|
56
|
+
message: "Boom",
|
|
57
|
+
key: "API.SOMETHING",
|
|
58
|
+
retryable: true,
|
|
59
|
+
});
|
|
55
60
|
});
|
|
56
61
|
|
|
57
62
|
it("omits key from the outcome when the server error carries no key", () => {
|
|
@@ -78,13 +83,18 @@ describe("evaluateFrictionlessResult", () => {
|
|
|
78
83
|
// the wire but is not the object shape the guard reads.
|
|
79
84
|
error: undefined,
|
|
80
85
|
}),
|
|
81
|
-
).toEqual({
|
|
86
|
+
).toEqual({
|
|
87
|
+
kind: "error",
|
|
88
|
+
message: MISSING_CAPTCHA_TYPE_MESSAGE,
|
|
89
|
+
retryable: false,
|
|
90
|
+
});
|
|
82
91
|
});
|
|
83
92
|
|
|
84
93
|
it("halts when both captchaType and error are absent (opaque parse failure)", () => {
|
|
85
94
|
expect(evaluateFrictionlessResult({})).toEqual({
|
|
86
95
|
kind: "error",
|
|
87
96
|
message: MISSING_CAPTCHA_TYPE_MESSAGE,
|
|
97
|
+
retryable: false,
|
|
88
98
|
});
|
|
89
99
|
});
|
|
90
100
|
|
|
@@ -99,6 +109,71 @@ describe("evaluateFrictionlessResult", () => {
|
|
|
99
109
|
kind: "error",
|
|
100
110
|
message: "Server error",
|
|
101
111
|
key: "API.SERVER_ERROR",
|
|
112
|
+
retryable: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("retryable classification", () => {
|
|
117
|
+
const retryableOf = (key?: string): boolean => {
|
|
118
|
+
const outcome = evaluateFrictionlessResult({
|
|
119
|
+
error: { message: "msg", ...(key !== undefined && { key }) },
|
|
120
|
+
});
|
|
121
|
+
if (outcome.kind !== "error") throw new Error("expected error outcome");
|
|
122
|
+
return outcome.retryable;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
it.each([
|
|
126
|
+
"API.SITE_KEY_NOT_REGISTERED",
|
|
127
|
+
"API.INVALID_SITE_KEY",
|
|
128
|
+
"API.UNAUTHORIZED_ORIGIN_URL",
|
|
129
|
+
"API.INCORRECT_CAPTCHA_TYPE",
|
|
130
|
+
])("shows integration fault %s rather than re-rolling", (key: string) => {
|
|
131
|
+
// Another provider returns the same answer, and the text is what
|
|
132
|
+
// tells the site owner what to change.
|
|
133
|
+
expect(retryableOf(key)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each([
|
|
137
|
+
"API.ACCESS_POLICY_BLOCK",
|
|
138
|
+
"API.ABUSER_BLOCKED",
|
|
139
|
+
"API.CRAWLER_BLOCKED",
|
|
140
|
+
"API.DATACENTER_BLOCKED",
|
|
141
|
+
"API.DISALLOWED_WEBVIEW",
|
|
142
|
+
"API.MOBILE_BLOCKED",
|
|
143
|
+
"API.PROXY_BLOCKED",
|
|
144
|
+
"API.SATELLITE_BLOCKED",
|
|
145
|
+
"API.TOR_BLOCKED",
|
|
146
|
+
"API.VPN_BLOCKED",
|
|
147
|
+
"API.FORBIDDEN",
|
|
148
|
+
"API.UNAUTHORIZED",
|
|
149
|
+
])("does not re-roll policy denial %s", (key: string) => {
|
|
150
|
+
// Every provider shares the decision; retrying would hammer the
|
|
151
|
+
// fleet on behalf of traffic already refused.
|
|
152
|
+
expect(retryableOf(key)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("leaves CAPTCHA.NO_SESSION_FOUND to its own restart timer", () => {
|
|
156
|
+
expect(retryableOf("CAPTCHA.NO_SESSION_FOUND")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("re-rolls API.BAD_REQUEST, which is what an unhandled provider throw becomes", () => {
|
|
160
|
+
// An unhandled throw in a handler serialises to this with a 400, and
|
|
161
|
+
// the client does not throw on a 400 with a JSON body — so without
|
|
162
|
+
// re-rolling, one unhealthy provider stranded the user on the first
|
|
163
|
+
// response while the rest of the fleet was up.
|
|
164
|
+
expect(retryableOf("API.BAD_REQUEST")).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it.each(["API.UNKNOWN", "API.INTERNAL_SERVER_ERROR", "DATABASE.UNKNOWN"])(
|
|
168
|
+
"re-rolls unrecognised provider fault %s",
|
|
169
|
+
(key: string) => {
|
|
170
|
+
expect(retryableOf(key)).toBe(true);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
it("does not re-roll an error with no key at all", () => {
|
|
175
|
+
// Hard blocks arrive as a bare `{ error: "..." }` string with no key.
|
|
176
|
+
expect(retryableOf(undefined)).toBe(false);
|
|
102
177
|
});
|
|
103
178
|
});
|
|
104
179
|
});
|
|
@@ -132,8 +132,7 @@ const checkbox = (): HTMLInputElement => {
|
|
|
132
132
|
return element;
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
-
const spinner = (): Element | null =>
|
|
136
|
-
host.querySelector('[aria-label="Loading spinner"]');
|
|
135
|
+
const spinner = (): Element | null => host.querySelector('[role="status"]');
|
|
137
136
|
|
|
138
137
|
const lastMountOf = (widget: InnerWidget) => {
|
|
139
138
|
const mount = mocks.mounts.filter((m) => m.widget === widget).at(-1);
|