@prosopo/procaptcha-frictionless 2.13.0 → 2.13.2

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.
@@ -0,0 +1,180 @@
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
+ * A slow detector assignment must still be used. Under the old 2000 ms cap on
17
+ * both the assign POST and the blob import, a cold-connection assign (measured
18
+ * at 6.7s against staging: fresh DNS + TLS + CORS preflight before ~215 KB of
19
+ * bundle) blew the deadline, the surrounding catch swallowed it, and the
20
+ * frictionless POST went out with an empty token and no detectorSessionId.
21
+ */
22
+
23
+ import type { AssignDetectorBundleResponse } from "@prosopo/types";
24
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
25
+
26
+ // `vi.mock` is hoisted; share state via `vi.hoisted` so the factories can
27
+ // reference the same spies the tests assert against.
28
+ const mocks = vi.hoisted(() => ({
29
+ getFrictionlessCaptcha: vi.fn(),
30
+ getProcaptchaRandomActiveProvider: vi.fn(),
31
+ assignDetectorBundle: vi.fn(),
32
+ detectorLoaderFromScript: vi.fn(),
33
+ detect: vi.fn(),
34
+ }));
35
+
36
+ vi.mock("@prosopo/api", () => ({
37
+ // customDetectBot does `new ProviderApi(...)`, so the implementation has to
38
+ // be constructible — an arrow function has no [[Construct]] slot.
39
+ ProviderApi: vi.fn(function () {
40
+ return {
41
+ getFrictionlessCaptcha: mocks.getFrictionlessCaptcha,
42
+ assignDetectorBundle: mocks.assignDetectorBundle,
43
+ };
44
+ }),
45
+ }));
46
+
47
+ vi.mock("@prosopo/procaptcha-common", () => ({
48
+ ExtensionLoader: vi.fn(async () => {
49
+ return class FakeExtension {
50
+ getAccount() {
51
+ return Promise.resolve({
52
+ account: { address: "5FakeUserAccountAddress" },
53
+ });
54
+ }
55
+ };
56
+ }),
57
+ getProcaptchaRandomActiveProvider: mocks.getProcaptchaRandomActiveProvider,
58
+ pickIpMode: vi.fn(() => undefined),
59
+ }));
60
+
61
+ vi.mock("../detectorLoader.js", () => ({
62
+ DetectorLoaderFromScript: mocks.detectorLoaderFromScript,
63
+ }));
64
+
65
+ import customDetectBot from "../customDetectBot.js";
66
+
67
+ const baseConfig = {
68
+ account: { address: "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP" },
69
+ defaultEnvironment: "staging" as const,
70
+ web2: true,
71
+ mode: "visible" as const,
72
+ } as unknown as Parameters<typeof customDetectBot>[0];
73
+
74
+ const assignResponse: AssignDetectorBundleResponse = {
75
+ useProviderBundle: true,
76
+ detectorSessionId: "det-5cad2419-43c0-41b7-a9b8-0117767624bc",
77
+ detectorScript: "SELF_CONTAINED_ESM",
78
+ status: "ok",
79
+ };
80
+
81
+ const detectionResult = {
82
+ token: "ENCRYPTED_TOKEN",
83
+ encryptHeadHash: "ENCRYPTED_HEAD_HASH",
84
+ userAccount: { account: { address: "5FakeUserAccountAddress" } },
85
+ shadowDomCleanup: () => undefined,
86
+ };
87
+
88
+ const captchaResponse = {
89
+ captchaType: "pow",
90
+ sessionId: "SID",
91
+ status: "ok",
92
+ };
93
+
94
+ // The measured cold-connection assign against staging.
95
+ const SLOW_MS = 6700;
96
+
97
+ const resolveAfter = <T>(ms: number, value: T): Promise<T> =>
98
+ new Promise<T>((resolve) => {
99
+ setTimeout(() => resolve(value), ms);
100
+ });
101
+
102
+ // The token the frictionless POST was actually called with (first positional
103
+ // arg of `getFrictionlessCaptcha`).
104
+ const postedToken = (): string | undefined => {
105
+ const call = mocks.getFrictionlessCaptcha.mock.calls[0];
106
+ return call?.[0] as string | undefined;
107
+ };
108
+
109
+ const postedDetectorSessionId = (): string | undefined => {
110
+ const call = mocks.getFrictionlessCaptcha.mock.calls[0];
111
+ return call?.[6] as string | undefined;
112
+ };
113
+
114
+ beforeEach(() => {
115
+ vi.useFakeTimers();
116
+ mocks.getFrictionlessCaptcha.mockReset();
117
+ mocks.getProcaptchaRandomActiveProvider.mockReset();
118
+ mocks.assignDetectorBundle.mockReset();
119
+ mocks.detectorLoaderFromScript.mockReset();
120
+ mocks.detect.mockReset();
121
+
122
+ mocks.getProcaptchaRandomActiveProvider.mockResolvedValue({
123
+ providerAccount: "dns-routed",
124
+ provider: { url: "https://staging-pronode3.prosopo.io" },
125
+ });
126
+ mocks.detect.mockResolvedValue(detectionResult);
127
+ mocks.detectorLoaderFromScript.mockResolvedValue(mocks.detect);
128
+ mocks.assignDetectorBundle.mockResolvedValue(assignResponse);
129
+ mocks.getFrictionlessCaptcha.mockResolvedValue(captchaResponse);
130
+ });
131
+
132
+ afterEach(() => {
133
+ vi.useRealTimers();
134
+ });
135
+
136
+ const runDetection = async (): Promise<void> => {
137
+ const pending = customDetectBot(baseConfig, undefined, () => undefined);
138
+ await vi.advanceTimersByTimeAsync(SLOW_MS + 1000);
139
+ await pending;
140
+ };
141
+
142
+ describe("customDetectBot detector-assignment deadline", () => {
143
+ it("uses a bundle whose assign POST is slow", async () => {
144
+ mocks.assignDetectorBundle.mockImplementation(() =>
145
+ resolveAfter(SLOW_MS, assignResponse),
146
+ );
147
+
148
+ await runDetection();
149
+
150
+ expect(mocks.detectorLoaderFromScript).toHaveBeenCalledWith(
151
+ assignResponse.detectorScript,
152
+ );
153
+ expect(postedToken()).toBe("ENCRYPTED_TOKEN");
154
+ expect(postedDetectorSessionId()).toBe(assignResponse.detectorSessionId);
155
+ });
156
+
157
+ it("uses a bundle whose blob import is slow", async () => {
158
+ mocks.detectorLoaderFromScript.mockImplementation(() =>
159
+ resolveAfter(SLOW_MS, mocks.detect),
160
+ );
161
+
162
+ await runDetection();
163
+
164
+ expect(postedToken()).toBe("ENCRYPTED_TOKEN");
165
+ expect(postedDetectorSessionId()).toBe(assignResponse.detectorSessionId);
166
+ });
167
+
168
+ it("still sends an empty token when the provider has no bundle to assign", async () => {
169
+ mocks.assignDetectorBundle.mockResolvedValue({
170
+ useProviderBundle: false,
171
+ status: "ok",
172
+ } satisfies AssignDetectorBundleResponse);
173
+
174
+ await runDetection();
175
+
176
+ expect(mocks.detectorLoaderFromScript).not.toHaveBeenCalled();
177
+ expect(postedToken()).toBeUndefined();
178
+ expect(postedDetectorSessionId()).toBeUndefined();
179
+ });
180
+ });
@@ -0,0 +1,114 @@
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 { EnvironmentTypes } from "@prosopo/types";
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+
18
+ const assignDetectorBundle = vi.fn();
19
+ const getProcaptchaRandomActiveProvider = vi.fn();
20
+
21
+ vi.mock("@prosopo/api", () => ({
22
+ ProviderApi: class {
23
+ assignDetectorBundle = assignDetectorBundle;
24
+ },
25
+ }));
26
+
27
+ vi.mock("@prosopo/procaptcha-common", () => ({
28
+ getProcaptchaRandomActiveProvider: (
29
+ ...args: [EnvironmentTypes, string | undefined]
30
+ ) => getProcaptchaRandomActiveProvider(...args),
31
+ }));
32
+
33
+ const { prefetchDetector, takePrefetchedDetector, clearPrefetchedDetectors } =
34
+ await import("../detectorPrefetch.js");
35
+
36
+ const ENV = "staging" as EnvironmentTypes;
37
+ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
38
+
39
+ const provider = { provider: { url: "https://pronode.example" } };
40
+
41
+ afterEach(() => {
42
+ clearPrefetchedDetectors();
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ describe("detectorPrefetch", () => {
47
+ it("returns undefined when nothing was prefetched", () => {
48
+ expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
49
+ });
50
+
51
+ it("resolves the provider and assigns a bundle", async () => {
52
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
53
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
54
+
55
+ prefetchDetector(ENV, undefined, SITE_KEY);
56
+ const claimed = takePrefetchedDetector(ENV, undefined, SITE_KEY);
57
+ expect(claimed).toBeDefined();
58
+
59
+ const result = await (claimed as Promise<unknown>);
60
+ expect(result).toStrictEqual({
61
+ provider,
62
+ assigned: { useProviderBundle: true },
63
+ });
64
+ expect(assignDetectorBundle).toHaveBeenCalledWith(SITE_KEY);
65
+ });
66
+
67
+ it("is single-use, so a retry does not reuse a stale provider pin", async () => {
68
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
69
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
70
+
71
+ prefetchDetector(ENV, undefined, SITE_KEY);
72
+ const first = takePrefetchedDetector(ENV, undefined, SITE_KEY);
73
+ await (first as Promise<unknown>);
74
+
75
+ expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
76
+ });
77
+
78
+ it("does not start a second request for the same key", () => {
79
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
80
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
81
+
82
+ prefetchDetector(ENV, undefined, SITE_KEY);
83
+ prefetchDetector(ENV, undefined, SITE_KEY);
84
+
85
+ expect(getProcaptchaRandomActiveProvider).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it("keys on site key and ip mode, so a different widget does not claim it", () => {
89
+ getProcaptchaRandomActiveProvider.mockResolvedValue(provider);
90
+ assignDetectorBundle.mockResolvedValue({ useProviderBundle: true });
91
+
92
+ prefetchDetector(ENV, "ipv4", SITE_KEY);
93
+
94
+ expect(takePrefetchedDetector(ENV, "ipv6", SITE_KEY)).toBeUndefined();
95
+ expect(takePrefetchedDetector(ENV, undefined, SITE_KEY)).toBeUndefined();
96
+ expect(takePrefetchedDetector(ENV, "ipv4", "other-key")).toBeUndefined();
97
+ expect(takePrefetchedDetector(ENV, "ipv4", SITE_KEY)).toBeDefined();
98
+ });
99
+
100
+ it("surfaces failure to the claimant without an unhandled rejection", async () => {
101
+ getProcaptchaRandomActiveProvider.mockRejectedValue(
102
+ new Error("no providers"),
103
+ );
104
+
105
+ prefetchDetector(ENV, undefined, SITE_KEY);
106
+ const claimed = takePrefetchedDetector(ENV, undefined, SITE_KEY);
107
+ expect(claimed).toBeDefined();
108
+
109
+ // customDetectBot awaits this inside a try/catch and falls back; the point
110
+ // here is that it rejects rather than hanging, and that the no-op catch
111
+ // attached at prefetch time did not swallow it for the real consumer.
112
+ await expect(claimed as Promise<unknown>).rejects.toThrow("no providers");
113
+ });
114
+ });