payload-mcp-toolkit 0.7.0 → 0.7.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/README.md +29 -8
- package/dist/api-keys.js +57 -21
- package/dist/api-keys.js.map +1 -1
- package/dist/auth-strategy.d.ts +18 -7
- package/dist/auth-strategy.js +54 -12
- package/dist/auth-strategy.js.map +1 -1
- package/dist/tools/_helpers.d.ts +34 -0
- package/dist/tools/_helpers.js +98 -0
- package/dist/tools/_helpers.js.map +1 -1
- package/dist/tools/publish-draft.js +33 -1
- package/dist/tools/publish-draft.js.map +1 -1
- package/dist/tools/publish-global-draft.js +30 -1
- package/dist/tools/publish-global-draft.js.map +1 -1
- package/package.json +29 -15
- package/dist/__tests__/api-keys.test.js +0 -292
- package/dist/__tests__/api-keys.test.js.map +0 -1
- package/dist/__tests__/auth-strategy.test.js +0 -681
- package/dist/__tests__/auth-strategy.test.js.map +0 -1
- package/dist/__tests__/conflict-detection.test.js +0 -69
- package/dist/__tests__/conflict-detection.test.js.map +0 -1
- package/dist/__tests__/delete-document.test.js +0 -70
- package/dist/__tests__/delete-document.test.js.map +0 -1
- package/dist/__tests__/endpoint.test.js +0 -143
- package/dist/__tests__/endpoint.test.js.map +0 -1
- package/dist/__tests__/find-document.test.js +0 -178
- package/dist/__tests__/find-document.test.js.map +0 -1
- package/dist/__tests__/find-global.test.js +0 -173
- package/dist/__tests__/find-global.test.js.map +0 -1
- package/dist/__tests__/global-versions.test.js +0 -183
- package/dist/__tests__/global-versions.test.js.map +0 -1
- package/dist/__tests__/hash.test.js +0 -58
- package/dist/__tests__/hash.test.js.map +0 -1
- package/dist/__tests__/index-integration.test.js +0 -191
- package/dist/__tests__/index-integration.test.js.map +0 -1
- package/dist/__tests__/introspection.test.js +0 -659
- package/dist/__tests__/introspection.test.js.map +0 -1
- package/dist/__tests__/patch-global-layout.test.js +0 -474
- package/dist/__tests__/patch-global-layout.test.js.map +0 -1
- package/dist/__tests__/patch-layout.test.js +0 -171
- package/dist/__tests__/patch-layout.test.js.map +0 -1
- package/dist/__tests__/registry.test.js +0 -795
- package/dist/__tests__/registry.test.js.map +0 -1
- package/dist/__tests__/resources.test.js +0 -139
- package/dist/__tests__/resources.test.js.map +0 -1
- package/dist/__tests__/update-global.test.js +0 -157
- package/dist/__tests__/update-global.test.js.map +0 -1
- package/dist/__tests__/url-validator.test.js +0 -326
- package/dist/__tests__/url-validator.test.js.map +0 -1
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import dns from 'node:dns';
|
|
3
|
-
import { isPrivateIp, validateAndFetchUrl } from '../url-validator';
|
|
4
|
-
// ─── isPrivateIp — pure unit tests ────────────────────────────────
|
|
5
|
-
describe('isPrivateIp', ()=>{
|
|
6
|
-
describe('IPv4 — blocked ranges', ()=>{
|
|
7
|
-
it.each([
|
|
8
|
-
[
|
|
9
|
-
'10.0.0.1',
|
|
10
|
-
'10.0.0.0/8'
|
|
11
|
-
],
|
|
12
|
-
[
|
|
13
|
-
'10.255.255.255',
|
|
14
|
-
'10.0.0.0/8 upper'
|
|
15
|
-
],
|
|
16
|
-
[
|
|
17
|
-
'172.16.0.1',
|
|
18
|
-
'172.16.0.0/12 lower'
|
|
19
|
-
],
|
|
20
|
-
[
|
|
21
|
-
'172.20.5.5',
|
|
22
|
-
'172.16.0.0/12 mid'
|
|
23
|
-
],
|
|
24
|
-
[
|
|
25
|
-
'172.31.255.255',
|
|
26
|
-
'172.16.0.0/12 upper'
|
|
27
|
-
],
|
|
28
|
-
[
|
|
29
|
-
'192.168.1.1',
|
|
30
|
-
'192.168.0.0/16'
|
|
31
|
-
],
|
|
32
|
-
[
|
|
33
|
-
'127.0.0.1',
|
|
34
|
-
'127.0.0.0/8 loopback'
|
|
35
|
-
],
|
|
36
|
-
[
|
|
37
|
-
'127.255.255.255',
|
|
38
|
-
'127.0.0.0/8 upper'
|
|
39
|
-
],
|
|
40
|
-
[
|
|
41
|
-
'169.254.169.254',
|
|
42
|
-
'169.254.0.0/16 — AWS metadata'
|
|
43
|
-
],
|
|
44
|
-
[
|
|
45
|
-
'0.0.0.0',
|
|
46
|
-
'unspecified — 0.0.0.0/8'
|
|
47
|
-
],
|
|
48
|
-
[
|
|
49
|
-
'0.0.0.1',
|
|
50
|
-
'0.0.0.0/8 not just :0'
|
|
51
|
-
],
|
|
52
|
-
[
|
|
53
|
-
'0.255.255.255',
|
|
54
|
-
'0.0.0.0/8 upper'
|
|
55
|
-
],
|
|
56
|
-
[
|
|
57
|
-
'100.64.0.0',
|
|
58
|
-
'100.64.0.0/10 — CGNAT lower'
|
|
59
|
-
],
|
|
60
|
-
[
|
|
61
|
-
'100.100.50.50',
|
|
62
|
-
'100.64.0.0/10 — CGNAT mid'
|
|
63
|
-
],
|
|
64
|
-
[
|
|
65
|
-
'100.127.255.255',
|
|
66
|
-
'100.64.0.0/10 — CGNAT upper'
|
|
67
|
-
]
|
|
68
|
-
])('blocks %s (%s)', (ip)=>{
|
|
69
|
-
expect(isPrivateIp(ip)).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
describe('IPv4 — public (allowed)', ()=>{
|
|
73
|
-
it.each([
|
|
74
|
-
[
|
|
75
|
-
'8.8.8.8'
|
|
76
|
-
],
|
|
77
|
-
[
|
|
78
|
-
'1.1.1.1'
|
|
79
|
-
],
|
|
80
|
-
[
|
|
81
|
-
'172.15.0.1'
|
|
82
|
-
],
|
|
83
|
-
[
|
|
84
|
-
'172.32.0.1'
|
|
85
|
-
],
|
|
86
|
-
[
|
|
87
|
-
'11.0.0.1'
|
|
88
|
-
],
|
|
89
|
-
[
|
|
90
|
-
'192.169.1.1'
|
|
91
|
-
],
|
|
92
|
-
[
|
|
93
|
-
'126.255.255.255'
|
|
94
|
-
],
|
|
95
|
-
[
|
|
96
|
-
'128.0.0.1'
|
|
97
|
-
],
|
|
98
|
-
[
|
|
99
|
-
'100.63.255.255'
|
|
100
|
-
],
|
|
101
|
-
[
|
|
102
|
-
'100.128.0.0'
|
|
103
|
-
],
|
|
104
|
-
[
|
|
105
|
-
'1.0.0.1'
|
|
106
|
-
]
|
|
107
|
-
])('allows %s', (ip)=>{
|
|
108
|
-
expect(isPrivateIp(ip)).toBe(false);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
describe('IPv6 — blocked ranges', ()=>{
|
|
112
|
-
it.each([
|
|
113
|
-
[
|
|
114
|
-
'::1',
|
|
115
|
-
'loopback shorthand'
|
|
116
|
-
],
|
|
117
|
-
[
|
|
118
|
-
'0:0:0:0:0:0:0:1',
|
|
119
|
-
'loopback expanded'
|
|
120
|
-
],
|
|
121
|
-
[
|
|
122
|
-
'0000:0000:0000:0000:0000:0000:0000:0001',
|
|
123
|
-
'loopback fully expanded'
|
|
124
|
-
],
|
|
125
|
-
[
|
|
126
|
-
'fc00::1',
|
|
127
|
-
'unique local fc'
|
|
128
|
-
],
|
|
129
|
-
[
|
|
130
|
-
'fd12:3456:789a::1',
|
|
131
|
-
'unique local fd'
|
|
132
|
-
],
|
|
133
|
-
[
|
|
134
|
-
'fe80::1',
|
|
135
|
-
'link-local'
|
|
136
|
-
],
|
|
137
|
-
[
|
|
138
|
-
'FE80::1',
|
|
139
|
-
'link-local case-insensitive'
|
|
140
|
-
],
|
|
141
|
-
[
|
|
142
|
-
'febf::1',
|
|
143
|
-
'link-local upper boundary fe80::/10'
|
|
144
|
-
],
|
|
145
|
-
[
|
|
146
|
-
'::ffff:127.0.0.1',
|
|
147
|
-
'IPv4-mapped loopback'
|
|
148
|
-
],
|
|
149
|
-
[
|
|
150
|
-
'::ffff:10.0.0.1',
|
|
151
|
-
'IPv4-mapped private 10/8'
|
|
152
|
-
],
|
|
153
|
-
[
|
|
154
|
-
'::ffff:169.254.169.254',
|
|
155
|
-
'IPv4-mapped AWS metadata'
|
|
156
|
-
],
|
|
157
|
-
[
|
|
158
|
-
'::ffff:100.64.0.1',
|
|
159
|
-
'IPv4-mapped CGNAT'
|
|
160
|
-
],
|
|
161
|
-
[
|
|
162
|
-
'::127.0.0.1',
|
|
163
|
-
'IPv4-compat loopback'
|
|
164
|
-
]
|
|
165
|
-
])('blocks %s (%s)', (ip)=>{
|
|
166
|
-
expect(isPrivateIp(ip)).toBe(true);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
describe('IPv6 — public (allowed)', ()=>{
|
|
170
|
-
it.each([
|
|
171
|
-
[
|
|
172
|
-
'2001:4860:4860::8888'
|
|
173
|
-
],
|
|
174
|
-
[
|
|
175
|
-
'2606:4700:4700::1111'
|
|
176
|
-
],
|
|
177
|
-
[
|
|
178
|
-
'ff00::1'
|
|
179
|
-
],
|
|
180
|
-
[
|
|
181
|
-
'::ffff:8.8.8.8'
|
|
182
|
-
],
|
|
183
|
-
[
|
|
184
|
-
'fec0::1'
|
|
185
|
-
]
|
|
186
|
-
])('allows %s', (ip)=>{
|
|
187
|
-
expect(isPrivateIp(ip)).toBe(false);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
// ─── validateAndFetchUrl — integration with mocked DNS + fetch ────
|
|
192
|
-
describe('validateAndFetchUrl', ()=>{
|
|
193
|
-
const originalFetch = globalThis.fetch;
|
|
194
|
-
let dnsLookupSpy;
|
|
195
|
-
beforeEach(()=>{
|
|
196
|
-
dnsLookupSpy = vi.spyOn(dns.promises, 'lookup');
|
|
197
|
-
});
|
|
198
|
-
afterEach(()=>{
|
|
199
|
-
vi.restoreAllMocks();
|
|
200
|
-
globalThis.fetch = originalFetch;
|
|
201
|
-
});
|
|
202
|
-
function mockDns(address, family = 4) {
|
|
203
|
-
dnsLookupSpy.mockResolvedValue({
|
|
204
|
-
address,
|
|
205
|
-
family
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
function mockFetch(impl) {
|
|
209
|
-
globalThis.fetch = vi.fn((url)=>Promise.resolve(impl(String(url))));
|
|
210
|
-
}
|
|
211
|
-
it('rejects non-HTTPS schemes', async ()=>{
|
|
212
|
-
await expect(validateAndFetchUrl('http://example.com/x.png')).rejects.toThrow(/HTTPS/);
|
|
213
|
-
});
|
|
214
|
-
it('rejects when DNS resolves to a private IPv4 (SSRF)', async ()=>{
|
|
215
|
-
mockDns('10.0.0.5');
|
|
216
|
-
await expect(validateAndFetchUrl('https://attacker.example/x.png')).rejects.toThrow(/SSRF blocked/);
|
|
217
|
-
});
|
|
218
|
-
it('rejects when DNS resolves to AWS metadata IP (SSRF)', async ()=>{
|
|
219
|
-
mockDns('169.254.169.254');
|
|
220
|
-
await expect(validateAndFetchUrl('https://metadata.example/x.png')).rejects.toThrow(/SSRF blocked/);
|
|
221
|
-
});
|
|
222
|
-
it('rejects when DNS resolves to ::1 (SSRF)', async ()=>{
|
|
223
|
-
mockDns('::1', 6);
|
|
224
|
-
await expect(validateAndFetchUrl('https://localhost.example/x.png')).rejects.toThrow(/SSRF blocked/);
|
|
225
|
-
});
|
|
226
|
-
it('fetches successfully when DNS resolves to a public IP', async ()=>{
|
|
227
|
-
mockDns('8.8.8.8');
|
|
228
|
-
mockFetch(()=>new Response(new Uint8Array([
|
|
229
|
-
1,
|
|
230
|
-
2,
|
|
231
|
-
3
|
|
232
|
-
]), {
|
|
233
|
-
status: 200,
|
|
234
|
-
headers: {
|
|
235
|
-
'content-type': 'image/png'
|
|
236
|
-
}
|
|
237
|
-
}));
|
|
238
|
-
const result = await validateAndFetchUrl('https://example.com/cat.png');
|
|
239
|
-
expect(result.contentType).toBe('image/png');
|
|
240
|
-
expect(result.filename).toBe('cat.png');
|
|
241
|
-
expect(result.buffer.length).toBe(3);
|
|
242
|
-
});
|
|
243
|
-
it('follows redirects and re-validates the redirect target', async ()=>{
|
|
244
|
-
let call = 0;
|
|
245
|
-
// First lookup: public. Second lookup (after redirect): private → must block.
|
|
246
|
-
dnsLookupSpy.mockImplementation(async ()=>{
|
|
247
|
-
call++;
|
|
248
|
-
return {
|
|
249
|
-
address: call === 1 ? '8.8.8.8' : '127.0.0.1',
|
|
250
|
-
family: 4
|
|
251
|
-
};
|
|
252
|
-
});
|
|
253
|
-
mockFetch((url)=>{
|
|
254
|
-
if (url === 'https://example.com/start') {
|
|
255
|
-
return new Response(null, {
|
|
256
|
-
status: 302,
|
|
257
|
-
headers: {
|
|
258
|
-
location: 'https://internal.example/secret'
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
throw new Error(`should not reach ${url} — redirect target must be blocked`);
|
|
263
|
-
});
|
|
264
|
-
await expect(validateAndFetchUrl('https://example.com/start')).rejects.toThrow(/SSRF blocked/);
|
|
265
|
-
expect(call).toBe(2);
|
|
266
|
-
});
|
|
267
|
-
it('caps redirect chains at maxRedirects', async ()=>{
|
|
268
|
-
mockDns('8.8.8.8');
|
|
269
|
-
mockFetch((url)=>new Response(null, {
|
|
270
|
-
status: 302,
|
|
271
|
-
headers: {
|
|
272
|
-
location: url + '/again'
|
|
273
|
-
}
|
|
274
|
-
}));
|
|
275
|
-
await expect(validateAndFetchUrl('https://example.com/loop', {
|
|
276
|
-
maxRedirects: 2
|
|
277
|
-
})).rejects.toThrow(/Too many redirects/);
|
|
278
|
-
});
|
|
279
|
-
it('errors when a redirect response has no Location header', async ()=>{
|
|
280
|
-
mockDns('8.8.8.8');
|
|
281
|
-
mockFetch(()=>new Response(null, {
|
|
282
|
-
status: 302
|
|
283
|
-
}));
|
|
284
|
-
await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(/Location header/);
|
|
285
|
-
});
|
|
286
|
-
it('surfaces non-OK HTTP responses', async ()=>{
|
|
287
|
-
mockDns('8.8.8.8');
|
|
288
|
-
mockFetch(()=>new Response(null, {
|
|
289
|
-
status: 404,
|
|
290
|
-
statusText: 'Not Found'
|
|
291
|
-
}));
|
|
292
|
-
await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(/404/);
|
|
293
|
-
});
|
|
294
|
-
it('derives a UUID filename when the URL path has no extension', async ()=>{
|
|
295
|
-
mockDns('8.8.8.8');
|
|
296
|
-
mockFetch(()=>new Response(new Uint8Array([
|
|
297
|
-
0
|
|
298
|
-
]), {
|
|
299
|
-
status: 200,
|
|
300
|
-
headers: {
|
|
301
|
-
'content-type': 'image/jpeg'
|
|
302
|
-
}
|
|
303
|
-
}));
|
|
304
|
-
const result = await validateAndFetchUrl('https://example.com/no-extension');
|
|
305
|
-
expect(result.filename).toMatch(/\.jpg$/);
|
|
306
|
-
expect(result.filename).not.toBe('no-extension');
|
|
307
|
-
});
|
|
308
|
-
it('strips path traversal segments from derived filenames', async ()=>{
|
|
309
|
-
mockDns('8.8.8.8');
|
|
310
|
-
mockFetch(()=>new Response(new Uint8Array([
|
|
311
|
-
0
|
|
312
|
-
]), {
|
|
313
|
-
status: 200,
|
|
314
|
-
headers: {
|
|
315
|
-
'content-type': 'image/png'
|
|
316
|
-
}
|
|
317
|
-
}));
|
|
318
|
-
const result = await validateAndFetchUrl('https://example.com/foo/..%2Fevil.png');
|
|
319
|
-
// URL parser decodes %2F into /, so lastSegment ends up "..evil.png" after .. strip
|
|
320
|
-
expect(result.filename).not.toContain('..');
|
|
321
|
-
expect(result.filename).not.toContain('/');
|
|
322
|
-
expect(result.filename).not.toContain('\\');
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
//# sourceMappingURL=url-validator.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/__tests__/url-validator.test.ts"],"sourcesContent":["import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport dns from 'node:dns'\nimport { isPrivateIp, validateAndFetchUrl } from '../url-validator'\n\n// ─── isPrivateIp — pure unit tests ────────────────────────────────\n\ndescribe('isPrivateIp', () => {\n describe('IPv4 — blocked ranges', () => {\n it.each([\n ['10.0.0.1', '10.0.0.0/8'],\n ['10.255.255.255', '10.0.0.0/8 upper'],\n ['172.16.0.1', '172.16.0.0/12 lower'],\n ['172.20.5.5', '172.16.0.0/12 mid'],\n ['172.31.255.255', '172.16.0.0/12 upper'],\n ['192.168.1.1', '192.168.0.0/16'],\n ['127.0.0.1', '127.0.0.0/8 loopback'],\n ['127.255.255.255', '127.0.0.0/8 upper'],\n ['169.254.169.254', '169.254.0.0/16 — AWS metadata'],\n ['0.0.0.0', 'unspecified — 0.0.0.0/8'],\n ['0.0.0.1', '0.0.0.0/8 not just :0'],\n ['0.255.255.255', '0.0.0.0/8 upper'],\n ['100.64.0.0', '100.64.0.0/10 — CGNAT lower'],\n ['100.100.50.50', '100.64.0.0/10 — CGNAT mid'],\n ['100.127.255.255', '100.64.0.0/10 — CGNAT upper'],\n ])('blocks %s (%s)', (ip) => {\n expect(isPrivateIp(ip)).toBe(true)\n })\n })\n\n describe('IPv4 — public (allowed)', () => {\n it.each([\n ['8.8.8.8'],\n ['1.1.1.1'],\n ['172.15.0.1'], // just outside 172.16/12\n ['172.32.0.1'], // just outside 172.16/12\n ['11.0.0.1'],\n ['192.169.1.1'], // not 192.168\n ['126.255.255.255'],\n ['128.0.0.1'],\n ['100.63.255.255'], // just below CGNAT\n ['100.128.0.0'], // just above CGNAT\n ['1.0.0.1'], // outside 0.0.0.0/8\n ])('allows %s', (ip) => {\n expect(isPrivateIp(ip)).toBe(false)\n })\n })\n\n describe('IPv6 — blocked ranges', () => {\n it.each([\n ['::1', 'loopback shorthand'],\n ['0:0:0:0:0:0:0:1', 'loopback expanded'],\n ['0000:0000:0000:0000:0000:0000:0000:0001', 'loopback fully expanded'],\n ['fc00::1', 'unique local fc'],\n ['fd12:3456:789a::1', 'unique local fd'],\n ['fe80::1', 'link-local'],\n ['FE80::1', 'link-local case-insensitive'],\n ['febf::1', 'link-local upper boundary fe80::/10'],\n ['::ffff:127.0.0.1', 'IPv4-mapped loopback'],\n ['::ffff:10.0.0.1', 'IPv4-mapped private 10/8'],\n ['::ffff:169.254.169.254', 'IPv4-mapped AWS metadata'],\n ['::ffff:100.64.0.1', 'IPv4-mapped CGNAT'],\n ['::127.0.0.1', 'IPv4-compat loopback'],\n ])('blocks %s (%s)', (ip) => {\n expect(isPrivateIp(ip)).toBe(true)\n })\n })\n\n describe('IPv6 — public (allowed)', () => {\n it.each([\n ['2001:4860:4860::8888'], // Google DNS\n ['2606:4700:4700::1111'], // Cloudflare DNS\n ['ff00::1'], // multicast — not in our private blocklist\n ['::ffff:8.8.8.8'], // IPv4-mapped public\n ['fec0::1'], // outside fe80::/10 (deprecated site-local, but not our match)\n ])('allows %s', (ip) => {\n expect(isPrivateIp(ip)).toBe(false)\n })\n })\n})\n\n// ─── validateAndFetchUrl — integration with mocked DNS + fetch ────\n\ndescribe('validateAndFetchUrl', () => {\n const originalFetch = globalThis.fetch\n let dnsLookupSpy: ReturnType<typeof vi.spyOn>\n\n beforeEach(() => {\n dnsLookupSpy = vi.spyOn(dns.promises, 'lookup')\n })\n\n afterEach(() => {\n vi.restoreAllMocks()\n globalThis.fetch = originalFetch\n })\n\n function mockDns(address: string, family: 4 | 6 = 4) {\n dnsLookupSpy.mockResolvedValue({ address, family } as any)\n }\n\n function mockFetch(impl: (url: string) => Promise<Response> | Response) {\n globalThis.fetch = vi.fn(((url: any) => Promise.resolve(impl(String(url)))) as any) as any\n }\n\n it('rejects non-HTTPS schemes', async () => {\n await expect(validateAndFetchUrl('http://example.com/x.png')).rejects.toThrow(/HTTPS/)\n })\n\n it('rejects when DNS resolves to a private IPv4 (SSRF)', async () => {\n mockDns('10.0.0.5')\n await expect(validateAndFetchUrl('https://attacker.example/x.png')).rejects.toThrow(\n /SSRF blocked/,\n )\n })\n\n it('rejects when DNS resolves to AWS metadata IP (SSRF)', async () => {\n mockDns('169.254.169.254')\n await expect(validateAndFetchUrl('https://metadata.example/x.png')).rejects.toThrow(\n /SSRF blocked/,\n )\n })\n\n it('rejects when DNS resolves to ::1 (SSRF)', async () => {\n mockDns('::1', 6)\n await expect(validateAndFetchUrl('https://localhost.example/x.png')).rejects.toThrow(\n /SSRF blocked/,\n )\n })\n\n it('fetches successfully when DNS resolves to a public IP', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n () =>\n new Response(new Uint8Array([1, 2, 3]), {\n status: 200,\n headers: { 'content-type': 'image/png' },\n }),\n )\n\n const result = await validateAndFetchUrl('https://example.com/cat.png')\n expect(result.contentType).toBe('image/png')\n expect(result.filename).toBe('cat.png')\n expect(result.buffer.length).toBe(3)\n })\n\n it('follows redirects and re-validates the redirect target', async () => {\n let call = 0\n // First lookup: public. Second lookup (after redirect): private → must block.\n dnsLookupSpy.mockImplementation(async () => {\n call++\n return { address: call === 1 ? '8.8.8.8' : '127.0.0.1', family: 4 } as any\n })\n\n mockFetch((url) => {\n if (url === 'https://example.com/start') {\n return new Response(null, {\n status: 302,\n headers: { location: 'https://internal.example/secret' },\n })\n }\n throw new Error(`should not reach ${url} — redirect target must be blocked`)\n })\n\n await expect(validateAndFetchUrl('https://example.com/start')).rejects.toThrow(\n /SSRF blocked/,\n )\n expect(call).toBe(2)\n })\n\n it('caps redirect chains at maxRedirects', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n (url) =>\n new Response(null, {\n status: 302,\n headers: { location: url + '/again' },\n }),\n )\n\n await expect(\n validateAndFetchUrl('https://example.com/loop', { maxRedirects: 2 }),\n ).rejects.toThrow(/Too many redirects/)\n })\n\n it('errors when a redirect response has no Location header', async () => {\n mockDns('8.8.8.8')\n mockFetch(() => new Response(null, { status: 302 }))\n\n await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(\n /Location header/,\n )\n })\n\n it('surfaces non-OK HTTP responses', async () => {\n mockDns('8.8.8.8')\n mockFetch(() => new Response(null, { status: 404, statusText: 'Not Found' }))\n\n await expect(validateAndFetchUrl('https://example.com/x.png')).rejects.toThrow(/404/)\n })\n\n it('derives a UUID filename when the URL path has no extension', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n () =>\n new Response(new Uint8Array([0]), {\n status: 200,\n headers: { 'content-type': 'image/jpeg' },\n }),\n )\n\n const result = await validateAndFetchUrl('https://example.com/no-extension')\n expect(result.filename).toMatch(/\\.jpg$/)\n expect(result.filename).not.toBe('no-extension')\n })\n\n it('strips path traversal segments from derived filenames', async () => {\n mockDns('8.8.8.8')\n mockFetch(\n () =>\n new Response(new Uint8Array([0]), {\n status: 200,\n headers: { 'content-type': 'image/png' },\n }),\n )\n\n const result = await validateAndFetchUrl('https://example.com/foo/..%2Fevil.png')\n // URL parser decodes %2F into /, so lastSegment ends up \"..evil.png\" after .. strip\n expect(result.filename).not.toContain('..')\n expect(result.filename).not.toContain('/')\n expect(result.filename).not.toContain('\\\\')\n })\n})\n"],"names":["describe","it","expect","vi","beforeEach","afterEach","dns","isPrivateIp","validateAndFetchUrl","each","ip","toBe","originalFetch","globalThis","fetch","dnsLookupSpy","spyOn","promises","restoreAllMocks","mockDns","address","family","mockResolvedValue","mockFetch","impl","fn","url","Promise","resolve","String","rejects","toThrow","Response","Uint8Array","status","headers","result","contentType","filename","buffer","length","call","mockImplementation","location","Error","maxRedirects","statusText","toMatch","not","toContain"],"mappings":"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"}
|