@continuedev/fetch 1.0.16 → 1.1.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.
- package/CHANGELOG.md +6 -0
- package/dist/fetch.e2e.test.js +356 -0
- package/dist/fetch.js +22 -2
- package/dist/ssl-certificate.test.d.ts +1 -0
- package/dist/ssl-certificate.test.js +231 -0
- package/dist/stream.test.js +4 -0
- package/package.json +9 -3
- package/src/fetch.e2e.test.ts +518 -0
- package/src/fetch.ts +23 -2
- package/src/ssl-certificate.test.ts +301 -0
- package/src/stream.test.ts +7 -0
- package/vitest.config.ts +3 -0
- package/dist/fetch.test.js +0 -131
- /package/dist/{fetch.test.d.ts → fetch.e2e.test.d.ts} +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as http from "node:http";
|
|
3
|
+
import * as https from "node:https";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
8
|
+
import { fetchwithRequestOptions } from "./fetch.js";
|
|
9
|
+
// Test server ports
|
|
10
|
+
const HTTP_PORT = 3001;
|
|
11
|
+
const HTTPS_PORT = 3002;
|
|
12
|
+
// Track servers and temp dirs for cleanup
|
|
13
|
+
const serversToCleanup = [];
|
|
14
|
+
const tempDirsToCleanup = [];
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
// Clean up all servers
|
|
17
|
+
serversToCleanup.forEach((server) => server.close());
|
|
18
|
+
serversToCleanup.length = 0;
|
|
19
|
+
// Clean up temp directories
|
|
20
|
+
tempDirsToCleanup.forEach((tempDir) => {
|
|
21
|
+
if (fs.existsSync(tempDir)) {
|
|
22
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
tempDirsToCleanup.length = 0;
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Create a temporary directory for test files
|
|
29
|
+
*/
|
|
30
|
+
function createTempDir() {
|
|
31
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-test-"));
|
|
32
|
+
tempDirsToCleanup.push(tempDir);
|
|
33
|
+
return tempDir;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Generate a self-signed certificate for testing
|
|
37
|
+
*/
|
|
38
|
+
async function generateCertificate(tempDir, name, options) {
|
|
39
|
+
const certPath = path.join(tempDir, `${name}.crt`);
|
|
40
|
+
const keyPath = path.join(tempDir, `${name}.key`);
|
|
41
|
+
const caCertPath = path.join(tempDir, `${name}-ca.crt`);
|
|
42
|
+
try {
|
|
43
|
+
// Generate a private key
|
|
44
|
+
execSync(`openssl genrsa -out "${keyPath}" 2048`, { stdio: "pipe" });
|
|
45
|
+
// Create config file for certificate
|
|
46
|
+
const configPath = path.join(tempDir, `${name}.conf`);
|
|
47
|
+
const altNames = options.san
|
|
48
|
+
.map((san, i) => {
|
|
49
|
+
return san.match(/^\d+\.\d+\.\d+\.\d+$/)
|
|
50
|
+
? `IP.${i + 1} = ${san}`
|
|
51
|
+
: `DNS.${i + 1} = ${san}`;
|
|
52
|
+
})
|
|
53
|
+
.join("\n");
|
|
54
|
+
const configContent = `
|
|
55
|
+
[req]
|
|
56
|
+
distinguished_name = req_distinguished_name
|
|
57
|
+
req_extensions = v3_req
|
|
58
|
+
prompt = no
|
|
59
|
+
|
|
60
|
+
[req_distinguished_name]
|
|
61
|
+
C = US
|
|
62
|
+
ST = Test
|
|
63
|
+
L = Test
|
|
64
|
+
O = ${options.org}
|
|
65
|
+
CN = ${options.cn}
|
|
66
|
+
|
|
67
|
+
[v3_req]
|
|
68
|
+
keyUsage = keyEncipherment, dataEncipherment
|
|
69
|
+
extendedKeyUsage = serverAuth
|
|
70
|
+
subjectAltName = @alt_names
|
|
71
|
+
|
|
72
|
+
[alt_names]
|
|
73
|
+
${altNames}
|
|
74
|
+
`;
|
|
75
|
+
fs.writeFileSync(configPath, configContent);
|
|
76
|
+
// Generate a self-signed certificate
|
|
77
|
+
execSync(`openssl req -new -x509 -key "${keyPath}" -out "${certPath}" -days 365 -config "${configPath}" -extensions v3_req`, { stdio: "pipe" });
|
|
78
|
+
// Copy the certificate as CA (for testing custom CA scenarios)
|
|
79
|
+
fs.copyFileSync(certPath, caCertPath);
|
|
80
|
+
return { certPath, keyPath, caCertPath };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Fallback: create minimal test certificates if OpenSSL not available
|
|
84
|
+
const key = `-----BEGIN PRIVATE KEY-----
|
|
85
|
+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7VJTUt9Us8cKB
|
|
86
|
+
wEiOfniel+2jNcJjYUiUoq5YbVKk+xqt4bOMh5DNFJ3LnU1OaUHyG5sHlgNyKA==
|
|
87
|
+
-----END PRIVATE KEY-----`;
|
|
88
|
+
const cert = `-----BEGIN CERTIFICATE-----
|
|
89
|
+
MIICljCCAX4CCQCKnW9qX7TlxzANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV
|
|
90
|
+
UzAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMA0xCzAJBgNVBAYTAlVT
|
|
91
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1L7VLPHCQD1OvF4p
|
|
92
|
+
4td9ozXCY2FIlKKuWG1SpPsareGzjIeQzRSdy51NTmlB8hubB5YDcigN8=
|
|
93
|
+
-----END CERTIFICATE-----`;
|
|
94
|
+
fs.writeFileSync(keyPath, key);
|
|
95
|
+
fs.writeFileSync(certPath, cert);
|
|
96
|
+
fs.writeFileSync(caCertPath, cert);
|
|
97
|
+
return { certPath, keyPath, caCertPath };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Start a simple HTTP server for testing
|
|
102
|
+
*/
|
|
103
|
+
async function startHttpServer() {
|
|
104
|
+
const server = http.createServer((req, res) => {
|
|
105
|
+
const url = new URL(req.url, `http://localhost:${HTTP_PORT}`);
|
|
106
|
+
if (url.pathname === "/json") {
|
|
107
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
108
|
+
res.end(JSON.stringify({
|
|
109
|
+
message: "Hello from HTTP server",
|
|
110
|
+
method: req.method,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
else if (url.pathname === "/headers") {
|
|
114
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
115
|
+
res.end(JSON.stringify({ headers: req.headers }));
|
|
116
|
+
}
|
|
117
|
+
else if (url.pathname === "/body") {
|
|
118
|
+
let body = "";
|
|
119
|
+
req.on("data", (chunk) => {
|
|
120
|
+
body += chunk.toString();
|
|
121
|
+
});
|
|
122
|
+
req.on("end", () => {
|
|
123
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
124
|
+
res.end(JSON.stringify({ body, headers: req.headers }));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else if (url.pathname === "/status/404") {
|
|
128
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
129
|
+
res.end("Not Found");
|
|
130
|
+
}
|
|
131
|
+
else if (url.pathname === "/slow") {
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
134
|
+
res.end("Slow response");
|
|
135
|
+
}, 100);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
res.writeHead(404);
|
|
139
|
+
res.end("Not Found");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
await new Promise((resolve) => {
|
|
143
|
+
server.listen(HTTP_PORT, () => resolve());
|
|
144
|
+
});
|
|
145
|
+
serversToCleanup.push(server);
|
|
146
|
+
return server;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Start an HTTPS server with given certificates
|
|
150
|
+
*/
|
|
151
|
+
async function startHttpsServer(certPath, keyPath) {
|
|
152
|
+
const server = https.createServer({
|
|
153
|
+
cert: fs.readFileSync(certPath),
|
|
154
|
+
key: fs.readFileSync(keyPath),
|
|
155
|
+
}, (req, res) => {
|
|
156
|
+
const url = new URL(req.url, `https://localhost:${HTTPS_PORT}`);
|
|
157
|
+
if (url.pathname === "/secure") {
|
|
158
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
159
|
+
res.end(JSON.stringify({ message: "Hello from HTTPS server", secure: true }));
|
|
160
|
+
}
|
|
161
|
+
else if (url.pathname === "/headers") {
|
|
162
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
163
|
+
res.end(JSON.stringify({ headers: req.headers }));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
res.writeHead(404);
|
|
167
|
+
res.end("Not Found");
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
await new Promise((resolve) => {
|
|
171
|
+
server.listen(HTTPS_PORT, () => resolve());
|
|
172
|
+
});
|
|
173
|
+
serversToCleanup.push(server);
|
|
174
|
+
return server;
|
|
175
|
+
}
|
|
176
|
+
describe("fetchwithRequestOptions E2E tests", () => {
|
|
177
|
+
describe("HTTP requests", () => {
|
|
178
|
+
test("should make basic HTTP GET request", async () => {
|
|
179
|
+
await startHttpServer();
|
|
180
|
+
const response = await fetchwithRequestOptions(`http://localhost:${HTTP_PORT}/json`);
|
|
181
|
+
expect(response.status).toBe(200);
|
|
182
|
+
const data = (await response.json());
|
|
183
|
+
expect(data).toEqual({
|
|
184
|
+
message: "Hello from HTTP server",
|
|
185
|
+
method: "GET",
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
test("should make HTTP POST request with body", async () => {
|
|
189
|
+
await startHttpServer();
|
|
190
|
+
const response = await fetchwithRequestOptions(`http://localhost:${HTTP_PORT}/json`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
body: JSON.stringify({ test: "data" }),
|
|
193
|
+
headers: { "Content-Type": "application/json" },
|
|
194
|
+
});
|
|
195
|
+
expect(response.status).toBe(200);
|
|
196
|
+
const data = (await response.json());
|
|
197
|
+
expect(data.method).toBe("POST");
|
|
198
|
+
});
|
|
199
|
+
test("should handle custom headers", async () => {
|
|
200
|
+
await startHttpServer();
|
|
201
|
+
const response = await fetchwithRequestOptions(`http://localhost:${HTTP_PORT}/headers`, {
|
|
202
|
+
headers: { "X-Custom-Header": "test-value" },
|
|
203
|
+
});
|
|
204
|
+
const data = (await response.json());
|
|
205
|
+
expect(data.headers["x-custom-header"]).toBe("test-value");
|
|
206
|
+
});
|
|
207
|
+
test("should handle error responses", async () => {
|
|
208
|
+
await startHttpServer();
|
|
209
|
+
const response = await fetchwithRequestOptions(`http://localhost:${HTTP_PORT}/status/404`);
|
|
210
|
+
expect(response.status).toBe(404);
|
|
211
|
+
expect(response.ok).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe("Request options integration", () => {
|
|
215
|
+
test("should merge extra body properties", async () => {
|
|
216
|
+
await startHttpServer();
|
|
217
|
+
const response = await fetchwithRequestOptions(`http://localhost:${HTTP_PORT}/body`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: JSON.stringify({ original: "data" }),
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
}, {
|
|
222
|
+
extraBodyProperties: { extra: "property" },
|
|
223
|
+
});
|
|
224
|
+
expect(response.status).toBe(200);
|
|
225
|
+
const data = (await response.json());
|
|
226
|
+
const body = JSON.parse(data.body);
|
|
227
|
+
expect(body).toEqual({ original: "data", extra: "property" });
|
|
228
|
+
});
|
|
229
|
+
test("should handle RequestOptions headers", async () => {
|
|
230
|
+
await startHttpServer();
|
|
231
|
+
const response = await fetchwithRequestOptions(`http://localhost:${HTTP_PORT}/headers`, {}, {
|
|
232
|
+
headers: { "X-Request-Options-Header": "from-request-options" },
|
|
233
|
+
});
|
|
234
|
+
const data = (await response.json());
|
|
235
|
+
expect(data.headers["x-request-options-header"]).toBe("from-request-options");
|
|
236
|
+
});
|
|
237
|
+
test("should merge headers from init and requestOptions", async () => {
|
|
238
|
+
await startHttpServer();
|
|
239
|
+
const response = await fetchwithRequestOptions(`http://localhost:${HTTP_PORT}/headers`, {
|
|
240
|
+
headers: { "X-Init-Header": "from-init" },
|
|
241
|
+
}, {
|
|
242
|
+
headers: { "X-Request-Options-Header": "from-request-options" },
|
|
243
|
+
});
|
|
244
|
+
const data = (await response.json());
|
|
245
|
+
expect(data.headers["x-init-header"]).toBe("from-init");
|
|
246
|
+
expect(data.headers["x-request-options-header"]).toBe("from-request-options");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
describe("Certificate handling - Enterprise scenarios", () => {
|
|
250
|
+
test("should REJECT self-signed certificates when SSL verification is enabled", async () => {
|
|
251
|
+
const tempDir = createTempDir();
|
|
252
|
+
const serverCerts = await generateCertificate(tempDir, "server", {
|
|
253
|
+
cn: "localhost",
|
|
254
|
+
san: ["localhost", "127.0.0.1"],
|
|
255
|
+
org: "Test",
|
|
256
|
+
});
|
|
257
|
+
await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
|
|
258
|
+
// This simulates the customer's "unable to get local issuer certificate" error
|
|
259
|
+
await expect(fetchwithRequestOptions(`https://localhost:${HTTPS_PORT}/secure`, {}, { verifySsl: true })).rejects.toThrow(); // Should fail with certificate error
|
|
260
|
+
});
|
|
261
|
+
test("should ACCEPT self-signed certificates when SSL verification is disabled", async () => {
|
|
262
|
+
const tempDir = createTempDir();
|
|
263
|
+
const serverCerts = await generateCertificate(tempDir, "server", {
|
|
264
|
+
cn: "localhost",
|
|
265
|
+
san: ["localhost", "127.0.0.1"],
|
|
266
|
+
org: "Test",
|
|
267
|
+
});
|
|
268
|
+
await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
|
|
269
|
+
const response = await fetchwithRequestOptions(`https://localhost:${HTTPS_PORT}/secure`, {}, { verifySsl: false });
|
|
270
|
+
expect(response.status).toBe(200);
|
|
271
|
+
const data = (await response.json());
|
|
272
|
+
expect(data.secure).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
test("should ACCEPT self-signed certificates with custom CA bundle", async () => {
|
|
275
|
+
const tempDir = createTempDir();
|
|
276
|
+
const serverCerts = await generateCertificate(tempDir, "server", {
|
|
277
|
+
cn: "localhost",
|
|
278
|
+
san: ["localhost", "127.0.0.1"],
|
|
279
|
+
org: "Test",
|
|
280
|
+
});
|
|
281
|
+
await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
|
|
282
|
+
const response = await fetchwithRequestOptions(`https://localhost:${HTTPS_PORT}/secure`, {}, {
|
|
283
|
+
caBundlePath: serverCerts.caCertPath, // Our self-signed cert as CA
|
|
284
|
+
verifySsl: true,
|
|
285
|
+
});
|
|
286
|
+
expect(response.status).toBe(200);
|
|
287
|
+
const data = (await response.json());
|
|
288
|
+
expect(data.secure).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
test("should REJECT certificates when provided WRONG CA bundle", async () => {
|
|
291
|
+
const tempDir = createTempDir();
|
|
292
|
+
const serverCerts = await generateCertificate(tempDir, "server", {
|
|
293
|
+
cn: "localhost",
|
|
294
|
+
san: ["localhost", "127.0.0.1"],
|
|
295
|
+
org: "Test",
|
|
296
|
+
});
|
|
297
|
+
// Generate a different certificate as the "wrong" CA
|
|
298
|
+
const wrongCaCerts = await generateCertificate(tempDir, "wrong-ca", {
|
|
299
|
+
cn: "wrong-ca",
|
|
300
|
+
san: ["wrong-ca"],
|
|
301
|
+
org: "Wrong CA",
|
|
302
|
+
});
|
|
303
|
+
await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
|
|
304
|
+
// This should fail because the wrong CA can't validate our server certificate
|
|
305
|
+
await expect(fetchwithRequestOptions(`https://localhost:${HTTPS_PORT}/secure`, {}, {
|
|
306
|
+
caBundlePath: wrongCaCerts.caCertPath,
|
|
307
|
+
verifySsl: true,
|
|
308
|
+
})).rejects.toThrow(); // Should fail with certificate validation error
|
|
309
|
+
});
|
|
310
|
+
test("should REJECT certificates when CA bundle file is corrupted", async () => {
|
|
311
|
+
const tempDir = createTempDir();
|
|
312
|
+
const serverCerts = await generateCertificate(tempDir, "server", {
|
|
313
|
+
cn: "localhost",
|
|
314
|
+
san: ["localhost", "127.0.0.1"],
|
|
315
|
+
org: "Test",
|
|
316
|
+
});
|
|
317
|
+
// Create a corrupted/invalid certificate file
|
|
318
|
+
const corruptedCaPath = path.join(tempDir, "corrupted-ca.pem");
|
|
319
|
+
const corruptedContent = `-----BEGIN CERTIFICATE-----
|
|
320
|
+
This is not a valid certificate content
|
|
321
|
+
Just some random text that looks like a cert
|
|
322
|
+
-----END CERTIFICATE-----`;
|
|
323
|
+
fs.writeFileSync(corruptedCaPath, corruptedContent);
|
|
324
|
+
await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
|
|
325
|
+
// This should fail because the corrupted CA file can't be parsed
|
|
326
|
+
await expect(fetchwithRequestOptions(`https://localhost:${HTTPS_PORT}/secure`, {}, {
|
|
327
|
+
caBundlePath: corruptedCaPath,
|
|
328
|
+
verifySsl: true,
|
|
329
|
+
})).rejects.toThrow(); // Should fail with certificate parsing or validation error
|
|
330
|
+
});
|
|
331
|
+
test("should REJECT certificates when CA bundle file does not exist", async () => {
|
|
332
|
+
const tempDir = createTempDir();
|
|
333
|
+
const serverCerts = await generateCertificate(tempDir, "server", {
|
|
334
|
+
cn: "localhost",
|
|
335
|
+
san: ["localhost", "127.0.0.1"],
|
|
336
|
+
org: "Test",
|
|
337
|
+
});
|
|
338
|
+
const nonExistentCaPath = path.join(tempDir, "does-not-exist.pem");
|
|
339
|
+
await startHttpsServer(serverCerts.certPath, serverCerts.keyPath);
|
|
340
|
+
// This should handle the missing file gracefully but still fail cert validation
|
|
341
|
+
await expect(fetchwithRequestOptions(`https://localhost:${HTTPS_PORT}/secure`, {}, {
|
|
342
|
+
caBundlePath: nonExistentCaPath,
|
|
343
|
+
verifySsl: true,
|
|
344
|
+
})).rejects.toThrow(); // Should fail with certificate validation error
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
describe("Error handling", () => {
|
|
348
|
+
test("should handle network errors gracefully", async () => {
|
|
349
|
+
// Try to connect to a non-existent server
|
|
350
|
+
await expect(fetchwithRequestOptions("http://localhost:9999/nonexistent")).rejects.toThrow();
|
|
351
|
+
});
|
|
352
|
+
test("should handle malformed URLs", async () => {
|
|
353
|
+
await expect(fetchwithRequestOptions("not-a-valid-url")).rejects.toThrow();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
package/dist/fetch.js
CHANGED
|
@@ -79,8 +79,28 @@ export async function fetchwithRequestOptions(url_, init, requestOptions) {
|
|
|
79
79
|
: new HttpProxyAgent(proxy, agentOptions)
|
|
80
80
|
: new protocol.Agent(agentOptions);
|
|
81
81
|
let headers = {};
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Handle different header formats
|
|
83
|
+
if (init?.headers) {
|
|
84
|
+
const headersSource = init.headers;
|
|
85
|
+
// Check if it's a Headers-like object (OpenAI v5 HeadersList, standard Headers)
|
|
86
|
+
if (headersSource && typeof headersSource.forEach === "function") {
|
|
87
|
+
// Use forEach method which works reliably on Headers objects
|
|
88
|
+
headersSource.forEach((value, key) => {
|
|
89
|
+
headers[key] = value;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else if (Array.isArray(headersSource)) {
|
|
93
|
+
// This is an array of [key, value] tuples
|
|
94
|
+
for (const [key, value] of headersSource) {
|
|
95
|
+
headers[key] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (headersSource && typeof headersSource === "object") {
|
|
99
|
+
// This is a plain object
|
|
100
|
+
for (const [key, value] of Object.entries(headersSource)) {
|
|
101
|
+
headers[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
84
104
|
}
|
|
85
105
|
headers = {
|
|
86
106
|
...headers,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
5
|
+
import { CertsCache, getCertificateContent } from "./certs.js";
|
|
6
|
+
import { getAgentOptions } from "./getAgentOptions.js";
|
|
7
|
+
// Store original env
|
|
8
|
+
const originalEnv = process.env;
|
|
9
|
+
// Temporary directory for test certificate files
|
|
10
|
+
let tempDir;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
// Create a temporary directory for test files
|
|
13
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cert-troubleshoot-test-"));
|
|
14
|
+
process.env = { ...originalEnv };
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
process.env = originalEnv;
|
|
18
|
+
CertsCache.getInstance().clear();
|
|
19
|
+
// Clean up temporary directory
|
|
20
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
21
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
describe("SSL certificate handling", () => {
|
|
25
|
+
describe("CA certificate handling", () => {
|
|
26
|
+
test("should handle custom CA bundle from file path", async () => {
|
|
27
|
+
const customCaCert = `-----BEGIN CERTIFICATE-----
|
|
28
|
+
MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
|
|
29
|
+
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
|
30
|
+
aWRnaXRzIFB0eSBMdGQwHhcNMTcwODI4MTkzNDA5WhcNMTgwODI4MTkzNDA5WjBF
|
|
31
|
+
-----END CERTIFICATE-----`;
|
|
32
|
+
const caCertPath = path.join(tempDir, "ca-bundle.pem");
|
|
33
|
+
fs.writeFileSync(caCertPath, customCaCert);
|
|
34
|
+
const options = await getAgentOptions({ caBundlePath: caCertPath });
|
|
35
|
+
expect(options.ca).toBeDefined();
|
|
36
|
+
expect(Array.isArray(options.ca)).toBe(true);
|
|
37
|
+
expect(options.ca).toContain(customCaCert);
|
|
38
|
+
});
|
|
39
|
+
test("should handle NODE_EXTRA_CA_CERTS environment variable", async () => {
|
|
40
|
+
const extraCaCert = `-----BEGIN CERTIFICATE-----
|
|
41
|
+
MIIDUzCCAjugAwIBAgIJALvxFjX5V+/vMA0GCSqGSIb3DQEBCwUAMDgxCzAJBgNV
|
|
42
|
+
BAYTAlVTMRAwDgYDVQQIDAdDb21wYW55MRcwFQYDVQQKDA5Db21wYW55IENvcnAw
|
|
43
|
+
-----END CERTIFICATE-----`;
|
|
44
|
+
const extraCaPath = path.join(tempDir, "extra-ca.crt");
|
|
45
|
+
fs.writeFileSync(extraCaPath, extraCaCert);
|
|
46
|
+
process.env.NODE_EXTRA_CA_CERTS = extraCaPath;
|
|
47
|
+
const options = await getAgentOptions();
|
|
48
|
+
expect(options.ca).toBeDefined();
|
|
49
|
+
expect(Array.isArray(options.ca)).toBe(true);
|
|
50
|
+
expect(options.ca).toContain(extraCaCert);
|
|
51
|
+
});
|
|
52
|
+
test("should combine NODE_EXTRA_CA_CERTS with custom CA bundle", async () => {
|
|
53
|
+
const extraCaCert = `-----BEGIN CERTIFICATE-----
|
|
54
|
+
MIIDExtra1234567890abcdefghijklmnopqrstuvwxyz
|
|
55
|
+
-----END CERTIFICATE-----`;
|
|
56
|
+
const customCaCert = `-----BEGIN CERTIFICATE-----
|
|
57
|
+
MIIDCustom1234567890abcdefghijklmnopqrstuvwxyz
|
|
58
|
+
-----END CERTIFICATE-----`;
|
|
59
|
+
const extraCaPath = path.join(tempDir, "extra-ca.crt");
|
|
60
|
+
const customCaPath = path.join(tempDir, "custom-ca.pem");
|
|
61
|
+
fs.writeFileSync(extraCaPath, extraCaCert);
|
|
62
|
+
fs.writeFileSync(customCaPath, customCaCert);
|
|
63
|
+
process.env.NODE_EXTRA_CA_CERTS = extraCaPath;
|
|
64
|
+
const options = await getAgentOptions({ caBundlePath: customCaPath });
|
|
65
|
+
expect(options.ca).toBeDefined();
|
|
66
|
+
expect(Array.isArray(options.ca)).toBe(true);
|
|
67
|
+
expect(options.ca).toContain(extraCaCert);
|
|
68
|
+
expect(options.ca).toContain(customCaCert);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe("Data URI certificate handling", () => {
|
|
72
|
+
test("should handle base64 encoded data URI certificates", () => {
|
|
73
|
+
const originalCert = `-----BEGIN CERTIFICATE-----
|
|
74
|
+
MIIDUzCCAjugAwIBAgIJALvxFjX5V+/vMA0GCSqGSIb3DQEB
|
|
75
|
+
-----END CERTIFICATE-----`;
|
|
76
|
+
const base64Data = Buffer.from(originalCert).toString("base64");
|
|
77
|
+
const dataUri = `data:application/x-pem-file;base64,${base64Data}`;
|
|
78
|
+
const result = getCertificateContent(dataUri);
|
|
79
|
+
expect(result).toBe(originalCert);
|
|
80
|
+
});
|
|
81
|
+
test("should handle URL-encoded data URI certificates", () => {
|
|
82
|
+
const originalCert = "certificate with spaces and special chars: !@#$%^&*()";
|
|
83
|
+
const encodedData = encodeURIComponent(originalCert);
|
|
84
|
+
const dataUri = `data:text/plain,${encodedData}`;
|
|
85
|
+
const result = getCertificateContent(dataUri);
|
|
86
|
+
expect(result).toBe(originalCert);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("SSL/TLS configuration", () => {
|
|
90
|
+
test("should configure SSL verification correctly", async () => {
|
|
91
|
+
// Test with SSL verification enabled
|
|
92
|
+
let options = await getAgentOptions({ verifySsl: true });
|
|
93
|
+
expect(options.rejectUnauthorized).toBe(true);
|
|
94
|
+
// Test with SSL verification disabled
|
|
95
|
+
options = await getAgentOptions({ verifySsl: false });
|
|
96
|
+
expect(options.rejectUnauthorized).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
test("should configure timeout correctly", async () => {
|
|
99
|
+
const customTimeout = 60; // 1 minute in seconds
|
|
100
|
+
const options = await getAgentOptions({ timeout: customTimeout });
|
|
101
|
+
expect(options.timeout).toBe(customTimeout * 1000); // Should be converted to milliseconds
|
|
102
|
+
});
|
|
103
|
+
test("should handle client certificate authentication", async () => {
|
|
104
|
+
const clientCert = `-----BEGIN CERTIFICATE-----
|
|
105
|
+
MIIDClientCert1234567890abcdefghijklmnopqrstuvwxyz
|
|
106
|
+
-----END CERTIFICATE-----`;
|
|
107
|
+
const clientKey = `-----BEGIN PRIVATE KEY-----
|
|
108
|
+
MIIEClientKey567890abcdefghijklmnopqrstuvwxyz
|
|
109
|
+
-----END PRIVATE KEY-----`;
|
|
110
|
+
const clientCertPath = path.join(tempDir, "client.crt");
|
|
111
|
+
const clientKeyPath = path.join(tempDir, "client.key");
|
|
112
|
+
fs.writeFileSync(clientCertPath, clientCert);
|
|
113
|
+
fs.writeFileSync(clientKeyPath, clientKey);
|
|
114
|
+
const options = await getAgentOptions({
|
|
115
|
+
clientCertificate: {
|
|
116
|
+
cert: clientCertPath,
|
|
117
|
+
key: clientKeyPath,
|
|
118
|
+
passphrase: "test-passphrase",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
expect(options.cert).toBe(clientCert);
|
|
122
|
+
expect(options.key).toBe(clientKey);
|
|
123
|
+
expect(options.passphrase).toBe("test-passphrase");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe("Certificate caching behavior", () => {
|
|
127
|
+
test("should cache certificate content to avoid repeated file reads", async () => {
|
|
128
|
+
const certsCache = CertsCache.getInstance();
|
|
129
|
+
const certPath = path.join(tempDir, "cached-cert.pem");
|
|
130
|
+
const certContent = `-----BEGIN CERTIFICATE-----
|
|
131
|
+
MIIDCachedCert1234567890abcdefghijklmnopqrstuvwxyz
|
|
132
|
+
-----END CERTIFICATE-----`;
|
|
133
|
+
fs.writeFileSync(certPath, certContent);
|
|
134
|
+
// First call should read from file
|
|
135
|
+
const result1 = await certsCache.getCachedCustomCert(certPath);
|
|
136
|
+
expect(result1).toBe(certContent);
|
|
137
|
+
// Modify the file on disk
|
|
138
|
+
const modifiedContent = certContent.replace("CachedCert", "ModifiedCert");
|
|
139
|
+
fs.writeFileSync(certPath, modifiedContent);
|
|
140
|
+
// Second call should return cached content, not the modified content
|
|
141
|
+
const result2 = await certsCache.getCachedCustomCert(certPath);
|
|
142
|
+
expect(result2).toBe(certContent); // Should still be the original cached content
|
|
143
|
+
});
|
|
144
|
+
test("should handle cache clearing", async () => {
|
|
145
|
+
const certsCache = CertsCache.getInstance();
|
|
146
|
+
const certPath = path.join(tempDir, "clear-cache-cert.pem");
|
|
147
|
+
const originalContent = `-----BEGIN CERTIFICATE-----
|
|
148
|
+
MIIDOriginalCert1234567890abcdefghijklmnopqrstuvwxyz
|
|
149
|
+
-----END CERTIFICATE-----`;
|
|
150
|
+
fs.writeFileSync(certPath, originalContent);
|
|
151
|
+
// Load into cache
|
|
152
|
+
await certsCache.getCachedCustomCert(certPath);
|
|
153
|
+
// Clear cache
|
|
154
|
+
certsCache.clear();
|
|
155
|
+
// Modify file
|
|
156
|
+
const newContent = originalContent.replace("OriginalCert", "NewCert");
|
|
157
|
+
fs.writeFileSync(certPath, newContent);
|
|
158
|
+
// Should read new content after cache clear
|
|
159
|
+
const result2 = await certsCache.getCachedCustomCert(certPath);
|
|
160
|
+
expect(result2).toBe(newContent);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe("Error handling scenarios", () => {
|
|
164
|
+
test("should handle non-existent certificate files gracefully", async () => {
|
|
165
|
+
const nonExistentPath = path.join(tempDir, "does-not-exist.pem");
|
|
166
|
+
// Should not throw but should continue with system certificates only
|
|
167
|
+
const options = await getAgentOptions({ caBundlePath: nonExistentPath });
|
|
168
|
+
expect(options.ca).toBeDefined();
|
|
169
|
+
expect(Array.isArray(options.ca)).toBe(true);
|
|
170
|
+
// Should only contain system certificates, not the missing custom one
|
|
171
|
+
expect(options.ca.length).toBeGreaterThan(0);
|
|
172
|
+
});
|
|
173
|
+
test("should handle empty certificate files", () => {
|
|
174
|
+
const emptyCertPath = path.join(tempDir, "empty-cert.pem");
|
|
175
|
+
fs.writeFileSync(emptyCertPath, "");
|
|
176
|
+
const result = getCertificateContent(emptyCertPath);
|
|
177
|
+
expect(result).toBe("");
|
|
178
|
+
});
|
|
179
|
+
test("should handle malformed data URIs", () => {
|
|
180
|
+
const validMalformedDataUris = ["data:invalid-format", "data:"];
|
|
181
|
+
validMalformedDataUris.forEach((uri) => {
|
|
182
|
+
// These should not throw but may return unexpected content
|
|
183
|
+
expect(() => getCertificateContent(uri)).not.toThrow();
|
|
184
|
+
const result = getCertificateContent(uri);
|
|
185
|
+
expect(typeof result).toBe("string");
|
|
186
|
+
});
|
|
187
|
+
// This one will be treated as a file path and should throw
|
|
188
|
+
expect(() => getCertificateContent("not-a-data-uri-at-all")).toThrow("ENOENT");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
describe("Real-world scenarios", () => {
|
|
192
|
+
test("should handle certificate bundle with multiple certificates", () => {
|
|
193
|
+
const bundleContent = `-----BEGIN CERTIFICATE-----
|
|
194
|
+
MIIDRootCA1234567890abcdefghijklmnopqrstuvwxyz
|
|
195
|
+
-----END CERTIFICATE-----
|
|
196
|
+
-----BEGIN CERTIFICATE-----
|
|
197
|
+
MIIDIntermediateCA1234567890abcdefghijklmnopqrstuvwxyz
|
|
198
|
+
-----END CERTIFICATE-----`;
|
|
199
|
+
const bundlePath = path.join(tempDir, "ca-bundle.pem");
|
|
200
|
+
fs.writeFileSync(bundlePath, bundleContent);
|
|
201
|
+
const result = getCertificateContent(bundlePath);
|
|
202
|
+
expect(result).toBe(bundleContent);
|
|
203
|
+
expect(result).toContain("RootCA");
|
|
204
|
+
expect(result).toContain("IntermediateCA");
|
|
205
|
+
});
|
|
206
|
+
test("should work with common certificate file extensions", () => {
|
|
207
|
+
const certExtensions = [".pem", ".crt", ".cer"];
|
|
208
|
+
const testCert = `-----BEGIN CERTIFICATE-----
|
|
209
|
+
MIIDTestCert1234567890abcdefghijklmnopqrstuvwxyz
|
|
210
|
+
-----END CERTIFICATE-----`;
|
|
211
|
+
for (const ext of certExtensions) {
|
|
212
|
+
const certPath = path.join(tempDir, `test-cert${ext}`);
|
|
213
|
+
fs.writeFileSync(certPath, testCert);
|
|
214
|
+
const result = getCertificateContent(certPath);
|
|
215
|
+
expect(result).toBe(testCert);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
test("should handle proxy scenarios with custom certificates", async () => {
|
|
219
|
+
// Test that certificates work properly when proxy might be involved
|
|
220
|
+
const proxyCaCert = `-----BEGIN CERTIFICATE-----
|
|
221
|
+
MIIDProxyCA1234567890abcdefghijklmnopqrstuvwxyz
|
|
222
|
+
-----END CERTIFICATE-----`;
|
|
223
|
+
const proxyCaPath = path.join(tempDir, "proxy-ca.pem");
|
|
224
|
+
fs.writeFileSync(proxyCaPath, proxyCaCert);
|
|
225
|
+
const options = await getAgentOptions({ caBundlePath: proxyCaPath });
|
|
226
|
+
expect(options.ca).toBeDefined();
|
|
227
|
+
expect(options.ca).toContain(proxyCaCert);
|
|
228
|
+
expect(options.keepAlive).toBe(true); // Should work with keep-alive for proxy scenarios
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
package/dist/stream.test.js
CHANGED
|
@@ -71,6 +71,10 @@ describe("parseDataLine", () => {
|
|
|
71
71
|
const line = 'data: {"error":"something went wrong"}';
|
|
72
72
|
expect(() => parseDataLine(line)).toThrow('Error streaming response: "something went wrong"');
|
|
73
73
|
});
|
|
74
|
+
test("parseDataLine should throw error when data contains error object with message", () => {
|
|
75
|
+
const line = 'data: {"error":{"message":"detailed error message"}}';
|
|
76
|
+
expect(() => parseDataLine(line)).toThrow("Error streaming response: detailed error message");
|
|
77
|
+
});
|
|
74
78
|
test("parseDataLine should handle empty objects", () => {
|
|
75
79
|
const line = "data: {}";
|
|
76
80
|
const result = parseDataLine(line);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@continuedev/fetch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"author": "Nate Sesti and Ty Dunn",
|
|
13
13
|
"license": "Apache-2.0",
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@continuedev/config-types": "^1.0.
|
|
15
|
+
"@continuedev/config-types": "^1.0.14",
|
|
16
16
|
"follow-redirects": "^1.15.6",
|
|
17
17
|
"http-proxy-agent": "^7.0.2",
|
|
18
18
|
"https-proxy-agent": "^7.0.5",
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/follow-redirects": "^1.14.4",
|
|
23
|
-
"
|
|
23
|
+
"typescript": "^5.0.0",
|
|
24
|
+
"vitest": "^3.2.0",
|
|
25
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
26
|
+
"@semantic-release/git": "^10.0.1",
|
|
27
|
+
"@semantic-release/github": "^9.2.6",
|
|
28
|
+
"@semantic-release/npm": "^10.0.6",
|
|
29
|
+
"semantic-release": "^21.1.2"
|
|
24
30
|
}
|
|
25
31
|
}
|