mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 00:14:15 +00:00
fix: address PR #4 review - HTTPS tests and timeout handling
- Fix timeout test to use HTTPS server (tunnel uses https.request) - Fix timeout event handling - call reject directly instead of relying on destroy error event - Fix misleading comment in remote-token-uploader (Authorization header, not X-Management-Key) - Remove unused imports in test files Built [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids <built@onsteroids.ai>
This commit is contained in:
1 parent
c3bfa34703
commit
e055890e16
4 files changed
+131
-68
No files matched your search
@@ -226,7 +226,10 @@ export class HttpsTunnelProxy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
upstreamReq.on('timeout', () => {
|
upstreamReq.on('timeout', () => {
|
||||||
upstreamReq.destroy(new Error('Upstream request timeout'));
|
const timeoutError = new Error('Upstream request timeout');
|
||||||
|
this.log(`Timeout: ${timeoutError.message}`);
|
||||||
|
upstreamReq.destroy();
|
||||||
|
reject(timeoutError);
|
||||||
});
|
});
|
||||||
|
|
||||||
upstreamReq.on('error', (err) => {
|
upstreamReq.on('error', (err) => {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export async function uploadTokenToRemote(
|
|||||||
const fileName = path.basename(tokenFilePath);
|
const fileName = path.basename(tokenFilePath);
|
||||||
const url = buildProxyUrl(target, '/v0/management/auth-files');
|
const url = buildProxyUrl(target, '/v0/management/auth-files');
|
||||||
|
|
||||||
// Use X-Management-Key header (CLIProxyAPI requirement)
|
// Use Authorization: Bearer header for authentication
|
||||||
const authKey = target.managementKey ?? target.authToken;
|
const authKey = target.managementKey ?? target.authToken;
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
|
|||||||
@@ -4,34 +4,73 @@
|
|||||||
* Tests for HttpsTunnelProxy which tunnels HTTP requests to remote HTTPS CLIProxyAPI.
|
* Tests for HttpsTunnelProxy which tunnels HTTP requests to remote HTTPS CLIProxyAPI.
|
||||||
* Required because Claude Code (undici) doesn't support HTTPS in ANTHROPIC_BASE_URL.
|
* Required because Claude Code (undici) doesn't support HTTPS in ANTHROPIC_BASE_URL.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
import { describe, it, expect, afterEach } from 'bun:test';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import type { AddressInfo } from 'net';
|
import type { AddressInfo } from 'net';
|
||||||
|
|
||||||
// Import the class under test
|
// Import the class under test
|
||||||
import { HttpsTunnelProxy, type HttpsTunnelConfig } from '../../../src/cliproxy/https-tunnel-proxy';
|
import { HttpsTunnelProxy, type HttpsTunnelConfig } from '../../../src/cliproxy/https-tunnel-proxy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-signed certificate for testing HTTPS servers.
|
||||||
|
* Generated with: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
|
||||||
|
*/
|
||||||
|
const TEST_CERT = {
|
||||||
|
key: `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1Ji8aq3YnxiRC
|
||||||
|
i5syvghdG+08f9Gc1C55UiNZc5zxY6Ij73pg72fWO614WH5MT3GeeDHdA4jF/xxZ
|
||||||
|
tgLecJ14L5CyqpbcFYnayjRS5WjWfG40VOsVAf5OiJem6YL4Yu9EExO1MxhzIQmW
|
||||||
|
fqijCSBVPiYkJ9CoT1EuUMPBudWkxs5NQHUYJu2Hq/mG89W+yMuT+Yp9YKau1snb
|
||||||
|
x0I4aqf+OBJKrlWEZ+tgcTbyWW0bvQv8Ou9cFZjsXQF8jXBHJ97/LPW850jKt76J
|
||||||
|
6PjIZeaTScZo9Py/fSIf8+4XYFHr3TVmUpoa1f1jmp+kE0H51+KDnqbcYCB6EwFm
|
||||||
|
UFRaJ6ZtAgMBAAECggEAB5j3DMqi2qmIHR+3KKT+ZiP6X+PfKhFeyZ5/oQwk9Egr
|
||||||
|
erpb4EjqNVACHIle8rBk9t0vqjIFwILMm5lIptpY9bt4+XqyImo81+eh04A6VL9E
|
||||||
|
l/6fxXJ0n11B5Fw9g/wSRkFOkvZLpjh9Kx9befWzXMqN1aJd2/vyTwZQJNs4cgAF
|
||||||
|
BUppw0PG1ujLXl48pNoGqMVLALWA1XwlexBxh6EgU9rsar9desqqhF1pZIAimB4x
|
||||||
|
pvQSPqENFjCOMY93RRvZHITE37Y61BiofdHHqIDLAqYZLHlq+rUupYe9auUHMpaD
|
||||||
|
KFV99x9gfVS9l0aqzxXw+nqar9o1+h5lWO2hCw3HgQKBgQD1n19aJ3z1TC1bRLGV
|
||||||
|
H1UTgx/TAfFsXB7S3RO+Tkfhn+g169ByOWRELIGptOSBYauo/N29AYNHjNJPZDYM
|
||||||
|
sraJfyLStrcCTzXcum1Xfx6rjyq5LU5D93F6ZXGBlVrLmk/+YEep65g0X82VzbPN
|
||||||
|
9aIKW2kBLKuH2O9MwaqWko+ZEQKBgQC8zXlgsq2fkKeyRJfIAN8+Wx2Kd1n5KPbl
|
||||||
|
nvbj9k59oYBz1yggj9v6vjfo9MrgZxp5LmR5UgGsSFpqXpkA7SDYCvKZUwQj/eIx
|
||||||
|
LhV6NG+SnbFKtinIuh9GHiEKGNxJsRL7ZljVjW6f6C4f4MeyEW2IpE5AMAaCfpUS
|
||||||
|
JxI3afJXnQKBgBcKsGNAuRQ55Tdeploa6lw+PMoKsJ89tRaK7sM3jL65xYrpaFCO
|
||||||
|
2b0bf75v3c/VXckoj5Sfg7U+nKwd9oQSb9VOO/IQefKZg7AFPSSsJDBr6dIdUe5G
|
||||||
|
VDrrMU66uB3JiB+Q4KgsFcc0BZE8DtYPaPgXwy39Bspjq29D68DcVuRBAoGAZbA9
|
||||||
|
qalS/lhJGij7nwtpMgqdNJDn8tzvbelajJmC2QN9Tecag783+istrdj61DZz+cTU
|
||||||
|
9MsIf6RQnm3o9qjBQdtTouUlm8UIaPirNLC9TziD3vuSMbydT4S2wtt0+nPXB3Su
|
||||||
|
cAbHCHVjMmQ86lmcpzXnt4amWu6Wl7pXg2Ua07kCgYEA8XMfXql48oUVIr+cN5AN
|
||||||
|
nnql5ojMi2Vp9SeTDM/LKCJ9HORCKi4DyHqm06OjDFi2Al57DVsLHpxENWVSgUmp
|
||||||
|
OpcE1P2kqmDqQguFg9GUPX38zspijdVBd1rtpPfHsAu+ZvRWT8ozFLKZ3Xjg1fIx
|
||||||
|
TL6BeOBii9TlZ66SlZT5HRM=
|
||||||
|
-----END PRIVATE KEY-----`,
|
||||||
|
cert: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDCTCCAfGgAwIBAgIUfoHoOgjjiqPNOxpbp7jHBFSfTeEwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDExNDE1MzAyMFoXDTI3MDEx
|
||||||
|
NDE1MzAyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||||
|
AAOCAQ8AMIIBCgKCAQEAtSYvGqt2J8YkQoubMr4IXRvtPH/RnNQueVIjWXOc8WOi
|
||||||
|
I+96YO9n1juteFh+TE9xnngx3QOIxf8cWbYC3nCdeC+QsqqW3BWJ2so0UuVo1nxu
|
||||||
|
NFTrFQH+ToiXpumC+GLvRBMTtTMYcyEJln6oowkgVT4mJCfQqE9RLlDDwbnVpMbO
|
||||||
|
TUB1GCbth6v5hvPVvsjLk/mKfWCmrtbJ28dCOGqn/jgSSq5VhGfrYHE28lltG70L
|
||||||
|
/DrvXBWY7F0BfI1wRyfe/yz1vOdIyre+iej4yGXmk0nGaPT8v30iH/PuF2BR6901
|
||||||
|
ZlKaGtX9Y5qfpBNB+dfig56m3GAgehMBZlBUWiembQIDAQABo1MwUTAdBgNVHQ4E
|
||||||
|
FgQUHvZklBlcvTOIuN/xSO7BP7rgSqswHwYDVR0jBBgwFoAUHvZklBlcvTOIuN/x
|
||||||
|
SO7BP7rgSqswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAWRj4
|
||||||
|
bB1eObtMOal4VPmL5iX07XzL4hp6Dwu2LSrw9KMArqTTeyJEGNSsykuHPZwPIflD
|
||||||
|
JkzfrFfDv8q7YDpSLy9vJN2E+SPn/Oq2BehUmD+uURghaoSsXeyY9Kv6vGZri95l
|
||||||
|
rgg+6wLJDVrnw5tKxEHx5hUyVR3Ms4LwU/hwAcCGCxx5exhvLpfjjxGBR814kCEc
|
||||||
|
IKGISNjqDo1Pz1Xm8QBLzG4CtlzE/QEbkJKImmwskv6vvoRbWg+B529WzFFCReYK
|
||||||
|
/sULgpvG29Uc3MwZK242dKyTUFdI6tQuZ8xierXwP0kIFlP2phtkgE9kjQJqrtRZ
|
||||||
|
fago/IeVI/sKlApDxA==
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
};
|
||||||
|
|
||||||
describe('HttpsTunnelProxy', () => {
|
describe('HttpsTunnelProxy', () => {
|
||||||
let tunnel: HttpsTunnelProxy | null = null;
|
let tunnel: HttpsTunnelProxy | null = null;
|
||||||
let mockServer: https.Server | null = null;
|
let mockServer: https.Server | null = null;
|
||||||
let mockServerPort: number = 0;
|
let mockServerPort: number = 0;
|
||||||
|
|
||||||
// Self-signed certificate for testing (generated inline)
|
|
||||||
const selfSignedCert = {
|
|
||||||
key: `-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7MJnE6J3cELkN
|
|
||||||
k4Jn0HmkF1K9VvWHzQp3F5EqOPvO5d7X5qQvQFv1M8UY8LZI9u5X5T0FJ3XKQK9F
|
|
||||||
1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890
|
|
||||||
-----END PRIVATE KEY-----`,
|
|
||||||
cert: `-----BEGIN CERTIFICATE-----
|
|
||||||
MIIC+TCCAeGgAwIBAgIJAKHBfpegQr2EMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV
|
|
||||||
BAMMCGxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBMx
|
|
||||||
-----END CERTIFICATE-----`,
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
// Clean up tunnel
|
// Clean up tunnel
|
||||||
if (tunnel) {
|
if (tunnel) {
|
||||||
@@ -295,62 +334,83 @@ BAMMCGxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBMx
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
it('should handle upstream timeout', async () => {
|
it(
|
||||||
// Create a server that never responds to trigger timeout
|
'should handle upstream timeout',
|
||||||
const hangingServer = http.createServer(() => {
|
async () => {
|
||||||
// Never respond - let it hang
|
// Create an HTTPS server that accepts connections but delays response
|
||||||
});
|
// beyond the tunnel's timeout. This tests the socket-level timeout.
|
||||||
|
const slowServer = https.createServer(
|
||||||
|
{ key: TEST_CERT.key, cert: TEST_CERT.cert },
|
||||||
|
(req, res) => {
|
||||||
|
// Delay response beyond tunnel timeout (500ms)
|
||||||
|
// The tunnel should timeout before this completes
|
||||||
|
setTimeout(() => {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end('too late');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
hangingServer.listen(0, '127.0.0.1', () => resolve());
|
slowServer.listen(0, '127.0.0.1', () => resolve());
|
||||||
});
|
|
||||||
|
|
||||||
const hangingPort = (hangingServer.address() as AddressInfo).port;
|
|
||||||
|
|
||||||
try {
|
|
||||||
tunnel = new HttpsTunnelProxy({
|
|
||||||
remoteHost: '127.0.0.1',
|
|
||||||
remotePort: hangingPort,
|
|
||||||
timeoutMs: 100, // Very short timeout
|
|
||||||
allowSelfSigned: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = await tunnel.start();
|
const slowPort = (slowServer.address() as AddressInfo).port;
|
||||||
|
|
||||||
// Make request - should timeout
|
try {
|
||||||
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
tunnel = new HttpsTunnelProxy({
|
||||||
const req = http.request(
|
remoteHost: '127.0.0.1',
|
||||||
{
|
remotePort: slowPort,
|
||||||
hostname: '127.0.0.1',
|
timeoutMs: 500, // Short timeout - server responds after 2000ms
|
||||||
port,
|
allowSelfSigned: true,
|
||||||
path: '/v1/messages',
|
});
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
},
|
|
||||||
resolve
|
|
||||||
);
|
|
||||||
req.on('error', reject);
|
|
||||||
req.setTimeout(5000);
|
|
||||||
req.write('{}');
|
|
||||||
req.end();
|
|
||||||
}).catch((err) => err);
|
|
||||||
|
|
||||||
// Either 502 response or error is acceptable
|
const port = await tunnel.start();
|
||||||
expect(response).toBeDefined();
|
|
||||||
} finally {
|
// Make request - should timeout because server delays 2s but tunnel times out at 500ms
|
||||||
hangingServer.close();
|
const response = await new Promise<http.IncomingMessage | Error>((resolve, reject) => {
|
||||||
}
|
const req = http.request(
|
||||||
});
|
{
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path: '/v1/messages',
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
resolve
|
||||||
|
);
|
||||||
|
req.on('error', (err) => resolve(err)); // Resolve with error instead of reject
|
||||||
|
req.setTimeout(3000); // Client timeout longer than tunnel timeout
|
||||||
|
req.write('{}');
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Either 502 response or connection error is acceptable
|
||||||
|
// The tunnel should have timed out and returned 502 or closed the connection
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
if (response instanceof http.IncomingMessage) {
|
||||||
|
expect(response.statusCode).toBe(502);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
slowServer.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
|
||||||
it('should handle client disconnect (premature close)', async () => {
|
it('should handle client disconnect (premature close)', async () => {
|
||||||
// Create a slow server that holds connection
|
// Create a slow HTTPS server that holds connection
|
||||||
const slowServer = http.createServer((req, res) => {
|
// HttpsTunnelProxy uses https.request(), so we need an HTTPS server
|
||||||
// Wait before responding
|
const slowServer = https.createServer(
|
||||||
setTimeout(() => {
|
{ key: TEST_CERT.key, cert: TEST_CERT.cert },
|
||||||
res.writeHead(200);
|
(req, res) => {
|
||||||
res.end('ok');
|
// Wait before responding
|
||||||
}, 2000);
|
setTimeout(() => {
|
||||||
});
|
res.writeHead(200);
|
||||||
|
res.end('ok');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
slowServer.listen(0, '127.0.0.1', () => resolve());
|
slowServer.listen(0, '127.0.0.1', () => resolve());
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Tests for uploadTokenToRemote and related functions.
|
* Tests for uploadTokenToRemote and related functions.
|
||||||
* Uses a local HTTP server to mock the remote CLIProxyAPI.
|
* Uses a local HTTP server to mock the remote CLIProxyAPI.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|||||||
Reference in new issue
Block a user