-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathhttp-caching-proxy.integration.ts
More file actions
327 lines (282 loc) · 9.26 KB
/
Copy pathhttp-caching-proxy.integration.ts
File metadata and controls
327 lines (282 loc) · 9.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved.
// Node module: @loopback/http-caching-proxy
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
import {expect} from '@loopback/testlab';
import axios, {
AxiosProxyConfig,
AxiosRequestConfig,
AxiosResponse,
} from 'axios';
import delay from 'delay';
import {once} from 'node:events';
import http from 'node:http';
import {AddressInfo} from 'node:net';
import path from 'node:path';
import {URL} from 'node:url';
import {rimraf} from 'rimraf';
import tunnel, {ProxyOptions as TunnelProxyOptions} from 'tunnel';
import {HttpCachingProxy, ProxyOptions} from '../../http-caching-proxy';
const CACHE_DIR = path.join(__dirname, '.cache');
describe('HttpCachingProxy', () => {
let stubServerUrl: string;
before(givenStubServer);
after(stopStubServer);
let proxy: HttpCachingProxy;
after(stopProxy);
beforeEach('clean cache dir', async () => rimraf(CACHE_DIR));
it('provides "url" property when running', async () => {
await givenRunningProxy();
expect(proxy.url).to.match(/^http:\/\/127.0.0.1:\d+$/);
});
it('provides invalid "url" property when not running', async () => {
proxy = new HttpCachingProxy({cachePath: CACHE_DIR});
expect(proxy.url).to.match(/not-running/);
});
it('proxies HTTP requests', async function (this: Mocha.Context) {
// Increase the timeout to accommodate slow network connections
this.timeout(30000);
await givenRunningProxy();
const result = await makeRequest({
url: 'http://example.com',
});
expect(result.statusCode).to.equal(200);
expect(result.body).to.containEql('example');
});
it('reports error for HTTP requests', async function (this: Mocha.Context) {
// Increase the timeout to accommodate slow network connections
this.timeout(30000);
await givenRunningProxy({logError: false});
await expect(
makeRequest({
url: 'http://does-not-exist.example.com',
}),
).to.be.rejectedWith(
// The error can be
// '502 - "Error: getaddrinfo EAI_AGAIN does-not-exist.example.com:80"'
// '502 - "Error: getaddrinfo ENOTFOUND does-not-exist.example.com'
/502 - "Error: getaddrinfo/,
);
});
it('reports timeout error for HTTP requests', async function () {
await givenRunningProxy({logError: false, timeout: 1});
await expect(
makeRequest({
url: 'http://www.mocky.io/v2/5dade5e72d0000a542e4bd9c?mocky-delay=1000ms',
}),
).to.be.rejectedWith(/502 - "AxiosError: timeout of 1ms exceeded/);
});
it('proxies HTTPs requests (no tunneling)', async function (this: Mocha.Context) {
// Increase the timeout to accommodate slow network connections
this.timeout(30000);
// Disable SSL validation for this test to avoid certificate issues
// with example.com in different Node.js versions and environments
await givenRunningProxy({rejectUnauthorized: false});
const result = await makeRequest({
url: 'https://example.com',
});
expect(result.statusCode).to.equal(200);
expect(result.body).to.containEql('example');
});
it('rejects CONNECT requests (HTTPS tunneling)', async () => {
await givenRunningProxy();
const agent = tunnel.httpsOverHttp({
proxy: getTunnelProxyConfig(proxy.url),
});
const resultPromise = makeRequest({
url: 'https://example.com',
httpsAgent: agent,
proxy: false,
});
await expect(resultPromise).to.be.rejectedWith(
/tunneling socket could not be established, statusCode=501/,
);
});
it('forwards request/response headers', async () => {
await givenRunningProxy();
givenServerDumpsRequests();
const result = await makeRequest({
url: stubServerUrl,
responseType: 'json',
headers: {'x-client': 'test'},
});
expect(result.headers).to.containEql({
'x-server': 'dumping-server',
});
expect(result.body.headers).to.containDeep({
'x-client': 'test',
});
});
it('forwards request body', async () => {
await givenRunningProxy();
stubServerHandler = (req, res) => req.pipe(res);
const result = await makeRequest({
method: 'POST',
url: stubServerUrl,
data: 'a text body',
});
expect(result.body).to.equal('a text body');
});
it('caches responses', async () => {
await givenRunningProxy();
let counter = 1;
stubServerHandler = function (req, res) {
res.writeHead(201, {'x-counter': counter++});
res.end(JSON.stringify({counter: counter++}));
};
const opts: AxiosRequestConfig = {
url: stubServerUrl,
responseType: 'json',
};
const result1 = await makeRequest(opts);
const result2 = await makeRequest(opts);
expect(result1.statusCode).equal(201);
expect(result1.statusCode).equal(result2.statusCode);
expect(result1.body).deepEqual(result2.body);
expect(result1.headers).deepEqual(result2.headers);
});
it('refreshes expired cache entries', async () => {
await givenRunningProxy({ttl: 1});
let counter = 1;
stubServerHandler = (req, res) => res.end(String(counter++));
const opts = {
url: stubServerUrl,
};
const result1 = await makeRequest(opts);
await delay(10);
const result2 = await makeRequest(opts);
expect(result1.body).to.equal(1);
expect(result2.body).to.equal(2);
});
it('handles the case where backend service is not running', async function (this: Mocha.Context) {
// This test takes a bit longer to finish on windows.
this.timeout(3000);
await givenRunningProxy({logError: false});
await expect(makeRequest({url: 'http://127.0.0.1:1/'})).to.be.rejectedWith({
status: 502,
});
});
function givenRunningProxy(options?: Partial<ProxyOptions>) {
proxy = new HttpCachingProxy(
Object.assign({cachePath: CACHE_DIR}, options),
);
return proxy.start();
}
async function stopProxy() {
if (!proxy) return;
await proxy.stop();
}
/**
* Parse an url to `tunnel` proxy options
* @param url - proxy url string
*/
function getTunnelProxyConfig(url: string): TunnelProxyOptions {
const parsed = new URL(url);
const options: TunnelProxyOptions = {
host: parsed.hostname,
port: parseInt(parsed.port),
};
if (parsed.username) {
options.proxyAuth = `${parsed.username}:${parsed.password}`;
}
return options;
}
/**
* Parse an url to Axios proxy configuration object
* @param url - proxy url string
*/
function getProxyConfig(url: string): AxiosProxyConfig {
const parsed = new URL(url);
return {
protocol: parsed.protocol,
host: parsed.hostname,
port: parseInt(parsed.port),
...(parsed.username && {
auth: {
username: parsed.username,
password: parsed.password,
},
}),
};
}
const axiosInstance = axios.create({
// Provide a custom function to control when Axios throws errors based on
// http status code. Please note that Axios creates a new error in such
// condition and the original low-level error is lost
validateStatus: () => true,
});
/**
* Helper method to make a http request via the proxy
* @param config - Axios request
*/
async function makeRequest(config: AxiosRequestConfig) {
config = {
proxy: getProxyConfig(proxy.url),
...config,
};
const res = await axiosInstance(config);
// Throw an error with message from the original error
if (res.status >= 300) {
const errData = JSON.stringify(res.data);
const err = new Error(`${res.status} - ${errData}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(err as any).status = res.status;
throw err;
}
const patchedRes = Object.create(res) as AxiosResponse & {
statusCode: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: any;
};
patchedRes.statusCode = res.status;
patchedRes.body = res.data;
return patchedRes;
}
let stubServer: http.Server | undefined,
stubServerHandler:
| ((request: http.IncomingMessage, response: http.ServerResponse) => void)
| undefined;
async function givenStubServer() {
stubServerHandler = undefined;
stubServer = http.createServer(function handleRequest(req, res) {
if (stubServerHandler) {
try {
stubServerHandler(req, res);
} catch (err) {
res.end(500);
process.nextTick(() => {
throw err;
});
}
} else {
res.writeHead(501);
res.end();
}
});
stubServer.listen(0);
await once(stubServer, 'listening');
const address = stubServer.address() as AddressInfo;
stubServerUrl = `http://127.0.0.1:${address.port}`;
}
async function stopStubServer() {
if (!stubServer) return;
stubServer.close();
await once(stubServer, 'close');
stubServer = undefined;
}
function givenServerDumpsRequests() {
stubServerHandler = function dumpRequest(req, res) {
res.writeHead(200, {
'x-server': 'dumping-server',
});
res.write(
JSON.stringify({
method: req.method,
url: req.url,
headers: req.headers,
}),
);
res.end();
};
}
});