• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

andrzej-woof / stuntman / 8908908344

01 May 2024 11:45AM UTC coverage: 81.27%. Remained the same
8908908344

push

github

andrzej-woof
fix module resolution

611 of 668 branches covered (91.47%)

Branch coverage included in aggregate %.

2218 of 2813 relevant lines covered (78.85%)

13.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.85
/packages/server/src/mock.ts
1
import { request as fetchRequest } from 'undici';
1✔
2
import type { Dispatcher } from 'undici';
1✔
3
import http from 'http';
1✔
4
import https from 'https';
1✔
5
import express from 'express';
1✔
6
import { v4 as uuidv4 } from 'uuid';
1✔
7
import { getRuleExecutor } from './ruleExecutor';
1✔
8
import { getTrafficStore } from './storage';
1✔
9
import { RawHeaders, logger, HttpCode, naiveGQLParser, escapeStringRegexp, errorToLog, HTTP_METHODS } from '@stuntman/shared';
1✔
10
import { RequestContext } from './requestContext';
1✔
11
import type * as Stuntman from '@stuntman/shared';
1✔
12
import { IPUtils } from './ipUtils';
1✔
13
import { LRUCache } from 'lru-cache';
1✔
14
import { API } from './api/api';
1✔
15

1✔
16
// TODO add proper web proxy mode
1✔
17

1✔
18
export class Mock {
1✔
19
    public readonly mockUuid: string;
18✔
20
    protected options: Stuntman.Config;
18✔
21
    protected mockApp: express.Express | null = null;
18✔
22
    protected MOCK_DOMAIN_REGEX: RegExp;
18✔
23
    protected URL_PORT_REGEX: RegExp;
18✔
24
    protected server: http.Server | null = null;
18✔
25
    protected serverHttps: https.Server | null = null;
18✔
26
    protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
18✔
27
    protected ipUtils: IPUtils | null = null;
18✔
28
    private _api: API | null = null;
18✔
29

18✔
30
    protected get apiServer() {
18✔
31
        if (this.options.api.disabled) {
7✔
32
            return null;
1✔
33
        }
1✔
34
        if (!this._api) {
7✔
35
            this._api = new API({ ...this.options.api, mockUuid: this.mockUuid }, this.options.webgui);
2✔
36
        }
2✔
37
        return this._api;
6✔
38
    }
6✔
39

18✔
40
    public get ruleExecutor(): Stuntman.RuleExecutorInterface {
18✔
41
        return getRuleExecutor(this.mockUuid);
2✔
42
    }
2✔
43

18✔
44
    constructor(options: Stuntman.Config) {
18✔
45
        this.mockUuid = uuidv4();
18✔
46
        this.options = options;
18✔
47
        if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
18✔
48
            throw new Error('missing https key/cert');
2✔
49
        }
2✔
50

16✔
51
        this.MOCK_DOMAIN_REGEX = new RegExp(
16✔
52
            `(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain}(https?)?)|(?:localhost))(:${this.options.mock.port}${
16✔
53
                this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
18✔
54
            })?(?:\\b|$)`,
18✔
55
            'i'
18✔
56
        );
18✔
57
        this.URL_PORT_REGEX = new RegExp(
18✔
58
            `^(https?:\\/\\/[^:/]+):(?:${this.options.mock.port}${
18✔
59
                this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
18✔
60
            })(\\/.*)`,
18✔
61
            'i'
18✔
62
        );
18✔
63
        this.trafficStore = getTrafficStore(this.mockUuid, this.options.storage.traffic);
18✔
64
        this.ipUtils =
18✔
65
            !this.options.mock.externalDns || this.options.mock.externalDns.length === 0
18✔
66
                ? null
18✔
67
                : new IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
18✔
68

18✔
69
        this.requestHandler = this.requestHandler.bind(this);
18✔
70
        this.bindRequestContext = this.bindRequestContext.bind(this);
18✔
71
        this.errorHandler = this.errorHandler.bind(this);
18✔
72
    }
18✔
73

18✔
74
    private extractJwt(req: Stuntman.Request): any {
18✔
75
        try {
2✔
76
            const authorizationHeaderIndex = req.rawHeaders.findIndex((header) => header.toLowerCase() === 'authorization');
2✔
77
            if (authorizationHeaderIndex < 0) {
2✔
78
                return { result: false, description: 'missing authorization header' };
2✔
79
            }
2✔
80
            const authorizationHeaderValue = req.rawHeaders[authorizationHeaderIndex + 1];
×
81
            const token =
×
82
                authorizationHeaderValue &&
×
83
                (authorizationHeaderValue.startsWith('Bearer ')
×
84
                    ? authorizationHeaderValue.split(' ')[1]
×
85
                    : authorizationHeaderValue);
2✔
86
            if (!token) {
2!
87
                return undefined;
×
88
            }
×
89
            const base64Url = token.split('.')[1];
×
90
            if (!base64Url) {
×
91
                return undefined;
×
92
            }
×
93
            const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
×
94
            const jsonPayload = decodeURIComponent(
×
95
                Buffer.from(base64, 'base64')
×
96
                    .toString('ascii')
×
97
                    .split('')
×
98
                    .map(function (c) {
×
99
                        return `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`;
×
100
                    })
×
101
                    .join('')
×
102
            );
×
103
            return JSON.parse(jsonPayload);
×
104
        } catch (error) {
×
105
            // TODO
×
106
        }
×
107
        return undefined;
×
108
    }
×
109

18✔
110
    private async requestHandler(req: express.Request, res: express.Response): Promise<void> {
18✔
111
        const ctx: RequestContext | null = RequestContext.get(req);
2✔
112
        const requestUuid = ctx?.uuid || uuidv4();
2!
113
        const timestamp = Date.now();
2✔
114
        const originalHostname = req.headers.host || req.hostname;
2✔
115
        const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
2✔
116
        const isProxiedHostname = originalHostname !== unproxiedHostname;
2✔
117
        const originalRequest: Stuntman.Request = {
2✔
118
            id: requestUuid,
2✔
119
            timestamp,
2✔
120
            url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
2✔
121
            method: req.method.toUpperCase() as Stuntman.HttpMethod,
2✔
122
            rawHeaders: new RawHeaders(...req.rawHeaders),
2✔
123
            ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
2!
124
                (typeof req.body === 'string' && { body: req.body })),
2!
125
        };
2✔
126
        logger.debug(originalRequest, 'processing request');
2✔
127
        const logContext: Record<string, any> = {
2✔
128
            requestId: originalRequest.id,
2✔
129
        };
2✔
130
        const mockEntry: Stuntman.WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
2✔
131
            originalRequest,
2✔
132
            modifiedRequest: {
2✔
133
                ...this.unproxyRequest(req),
2✔
134
                id: requestUuid,
2✔
135
                timestamp,
2✔
136
                ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
2!
137
                jwt: this.extractJwt(originalRequest),
2✔
138
            },
2✔
139
        };
2✔
140
        if (!isProxiedHostname) {
2✔
141
            this.removeProxyPort(mockEntry.modifiedRequest);
2✔
142
        }
2✔
143
        const matchingRule = await this.ruleExecutor.findMatchingRule(mockEntry.modifiedRequest);
2✔
144
        if (matchingRule) {
2✔
145
            mockEntry.mockRuleId = matchingRule.id;
2✔
146
            mockEntry.labels = matchingRule.labels;
2✔
147
            if (matchingRule.actions.mockResponse) {
2✔
148
                const staticResponse =
1✔
149
                    typeof matchingRule.actions.mockResponse === 'function'
1✔
150
                        ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
1✔
151
                        : matchingRule.actions.mockResponse;
1!
152
                mockEntry.modifiedResponse = staticResponse;
1✔
153
                logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
1✔
154
                if (matchingRule.storeTraffic) {
1!
155
                    this.trafficStore.set(requestUuid, mockEntry);
×
156
                }
×
157
                if (staticResponse.rawHeaders) {
1!
158
                    for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
×
159
                        res.setHeader(header[0], header[1]);
×
160
                    }
×
161
                }
×
162
                res.status(staticResponse.status || 200);
1!
163
                res.send(staticResponse.body);
1✔
164
                // static response blocks any further processing
1✔
165
                return;
1✔
166
            }
1✔
167
            if (matchingRule.actions.modifyRequest) {
2!
168
                mockEntry.modifiedRequest = matchingRule.actions.modifyRequest(mockEntry.modifiedRequest);
×
169
                logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
×
170
            }
×
171
        }
2✔
172
        if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
2✔
173
            const hostname = originalHostname.split(':')[0]!;
1✔
174
            try {
1✔
175
                const internalIPs = await this.ipUtils.resolveIP(hostname);
1✔
176
                if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
1!
177
                    const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
×
178
                    logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
×
179
                    mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(
×
180
                        /^(https?:\/\/)[^:/]+/i,
×
181
                        `$1${externalIPs}`
×
182
                    );
×
183
                }
×
184
            } catch (error) {
1!
185
                // swallow the exeception, don't think much can be done at this point
×
186
                logger.warn({ ...logContext, error: errorToLog(error as Error) }, `error trying to resolve IP for "${hostname}"`);
×
187
            }
×
188
        }
1✔
189

×
190
        const originalResponse: Required<Stuntman.Response> = this.options.mock.disableProxy
×
191
            ? {
×
192
                  timestamp: Date.now(),
×
193
                  body: undefined,
×
194
                  rawHeaders: new RawHeaders(),
×
195
                  status: 404,
×
196
              }
×
197
            : await this.proxyRequest(req, mockEntry, logContext);
2!
198

×
199
        logger.debug({ ...logContext, originalResponse }, 'received response');
×
200
        mockEntry.originalResponse = originalResponse;
×
201
        let modifedResponse: Stuntman.Response = {
×
202
            ...originalResponse,
×
203
            rawHeaders: new RawHeaders(
×
204
                ...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
×
205
                    // TODO this replace may be too aggressive and doesn't handle protocol (won't be necessary with a trusted cert and mock serving http+https)
×
206
                    return [
×
207
                        key,
×
208
                        isProxiedHostname
×
209
                            ? value
×
210
                            : value.replace(
×
211
                                  new RegExp(`(?:^|\\b)(${escapeStringRegexp(unproxiedHostname)})(?:\\b|$)`, 'igm'),
×
212
                                  originalHostname
×
213
                              ),
×
214
                    ];
×
215
                })
×
216
            ),
×
217
        };
×
218
        if (matchingRule?.actions.modifyResponse) {
2!
219
            modifedResponse = matchingRule?.actions.modifyResponse(mockEntry.modifiedRequest, originalResponse);
×
220
            logger.debug({ ...logContext, modifedResponse }, 'modified response');
×
221
        }
×
222

×
223
        mockEntry.modifiedResponse = modifedResponse;
×
224
        if (matchingRule?.storeTraffic) {
2!
225
            this.trafficStore.set(requestUuid, mockEntry);
×
226
        }
×
227

×
228
        if (modifedResponse.status) {
×
229
            res.status(modifedResponse.status);
×
230
        }
×
231
        if (modifedResponse.rawHeaders) {
×
232
            for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
×
233
                // since fetch decompresses responses we need to get rid of some headers
×
234
                // TODO maybe could be handled better than just skipping, although express should add these back for new body
×
235
                // if (/^content-(?:length|encoding)$/i.test(header[0])) {
×
236
                //     continue;
×
237
                // }
×
238
                res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
×
239
            }
×
240
        }
×
241
        res.end(Buffer.from(modifedResponse.body, 'binary'));
×
242
    }
×
243

18✔
244
    private bindRequestContext(req: express.Request, _res: express.Response, next: express.NextFunction) {
18✔
245
        RequestContext.bind(req, this.mockUuid);
1✔
246
        next();
1✔
247
    }
1✔
248

18✔
249
    private errorHandler(error: Error, req: express.Request, res: express.Response) {
18✔
250
        const ctx: RequestContext | null = RequestContext.get(req);
1✔
251
        const uuid = ctx?.uuid || uuidv4();
1!
252
        logger.error({ error: errorToLog(error), uuid }, 'unexpected error');
1✔
253
        if (res) {
1✔
254
            res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
1✔
255
                error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
1✔
256
            });
1✔
257
            return;
1✔
258
        }
1✔
259
        // eslint-disable-next-line no-console
×
260
        console.error('mock server encountered a critical error. exiting');
×
261
        process.exit(1);
×
262
    }
×
263

18✔
264
    private init() {
18✔
265
        if (this.mockApp) {
2!
266
            return;
×
267
        }
×
268
        this.mockApp = express();
2✔
269
        // TODO for now request body is just a buffer passed further, not inflated
2✔
270
        this.mockApp.use(express.raw({ type: '*/*' }));
2✔
271

2✔
272
        this.mockApp.use(this.bindRequestContext);
2✔
273

2✔
274
        this.mockApp.all(/.*/, this.requestHandler);
2✔
275

2✔
276
        this.mockApp.use(this.errorHandler);
2✔
277
    }
2✔
278

18✔
279
    private async proxyRequest(
18✔
280
        req: express.Request,
1✔
281
        mockEntry: Stuntman.WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'>,
1✔
282
        logContext: any
1✔
283
    ) {
1✔
284
        let controller: AbortController | null = new AbortController();
1✔
285
        const fetchTimeout = setTimeout(() => {
1✔
286
            if (controller) {
×
287
                controller.abort(`timeout after ${this.options.mock.timeout}`);
×
288
            }
×
289
        }, this.options.mock.timeout);
1✔
290
        req.on('close', () => {
1✔
291
            logger.debug(logContext, 'remote client canceled the request');
×
292
            clearTimeout(fetchTimeout);
×
293
            if (controller) {
×
294
                controller.abort('remote client canceled the request');
×
295
            }
×
296
        });
1✔
297
        let targetResponse: Dispatcher.ResponseData;
1✔
298
        try {
1✔
299
            const requestOptions = {
1✔
300
                headers: mockEntry.modifiedRequest.rawHeaders,
1✔
301
                body: mockEntry.modifiedRequest.body,
1✔
302
                method: mockEntry.modifiedRequest.method,
1✔
303
            };
1✔
304
            logger.debug(
1✔
305
                {
1✔
306
                    ...logContext,
1✔
307
                    url: mockEntry.modifiedRequest.url,
1✔
308
                    ...requestOptions,
1✔
309
                },
1✔
310
                'outgoing request attempt'
1✔
311
            );
1✔
312
            // TODO migrate to node-libcurl
1✔
313
            targetResponse = await fetchRequest(mockEntry.modifiedRequest.url, requestOptions);
1✔
314
        } catch (error) {
×
315
            logger.error(
×
316
                { ...logContext, error: errorToLog(error as Error), request: mockEntry.modifiedRequest },
×
317
                'error fetching'
×
318
            );
×
319
            throw error;
×
320
        } finally {
×
321
            controller = null;
×
322
            clearTimeout(fetchTimeout);
×
323
        }
×
324
        const targetResponseBuffer = Buffer.from(await targetResponse.body.arrayBuffer());
×
325
        return {
×
326
            timestamp: Date.now(),
×
327
            body: targetResponseBuffer.toString('binary'),
×
328
            status: targetResponse.statusCode,
×
329
            rawHeaders: RawHeaders.fromHeadersRecord(targetResponse.headers),
×
330
        };
×
331
    }
×
332

18✔
333
    public start() {
18✔
334
        this.init();
2✔
335
        if (!this.mockApp) {
2!
336
            throw new Error('initialization error');
×
337
        }
×
338
        if (this.server) {
2!
339
            throw new Error('mock server already started');
×
340
        }
×
341
        if (this.options.mock.httpsPort) {
2✔
342
            this.serverHttps = https
1✔
343
                .createServer(
1✔
344
                    {
1✔
345
                        key: this.options.mock.httpsKey,
1✔
346
                        cert: this.options.mock.httpsCert,
1✔
347
                    },
1✔
348
                    this.mockApp
1✔
349
                )
1✔
350
                .listen(this.options.mock.httpsPort, () => {
1✔
351
                    logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.httpsPort}`);
×
352
                });
1✔
353
        }
1✔
354
        this.server = this.mockApp.listen(this.options.mock.port, () => {
2✔
355
            logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.port}`);
×
356
            if (!this.options.api.disabled) {
×
357
                this.apiServer?.start();
×
358
            }
×
359
        });
2✔
360
    }
2✔
361

18✔
362
    public async stop(): Promise<void> {
18✔
363
        const closePromises: Promise<void>[] = [];
3✔
364
        if (!this.server) {
3✔
365
            throw new Error('mock server not started');
1✔
366
        }
1✔
367
        if (!this.options.api.disabled) {
2✔
368
            if (!this.apiServer) {
2!
369
                logger.warn('no api server');
×
370
            } else {
2✔
371
                closePromises.push(
2✔
372
                    new Promise<void>((resolve) => {
2✔
373
                        if (!this.apiServer) {
2!
374
                            resolve();
×
375
                            return;
×
376
                        }
×
377
                        this.apiServer
2✔
378
                            .stop()
2✔
379
                            .then(() => resolve())
2✔
380
                            .catch((error) => {
2✔
381
                                logger.error(error, 'problem closing api server');
2✔
382
                                resolve();
2✔
383
                            });
2✔
384
                    })
2✔
385
                );
2✔
386
            }
2✔
387
        }
2✔
388
        closePromises.push(
2✔
389
            new Promise<void>((resolve) => {
2✔
390
                if (!this.server) {
2!
391
                    resolve();
×
392
                    return;
×
393
                }
×
394
                this.server.close((error) => {
2✔
395
                    if (error) {
2!
396
                        logger.warn(error, 'problem closing http server');
×
397
                    }
×
398
                    this.server = null;
2✔
399
                    resolve();
2✔
400
                });
2✔
401
            })
2✔
402
        );
2✔
403
        if (this.serverHttps) {
3✔
404
            closePromises.push(
1✔
405
                new Promise<void>((resolve) => {
1✔
406
                    if (!this.serverHttps) {
1!
407
                        resolve();
×
408
                        return;
×
409
                    }
×
410
                    this.serverHttps.close((error) => {
1✔
411
                        if (error) {
1!
412
                            logger.warn(error, 'problem closing https server');
×
413
                        }
×
414
                        this.server = null;
1✔
415
                        resolve();
1✔
416
                    });
1✔
417
                })
1✔
418
            );
1✔
419
        }
1✔
420
        await Promise.all(closePromises);
2✔
421
    }
2✔
422

18✔
423
    protected unproxyRequest(req: express.Request): Stuntman.BaseRequest {
18✔
424
        const protocol = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[2] || req.protocol;
8✔
425
        const port = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[1] || undefined;
8✔
426

8✔
427
        if (!HTTP_METHODS.includes(req.method.toUpperCase() as Stuntman.HttpMethod)) {
8✔
428
            throw new Error(`unrecognized http method "${req.method}"`);
1✔
429
        }
1✔
430

7✔
431
        // TODO unproxied req might fail if there's a signed url :shrug:
7✔
432
        // but then we can probably switch DNS for some particular 3rd party server to point to mock
7✔
433
        // and in mock have a mapping rule for that domain to point directly to some IP :thinking:
7✔
434
        return {
7✔
435
            url: `${protocol}://${req.hostname.replace(this.MOCK_DOMAIN_REGEX, '')}${port ? `:${port}` : ''}${req.originalUrl}`,
8✔
436
            rawHeaders: new RawHeaders(
8✔
437
                ...req.rawHeaders.map((h) => {
8✔
438
                    let outputHeader = h;
30✔
439
                    if (this.MOCK_DOMAIN_REGEX.test(outputHeader)) {
30✔
440
                        outputHeader = outputHeader.replace(this.MOCK_DOMAIN_REGEX, '').replace(/^https?/i, protocol);
12✔
441
                    }
12✔
442
                    return outputHeader;
30✔
443
                })
8✔
444
            ),
8✔
445
            method: req.method.toUpperCase() as Stuntman.HttpMethod,
8✔
446
            ...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
8✔
447
        };
8✔
448
    }
8✔
449

18✔
450
    protected removeProxyPort(req: Stuntman.Request): void {
18✔
451
        if (this.URL_PORT_REGEX.test(req.url)) {
5✔
452
            req.url = req.url.replace(this.URL_PORT_REGEX, '$1$2');
3✔
453
        }
3✔
454
        const host = req.rawHeaders.get('host') || '';
5✔
455
        if (
5✔
456
            host.endsWith(`:${this.options.mock.port}`) ||
5✔
457
            (this.options.mock.httpsPort && host.endsWith(`:${this.options.mock.httpsPort}`))
4✔
458
        ) {
5✔
459
            req.rawHeaders.set('host', host.split(':')[0]!);
2✔
460
        }
2✔
461
    }
5✔
462
}
18✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc