Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/testing/helpers.ts
1028 views
1
import * as Fs from 'fs';
2
import * as Path from 'path';
3
import * as Url from 'url';
4
import { URL } from 'url';
5
import * as querystring from 'querystring';
6
import * as http from 'http';
7
import { IncomingMessage, RequestListener, Server } from 'http';
8
import * as https from 'https';
9
import { Agent } from 'https';
10
import { createPromise } from '@secret-agent/commons/utils';
11
import * as HttpProxyAgent from 'http-proxy-agent';
12
import * as HttpsProxyAgent from 'https-proxy-agent';
13
import * as Koa from 'koa';
14
import * as KoaRouter from '@koa/router';
15
import * as KoaMulter from '@koa/multer';
16
import * as net from 'net';
17
import * as tls from 'tls';
18
import * as http2 from 'http2';
19
import * as stream from 'stream';
20
import Core, { CoreProcess } from '@secret-agent/core';
21
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
22
import MitmSocket from '@secret-agent/mitm-socket';
23
import MitmSocketSession from '@secret-agent/mitm-socket/lib/MitmSocketSession';
24
import { Helpers } from './index';
25
26
export const needsClosing: { close: () => Promise<any> | void; onlyCloseOnFinal?: boolean }[] = [];
27
28
export interface ITestKoaServer extends KoaRouter {
29
close: () => void;
30
server: http.Server;
31
koa: Koa;
32
isClosing?: boolean;
33
onlyCloseOnFinal?: boolean;
34
baseHost: string;
35
baseUrl: string;
36
upload: KoaMulter.Instance;
37
}
38
export interface ITestHttpServer<T> {
39
isClosing: boolean;
40
onlyCloseOnFinal: boolean;
41
url: string;
42
port: number;
43
baseUrl: string;
44
close: () => Promise<any>;
45
on: (eventName: string, fn: (...args: any[]) => void) => any;
46
server: T;
47
}
48
49
export async function runKoaServer(onlyCloseOnFinal = true): Promise<ITestKoaServer> {
50
const koa = new Koa();
51
const router = new KoaRouter() as ITestKoaServer;
52
const exampleOrgPath = Path.join(__dirname, 'html', 'example.org.html');
53
const exampleOrgHtml = Fs.readFileSync(exampleOrgPath, 'utf-8');
54
const upload = KoaMulter(); // note you can pass `multer` options here
55
56
koa.use(router.routes()).use(router.allowedMethods());
57
58
const server = await new Promise<Server>(resolve => {
59
const koaServer = koa
60
.listen(() => {
61
resolve(koaServer);
62
})
63
.unref();
64
});
65
66
const destroyer = destroyServerFn(server);
67
68
const port = (server.address() as net.AddressInfo).port;
69
router.baseHost = `localhost:${port}`;
70
router.baseUrl = `http://${router.baseHost}`;
71
72
router.get('/', ctx => {
73
ctx.body = exampleOrgHtml;
74
});
75
76
router.close = () => {
77
if (router.isClosing) {
78
return;
79
}
80
router.isClosing = true;
81
return destroyer();
82
};
83
router.onlyCloseOnFinal = onlyCloseOnFinal;
84
needsClosing.push(router);
85
router.koa = koa;
86
router.server = server;
87
router.upload = upload;
88
89
return router;
90
}
91
92
export function sslCerts() {
93
return {
94
key: Fs.readFileSync(`${__dirname}/certs/key.pem`),
95
cert: Fs.readFileSync(`${__dirname}/certs/cert.pem`),
96
};
97
}
98
99
export async function runHttpsServer(
100
handler: RequestListener,
101
onlyCloseOnFinal = false,
102
): Promise<ITestHttpServer<https.Server>> {
103
const options = {
104
...sslCerts(),
105
};
106
107
const server = https.createServer(options, handler).listen(0).unref();
108
await new Promise(resolve => server.once('listening', resolve));
109
110
const destroyServer = destroyServerFn(server);
111
112
bindSslListeners(server);
113
const port = (server.address() as net.AddressInfo).port;
114
const baseUrl = `https://localhost:${port}`;
115
const httpServer: ITestHttpServer<https.Server> = {
116
isClosing: false,
117
on(eventName, fn) {
118
server.on(eventName, fn);
119
},
120
close(): Promise<void> {
121
if (httpServer.isClosing) {
122
return null;
123
}
124
httpServer.isClosing = true;
125
return destroyServer();
126
},
127
onlyCloseOnFinal,
128
baseUrl,
129
url: `${baseUrl}/`,
130
port,
131
server,
132
};
133
134
needsClosing.push(httpServer);
135
136
return httpServer;
137
}
138
139
export async function runHttpServer(
140
params: {
141
onRequest?: (url: string, method: string, headers: http.IncomingHttpHeaders) => void;
142
onPost?: (body: string) => void;
143
addToResponse?: (response: http.ServerResponse) => void;
144
onlyCloseOnFinal?: boolean;
145
} = {},
146
): Promise<ITestHttpServer<http.Server>> {
147
const { onRequest, onPost, addToResponse } = params;
148
const server = http.createServer().unref();
149
const destroyServer = destroyServerFn(server);
150
server.on('request', async (request, response) => {
151
if (onRequest) onRequest(request.url, request.method, request.headers);
152
if (addToResponse) addToResponse(response);
153
154
let pageBody = 'Hello';
155
const requestUrl = Url.parse(request.url);
156
if (requestUrl.pathname === '/') {
157
return response.end(`<html><head></head><body>Hello world</body></html>`);
158
}
159
if (requestUrl.pathname === '/page1') {
160
if (request.method === 'OPTIONS') {
161
response.writeHead(200, {
162
'Access-Control-Allow-Origin': '*',
163
'Access-Control-Allow-Methods': 'GET',
164
'Access-Control-Allow-Headers': 'X-Custom-Header',
165
});
166
return response.end('');
167
}
168
return response.end(
169
`<html><head></head><body>
170
<form action="/page2" method="post"><input type="text" id="input" name="thisText"/><input type="submit" id="submit-button" name="submit"/></form>
171
</body></html>`,
172
);
173
}
174
if (requestUrl.pathname === '/page2' && request.method === 'POST') {
175
let body = '';
176
for await (const chunk of request) {
177
body += chunk;
178
}
179
// eslint-disable-next-line no-shadow,@typescript-eslint/no-shadow
180
const params = querystring.parse(body);
181
pageBody = params.thisText as string;
182
if (onPost) onPost(params.thisText as string);
183
}
184
response.end(`<html><head></head><body>${pageBody}</body></html>`);
185
});
186
server.listen();
187
await new Promise(resolve => server.once('listening', resolve));
188
const port = (server.address() as net.AddressInfo).port;
189
190
const baseUrl = `http://localhost:${port}`;
191
const httpServer: ITestHttpServer<http.Server> = {
192
isClosing: false,
193
onlyCloseOnFinal: params.onlyCloseOnFinal ?? false,
194
on(eventName, fn) {
195
server.on(eventName, fn);
196
},
197
close() {
198
if (httpServer.isClosing) {
199
return null;
200
}
201
httpServer.isClosing = true;
202
return destroyServer();
203
},
204
baseUrl,
205
url: `${baseUrl}/`,
206
port,
207
server,
208
};
209
210
needsClosing.push(httpServer);
211
212
return httpServer;
213
}
214
215
export function httpRequest(
216
urlStr: string,
217
method: string,
218
proxyHost: string,
219
proxyAuth?: string,
220
headers: { [name: string]: string } = {},
221
response?: (res: IncomingMessage) => any,
222
postData?: Buffer,
223
): Promise<string> {
224
const createdPromise = createPromise();
225
const { promise, resolve, reject } = createdPromise;
226
const url = new URL(urlStr);
227
const urlPort = extractPort(url);
228
const urlPath = [url.pathname, url.search].join('');
229
const options: any = {
230
host: url.hostname,
231
port: urlPort,
232
method,
233
path: urlPath,
234
headers: headers || {},
235
rejectUnauthorized: false,
236
};
237
238
if (proxyHost) {
239
options.agent = getProxyAgent(url, proxyHost, proxyAuth);
240
}
241
242
const client = url.protocol === 'https:' ? https : http;
243
const req = client.request(options, (res): void => {
244
if (createdPromise.isResolved) return;
245
let data = '';
246
if (response) response(res);
247
res.on('end', () => resolve(data));
248
res.on('data', chunk => (data += chunk));
249
});
250
req.on('error', reject);
251
if (postData) req.write(postData);
252
req.end();
253
254
return promise;
255
}
256
257
export function getProxyAgent(url: URL, proxyHost: string, auth?: string): Agent {
258
const ProxyAgent = url.protocol === 'https:' ? HttpsProxyAgent : HttpProxyAgent;
259
const opts = Url.parse(proxyHost);
260
opts.auth = auth;
261
return ProxyAgent(opts);
262
}
263
264
export function httpGet(
265
urlStr: string,
266
proxyHost: string,
267
proxyAuth?: string,
268
headers: { [name: string]: string } = {},
269
) {
270
return httpRequest(urlStr, 'GET', proxyHost, proxyAuth, headers);
271
}
272
273
export async function http2Get(
274
host: string,
275
headers: { [':path']: string; [name: string]: string },
276
sessionId: string,
277
proxyUrl?: string,
278
): Promise<string> {
279
const hostUrl = new URL(host);
280
const socketSession = new MitmSocketSession(sessionId, {
281
clientHelloId: 'Chrome79',
282
rejectUnauthorized: false,
283
});
284
Helpers.needsClosing.push(socketSession);
285
286
const tlsConnection = getTlsConnection(
287
Number(hostUrl.port ?? 443),
288
hostUrl.hostname,
289
false,
290
proxyUrl,
291
);
292
Helpers.onClose(() => tlsConnection.close());
293
await tlsConnection.connect(socketSession);
294
295
const client = http2.connect(host, {
296
createConnection: () => tlsConnection.socket,
297
});
298
Helpers.onClose(() => client.close());
299
const responseStream = await client.request(headers);
300
await new Promise(resolve => responseStream.once('response', resolve));
301
return (await readableToBuffer(responseStream)).toString();
302
}
303
304
export async function runHttp2Server(
305
handler: (request: http2.Http2ServerRequest, response: http2.Http2ServerResponse) => void,
306
): Promise<ITestHttpServer<http2.Http2SecureServer>> {
307
const h2ServerStarted = createPromise();
308
const sessions = new Set<http2.ServerHttp2Session>();
309
const server = http2
310
.createSecureServer(sslCerts(), handler)
311
.unref()
312
.listen(0, () => {
313
h2ServerStarted.resolve();
314
});
315
bindSslListeners(server);
316
server.on('session', session => {
317
sessions.add(session);
318
});
319
await h2ServerStarted.promise;
320
const port = (server.address() as net.AddressInfo).port;
321
322
const baseUrl = `https://localhost:${port}`;
323
const httpServer: ITestHttpServer<http2.Http2SecureServer> = {
324
isClosing: false,
325
onlyCloseOnFinal: false,
326
on(eventName, fn) {
327
server.on(eventName, fn);
328
},
329
close() {
330
if (httpServer.isClosing) {
331
return null;
332
}
333
httpServer.isClosing = true;
334
for (const session of sessions) {
335
session.socket?.unref();
336
session.destroy();
337
}
338
return new Promise(resolve => {
339
server.close(() => setTimeout(resolve, 10));
340
});
341
},
342
baseUrl,
343
url: `${baseUrl}/`,
344
port,
345
server,
346
};
347
needsClosing.push(httpServer);
348
return httpServer;
349
}
350
351
export function httpGetWithSocket(
352
url: string,
353
clientOptions: https.RequestOptions,
354
socket: net.Socket,
355
): Promise<string> {
356
return new Promise<string>((resolve, reject) => {
357
let isResolved = false;
358
socket.once('close', () => {
359
if (isResolved) return;
360
reject(new Error('Socket closed before resolve'));
361
});
362
socket.once('error', err => {
363
if (isResolved) return;
364
reject(err);
365
});
366
const request = https.get(
367
url,
368
{
369
...clientOptions,
370
agent: null,
371
createConnection: () => socket,
372
},
373
async res => {
374
isResolved = true;
375
const buffer = await readableToBuffer(res);
376
resolve(buffer.toString('utf8'));
377
},
378
);
379
request.on('error', err => {
380
if (isResolved) return;
381
reject(err);
382
});
383
});
384
}
385
386
let sessionId = 0;
387
388
export function getTlsConnection(
389
serverPort: number,
390
host = 'localhost',
391
isWebsocket = false,
392
proxyUrl?: string,
393
): MitmSocket {
394
const tlsConnection = new MitmSocket(`session${(sessionId += 1)}`, {
395
host,
396
port: String(serverPort),
397
servername: host,
398
isWebsocket,
399
isSsl: true,
400
proxyUrl,
401
});
402
Helpers.onClose(() => tlsConnection.close());
403
return tlsConnection;
404
}
405
406
export function getLogo(): Buffer {
407
return Fs.readFileSync(`${__dirname}/html/img.png`);
408
}
409
410
export async function readableToBuffer(res: stream.Readable): Promise<Buffer> {
411
const buffer: Buffer[] = [];
412
for await (const data of res) {
413
buffer.push(data);
414
}
415
return Buffer.concat(buffer);
416
}
417
418
export function afterEach(): Promise<void> {
419
return closeAll(false);
420
}
421
422
export async function afterAll(): Promise<void> {
423
await closeAll(true);
424
await Core.shutdown(true);
425
await CoreProcess.kill();
426
}
427
428
async function closeAll(isFinal = false): Promise<void> {
429
const closeList = [...needsClosing];
430
needsClosing.length = 0;
431
432
await Promise.all(
433
closeList.map(async (toClose, i) => {
434
if (!toClose.close) {
435
// eslint-disable-next-line no-console
436
console.log('Error closing', { closeIndex: i });
437
return;
438
}
439
if (toClose.onlyCloseOnFinal && !isFinal) {
440
needsClosing.push(toClose);
441
return;
442
}
443
444
try {
445
await toClose.close();
446
} catch (err) {
447
if (err instanceof CanceledPromiseError) return;
448
// eslint-disable-next-line no-console
449
console.log('Error shutting down', err);
450
}
451
}),
452
);
453
}
454
455
function bindSslListeners(server: tls.Server): void {
456
if (process.env.SSLKEYLOGFILE) {
457
const logFile = Fs.createWriteStream(process.env.SSLKEYLOGFILE, { flags: 'a' });
458
server.on('keylog', line => logFile.write(line));
459
}
460
}
461
462
export function onClose(closeFn: (() => Promise<any>) | (() => any), onlyCloseOnFinal = false) {
463
needsClosing.push({ close: closeFn, onlyCloseOnFinal });
464
}
465
466
function extractPort(url: URL) {
467
if (url.port) return url.port;
468
if (url.protocol === 'https:') return 443;
469
return 80;
470
}
471
472
function destroyServerFn(
473
server: http.Server | http2.Http2Server | https.Server,
474
): () => Promise<void> {
475
const connections = new Set<net.Socket>();
476
477
server.on('connection', conn => {
478
connections.add(conn);
479
conn.on('close', () => connections.delete(conn));
480
});
481
482
return () =>
483
new Promise(resolve => {
484
for (const conn of connections) {
485
conn.destroy();
486
}
487
server.close(() => {
488
setTimeout(resolve, 10);
489
});
490
});
491
}
492
493