@prosopo/load-balancer 2.11.3 → 2.11.5

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.
@@ -1,267 +0,0 @@
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
- // End-to-end coverage that drives the *real* balancer parsing against the shape
16
- // served by https://provider-list.prosopo.io/ — dual-stack pronode entries at
17
- // the top level alongside `ipv4` / `ipv6` single-stack sub-lists. Deliberately
18
- // does NOT mock `../balancer.js` (unlike providers.unit.test.ts) so the raw JSON
19
- // flows through convertHostedProvider → loadBalancer → getProviders →
20
- // getRandomProviderFromList exactly as it does in production, with only
21
- // `fetch` stubbed.
22
-
23
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
24
- import {
25
- PROVIDER_LIST_FETCH_TIMEOUT_MS,
26
- convertHostedProvider,
27
- getProviderHostname,
28
- loadBalancer,
29
- } from "../balancer.js";
30
- import {
31
- _resetPinCache,
32
- _resetProviderListCache,
33
- getRandomProviderFromList,
34
- } from "../providers.js";
35
-
36
- const DATASET_ID =
37
- "0x2e52a1e70f0e0c808972bc04e6fbc8f264b1fbb29c94041fdf01a839b000a40e";
38
- const ADDRESS = "5FnBurrfqWgSLJFMsojjEP74mLX1vZZ9ASyNXKfA5YXu8FR2";
39
-
40
- type ProviderEntry = { url: string; datasetId: string; address: string };
41
-
42
- const entry = (host: string): ProviderEntry => ({
43
- url: `https://${host}.prosopo.io`,
44
- datasetId: DATASET_ID,
45
- address: ADDRESS,
46
- });
47
-
48
- // A trimmed but faithful copy of the production provider-list JSON: three
49
- // pronodes, each present in the ipv4 sub-list, the ipv6 sub-list, and the
50
- // top-level dual-stack list. Note pronode16 sorts before pronode6 and pronode7
51
- // under a plain string compare ('1' < '6' < '7').
52
- const productionJson: Record<string, unknown> = {
53
- ipv4: {
54
- pronode6: entry("ipv4.pronode6"),
55
- pronode7: entry("ipv4.pronode7"),
56
- pronode16: entry("ipv4.pronode16"),
57
- },
58
- ipv6: {
59
- pronode6: entry("ipv6.pronode6"),
60
- pronode7: entry("ipv6.pronode7"),
61
- pronode16: entry("ipv6.pronode16"),
62
- },
63
- pronode6: entry("pronode6"),
64
- pronode7: entry("pronode7"),
65
- pronode16: entry("pronode16"),
66
- };
67
-
68
- const originalFetch = globalThis.fetch;
69
-
70
- const mockFetchReturning = (payload: unknown) => {
71
- const mocked = vi.fn(
72
- async () => new Response(JSON.stringify(payload), { status: 200 }),
73
- );
74
- globalThis.fetch = mocked as typeof fetch;
75
- return mocked;
76
- };
77
-
78
- beforeEach(() => {
79
- _resetPinCache();
80
- _resetProviderListCache();
81
- });
82
-
83
- afterEach(() => {
84
- globalThis.fetch = originalFetch;
85
- vi.restoreAllMocks();
86
- });
87
-
88
- describe("convertHostedProvider (production.json shape)", () => {
89
- it("keeps only the dual-stack pronodes and drops the ipv4/ipv6 sub-lists", () => {
90
- const providers = convertHostedProvider(productionJson);
91
- expect(providers.map((p) => p.url)).toEqual([
92
- // sorted by url.localeCompare — pronode16 first ('1' < '6')
93
- "https://pronode16.prosopo.io",
94
- "https://pronode6.prosopo.io",
95
- "https://pronode7.prosopo.io",
96
- ]);
97
- // Every entry defaults to weight 1 and carries the shared dataset/address.
98
- for (const provider of providers) {
99
- expect(provider.weight).toBe(1);
100
- expect(provider.datasetId).toBe(DATASET_ID);
101
- expect(provider.address).toBe(ADDRESS);
102
- expect(provider.url).not.toMatch(/ipv[46]\./);
103
- }
104
- });
105
-
106
- it("returns the ipv4 single-stack sub-list when ipMode=ipv4", () => {
107
- const providers = convertHostedProvider(productionJson, "ipv4");
108
- expect(providers.map((p) => p.url)).toEqual([
109
- "https://ipv4.pronode16.prosopo.io",
110
- "https://ipv4.pronode6.prosopo.io",
111
- "https://ipv4.pronode7.prosopo.io",
112
- ]);
113
- // The label strips back to the bare pronode hostname for identity.
114
- expect(providers.map((p) => getProviderHostname(p))).toEqual([
115
- "pronode16.prosopo.io",
116
- "pronode6.prosopo.io",
117
- "pronode7.prosopo.io",
118
- ]);
119
- });
120
-
121
- it("returns the ipv6 single-stack sub-list when ipMode=ipv6", () => {
122
- const providers = convertHostedProvider(productionJson, "ipv6");
123
- expect(providers.map((p) => p.url)).toEqual([
124
- "https://ipv6.pronode16.prosopo.io",
125
- "https://ipv6.pronode6.prosopo.io",
126
- "https://ipv6.pronode7.prosopo.io",
127
- ]);
128
- });
129
- });
130
-
131
- describe("loadBalancer (production.json shape)", () => {
132
- it("fetches the production list and returns the dual-stack pronodes", async () => {
133
- const mocked = mockFetchReturning(productionJson);
134
- const providers = await loadBalancer("production");
135
- expect(mocked).toHaveBeenCalledWith(
136
- "https://provider-list.prosopo.io/",
137
- expect.objectContaining({ method: "GET" }),
138
- );
139
- expect(providers.map((p) => p.url)).toEqual([
140
- "https://pronode16.prosopo.io",
141
- "https://pronode6.prosopo.io",
142
- "https://pronode7.prosopo.io",
143
- ]);
144
- });
145
- });
146
-
147
- describe("getRandomProviderFromList (production.json end-to-end)", () => {
148
- const dualStackUrls = [
149
- "https://pronode16.prosopo.io",
150
- "https://pronode6.prosopo.io",
151
- "https://pronode7.prosopo.io",
152
- ];
153
-
154
- it("picks a dual-stack pronode from the fetched production list", async () => {
155
- mockFetchReturning(productionJson);
156
- const result = await getRandomProviderFromList(
157
- "production",
158
- undefined,
159
- undefined,
160
- () => 0,
161
- );
162
- // random=0 selects the first entry after the url sort.
163
- expect(result.provider.url).toBe("https://pronode16.prosopo.io");
164
- expect(result.providerAccount).toBe(ADDRESS);
165
- });
166
-
167
- it("labels the chosen pronode with ipv4 when ipMode=ipv4", async () => {
168
- mockFetchReturning(productionJson);
169
- const result = await getRandomProviderFromList(
170
- "production",
171
- "ipv4",
172
- undefined,
173
- () => 0,
174
- );
175
- // Matches the url found under the production `ipv4` sub-list.
176
- expect(result.provider.url).toBe("https://ipv4.pronode16.prosopo.io");
177
- });
178
-
179
- it("labels the chosen pronode with ipv6 when ipMode=ipv6", async () => {
180
- mockFetchReturning(productionJson);
181
- const result = await getRandomProviderFromList(
182
- "production",
183
- "ipv6",
184
- undefined,
185
- () => 0.99,
186
- );
187
- // random≈1 selects the last entry (pronode7) after the sort.
188
- expect(result.provider.url).toBe("https://ipv6.pronode7.prosopo.io");
189
- });
190
-
191
- it("excludes the pronode that just failed and picks a different one", async () => {
192
- mockFetchReturning(productionJson);
193
- const result = await getRandomProviderFromList(
194
- "production",
195
- undefined,
196
- "https://pronode16.prosopo.io",
197
- () => 0,
198
- );
199
- expect(result.provider.url).not.toBe("https://pronode16.prosopo.io");
200
- expect(dualStackUrls).toContain(result.provider.url);
201
- // With pronode16 removed the sorted pool starts at pronode6.
202
- expect(result.provider.url).toBe("https://pronode6.prosopo.io");
203
- });
204
-
205
- it("excludes by ipv4-labelled url, matching what the widget actually used", async () => {
206
- mockFetchReturning(productionJson);
207
- // The widget's previous attempt used the ipv4-labelled url; exclusion must
208
- // still match the underlying pronode so it is dropped from the pool.
209
- const result = await getRandomProviderFromList(
210
- "production",
211
- "ipv4",
212
- "https://ipv4.pronode16.prosopo.io",
213
- () => 0,
214
- );
215
- expect(result.provider.url).not.toBe("https://ipv4.pronode16.prosopo.io");
216
- expect(result.provider.url).toBe("https://ipv4.pronode6.prosopo.io");
217
- });
218
-
219
- it("serves the cached list across repeat selections (one fetch)", async () => {
220
- const mocked = mockFetchReturning(productionJson);
221
- await getRandomProviderFromList(
222
- "production",
223
- undefined,
224
- undefined,
225
- () => 0,
226
- );
227
- await getRandomProviderFromList("production", "ipv4", undefined, () => 0);
228
- await getRandomProviderFromList("production", "ipv6", undefined, () => 0);
229
- expect(mocked).toHaveBeenCalledTimes(1);
230
- });
231
- });
232
-
233
- describe("loadBalancer provider-list fetch timeout", () => {
234
- // A list endpoint that accepts the request and never answers: the fetch
235
- // only settles if the caller supplied an abort signal.
236
- const stallingFetch = () =>
237
- vi.fn(
238
- (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
239
- new Promise<Response>((_resolve, reject) => {
240
- init?.signal?.addEventListener("abort", () =>
241
- reject(init.signal?.reason),
242
- );
243
- }),
244
- );
245
-
246
- it("aborts a stalled provider-list fetch after the timeout", async () => {
247
- const mocked = stallingFetch();
248
- globalThis.fetch = mocked as typeof fetch;
249
- const started = Date.now();
250
- await expect(
251
- loadBalancer("production", undefined, 50),
252
- ).rejects.toMatchObject({ name: "TimeoutError" });
253
- expect(Date.now() - started).toBeLessThan(2_000);
254
- });
255
-
256
- it("bounds the fetch by default", async () => {
257
- const mocked = vi.fn(
258
- async (_input: RequestInfo | URL, _init?: RequestInit) =>
259
- new Response(JSON.stringify(productionJson), { status: 200 }),
260
- );
261
- globalThis.fetch = mocked as typeof fetch;
262
- await loadBalancer("production");
263
- const init = mocked.mock.calls[0]?.[1];
264
- expect(init?.signal).toBeInstanceOf(AbortSignal);
265
- expect(PROVIDER_LIST_FETCH_TIMEOUT_MS).toBe(10_000);
266
- });
267
- });
@@ -1,182 +0,0 @@
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 { afterEach, describe, expect, it, vi } from "vitest";
16
- import { getProviderListOverride, loadBalancer } from "../balancer.js";
17
- import { readProviderListOverride } from "../providerUrlOverride.js";
18
-
19
- const ENV_KEY = "PROSOPO_PROVIDER_LIST";
20
- const original: string | undefined = process.env[ENV_KEY];
21
- const originalFetch = globalThis.fetch;
22
-
23
- afterEach(() => {
24
- if (original === undefined) delete process.env[ENV_KEY];
25
- else process.env[ENV_KEY] = original;
26
- globalThis.fetch = originalFetch;
27
- });
28
-
29
- const PROVIDER_LIST_JSON = JSON.stringify({
30
- one: {
31
- address: "5Provider1",
32
- url: "https://one.example.com/",
33
- datasetId: "0xabc",
34
- weight: 3,
35
- },
36
- two: {
37
- address: "5Provider2",
38
- url: "https://two.example.com",
39
- datasetId: "0xdef",
40
- },
41
- });
42
-
43
- describe("reading the override", () => {
44
- it("returns undefined when unset", () => {
45
- delete process.env[ENV_KEY];
46
- expect(readProviderListOverride()).toBeUndefined();
47
- });
48
-
49
- it("returns the raw value when set", () => {
50
- process.env[ENV_KEY] = "https://captcha.example.com";
51
- expect(readProviderListOverride()).toBe("https://captcha.example.com");
52
- });
53
- });
54
-
55
- describe("parsing the override", () => {
56
- it("is empty when unset or blank, so normal discovery is untouched", () => {
57
- expect(getProviderListOverride(undefined)).toEqual([]);
58
- expect(getProviderListOverride("")).toEqual([]);
59
- expect(getProviderListOverride(" ")).toEqual([]);
60
- });
61
-
62
- it("accepts a single bare url", () => {
63
- expect(getProviderListOverride("https://captcha.example.com")).toEqual([
64
- {
65
- address: "self-hosted",
66
- url: "https://captcha.example.com",
67
- datasetId: "",
68
- weight: 1,
69
- },
70
- ]);
71
- });
72
-
73
- it("accepts several comma-separated urls", () => {
74
- const providers = getProviderListOverride(
75
- "https://one.example.com, https://two.example.com",
76
- );
77
- expect(providers.map((p) => p.url)).toEqual([
78
- "https://one.example.com",
79
- "https://two.example.com",
80
- ]);
81
- });
82
-
83
- it("strips trailing slashes from both forms", () => {
84
- // A token embeds the provider url it was minted against and the verifier
85
- // matches it by exact string, so the two spellings must normalise.
86
- expect(
87
- getProviderListOverride("https://captcha.example.com/")[0]?.url,
88
- ).toBe("https://captcha.example.com");
89
- const fromJson = getProviderListOverride(PROVIDER_LIST_JSON);
90
- expect(fromJson.map((p) => p.url)).toEqual([
91
- "https://one.example.com",
92
- "https://two.example.com",
93
- ]);
94
- });
95
-
96
- it("accepts the hosted provider-list JSON, preserving address and weight", () => {
97
- const providers = getProviderListOverride(PROVIDER_LIST_JSON);
98
- expect(providers).toEqual([
99
- {
100
- address: "5Provider1",
101
- url: "https://one.example.com",
102
- datasetId: "0xabc",
103
- weight: 3,
104
- },
105
- {
106
- address: "5Provider2",
107
- url: "https://two.example.com",
108
- datasetId: "0xdef",
109
- weight: 1,
110
- },
111
- ]);
112
- });
113
-
114
- it("reads the ipv4 / ipv6 sub-lists when the JSON carries them", () => {
115
- const raw = JSON.stringify({
116
- dual: {
117
- address: "5Dual",
118
- url: "https://dual.example.com",
119
- datasetId: "0x1",
120
- },
121
- ipv4: {
122
- four: {
123
- address: "5Four",
124
- url: "https://v4.example.com",
125
- datasetId: "0x2",
126
- },
127
- },
128
- });
129
- expect(getProviderListOverride(raw, "ipv4").map((p) => p.url)).toEqual([
130
- "https://v4.example.com",
131
- ]);
132
- expect(getProviderListOverride(raw).map((p) => p.url)).toEqual([
133
- "https://dual.example.com",
134
- ]);
135
- });
136
-
137
- it("falls back to normal discovery rather than throwing on malformed JSON", () => {
138
- // A deployment-time typo must not take down every captcha on the page.
139
- expect(getProviderListOverride("{not json")).toEqual([]);
140
- expect(
141
- getProviderListOverride('{"one":{"url":"missing-other-fields"}}'),
142
- ).toEqual([]);
143
- });
144
- });
145
-
146
- describe("loadBalancer with an override", () => {
147
- it("returns the override without fetching the hosted list", async () => {
148
- process.env[ENV_KEY] = "https://captcha.example.com/";
149
- const fetchSpy = vi.fn();
150
- // biome-ignore lint/suspicious/noExplicitAny: asserting the fetch never happens
151
- globalThis.fetch = fetchSpy as any;
152
-
153
- await expect(loadBalancer("production")).resolves.toEqual([
154
- {
155
- address: "self-hosted",
156
- url: "https://captcha.example.com",
157
- datasetId: "",
158
- weight: 1,
159
- },
160
- ]);
161
- expect(fetchSpy).not.toHaveBeenCalled();
162
- });
163
-
164
- it("applies in every environment, not just development", async () => {
165
- process.env[ENV_KEY] = "https://captcha.example.com";
166
- for (const env of ["development", "staging", "production"] as const) {
167
- const providers = await loadBalancer(env);
168
- expect(providers.map((p) => p.url)).toEqual([
169
- "https://captcha.example.com",
170
- ]);
171
- }
172
- });
173
-
174
- it("does not prefix ipv4/ipv6 labels onto a bare-url override", async () => {
175
- // Those labels only resolve inside our own fleet's DNS.
176
- process.env[ENV_KEY] = "https://captcha.example.com";
177
- const providers = await loadBalancer("production", "ipv4");
178
- expect(providers.map((p) => p.url)).toEqual([
179
- "https://captcha.example.com",
180
- ]);
181
- });
182
- });