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

andrzej-woof / stuntman / 8302728422

15 Mar 2024 09:53PM UTC coverage: 82.594% (-16.6%) from 99.152%
8302728422

Pull #6

github

web-flow
Merge 1bfe15182 into e05d8a943
Pull Request #6: Mock tests

649 of 718 branches covered (90.39%)

Branch coverage included in aggregate %.

148 of 248 new or added lines in 9 files covered. (59.68%)

5 existing lines in 1 file now uncovered.

2274 of 2821 relevant lines covered (80.61%)

12.63 hits per line

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

21.19
/packages/server/src/api/api.ts
1
import http from 'http';
1✔
2
import express, { NextFunction, Request, Response, Express as ExpressServer } from 'express';
1✔
3
import { v4 as uuidv4 } from 'uuid';
1✔
4
import { getTrafficStore } from '../storage';
1✔
5
import { getRuleExecutor } from '../ruleExecutor';
1✔
6
import { logger, AppError, HttpCode, MAX_RULE_TTL_SECONDS, stringify, INDEX_DTS, errorToLog } from '@stuntman/shared';
1✔
7
import type * as Stuntman from '@stuntman/shared';
1✔
8
import { RequestContext } from '../requestContext';
1✔
9
import serializeJavascript from 'serialize-javascript';
1✔
10
import LRUCache from 'lru-cache';
1✔
11
import { validateDeserializedRule } from './validators';
1✔
12
import { deserializeRule, escapedSerialize, liveRuleToRule } from './utils';
1✔
13

1✔
14
type ApiOptions = Stuntman.ApiConfig & {
1✔
15
    mockUuid: string;
1✔
16
};
1✔
17

1✔
18
const API_KEY_HEADER = 'x-api-key';
1✔
19

1✔
20
export class API {
1✔
21
    protected options: Required<Exclude<ApiOptions, { disabled: true } & { mockUuid: string }>>;
1✔
22
    protected webGuiOptions: Stuntman.WebGuiConfig;
1✔
23
    protected apiApp: ExpressServer | null = null;
1✔
24
    trafficStore: LRUCache<string, Stuntman.LogEntry>;
1✔
25
    server: http.Server | null = null;
1✔
26

1✔
27
    constructor(options: ApiOptions, webGuiOptions: Stuntman.WebGuiConfig = { disabled: false }) {
1✔
28
        if (options.disabled) {
2!
NEW
29
            throw new Error('unable to run with disabled flag');
×
NEW
30
        }
×
31
        if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
2!
32
            throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
×
33
        }
×
34
        this.options = options;
2✔
35
        this.webGuiOptions = webGuiOptions;
2✔
36

2✔
37
        this.trafficStore = getTrafficStore(this.options.mockUuid);
2✔
38
        this.auth = this.auth.bind(this);
2✔
39
        this.authReadOnly = this.authReadOnly.bind(this);
2✔
40
        this.authReadWrite = this.authReadWrite.bind(this);
2✔
41
    }
2✔
42

1✔
43
    private auth(req: Request, type: 'read' | 'write'): void {
1✔
44
        if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
×
45
            return;
×
46
        }
×
47
        const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
×
48
        const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
×
49
        const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
×
50
        if (!hasValidKey) {
×
51
            throw new AppError({ httpCode: HttpCode.UNAUTHORIZED, message: 'unauthorized' });
×
52
        }
×
53
        return;
×
54
    }
×
55

1✔
56
    protected authReadOnly(req: Request, _res: Response, next: NextFunction): void {
1✔
57
        this.auth(req, 'read');
×
58
        next();
×
59
    }
×
60

1✔
61
    protected authReadWrite(req: Request, _res: Response, next: NextFunction): void {
1✔
62
        this.auth(req, 'write');
×
63
        next();
×
64
    }
×
65

1✔
66
    private initApi() {
1✔
67
        this.apiApp = express();
×
68

×
69
        this.apiApp.use(express.json());
×
70
        this.apiApp.use(express.text());
×
71

×
72
        this.apiApp.use((req: Request, _res: Response, next: NextFunction) => {
×
73
            RequestContext.bind(req, this.options.mockUuid);
×
74
            next();
×
75
        });
×
76

×
77
        this.apiApp.get('/rule', this.authReadOnly.bind, async (_req, res) => {
×
78
            res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRules()));
×
79
        });
×
80

×
81
        this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
×
82
            if (!req.params.ruleId) {
×
83
                throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
×
84
            }
×
85
            res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRule(req.params.ruleId)));
×
86
        });
×
87

×
88
        this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
×
89
            if (!req.params.ruleId) {
×
90
                throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
×
91
            }
×
92
            getRuleExecutor(this.options.mockUuid).disableRule(req.params.ruleId);
×
93
            res.send();
×
94
        });
×
95

×
96
        this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
×
97
            if (!req.params.ruleId) {
×
98
                throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
×
99
            }
×
100
            getRuleExecutor(this.options.mockUuid).enableRule(req.params.ruleId);
×
101
            res.send();
×
102
        });
×
103

×
104
        this.apiApp.post(
×
105
            '/rule',
×
106
            this.authReadWrite,
×
107
            async (req: Request<object, string, Stuntman.SerializedRule>, res: Response) => {
×
108
                const deserializedRule = deserializeRule(req.body);
×
109
                validateDeserializedRule(deserializedRule);
×
110
                // eslint-disable-next-line @typescript-eslint/ban-ts-comment
×
111
                // @ts-ignore
×
112
                const rule = await getRuleExecutor(this.options.mockUuid).addRule(deserializedRule);
×
113
                res.send(stringify(rule));
×
114
            }
×
115
        );
×
116

×
117
        this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
×
118
            if (!req.params.ruleId) {
×
119
                throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
×
120
            }
×
121
            await getRuleExecutor(this.options.mockUuid).removeRule(req.params.ruleId);
×
122
            res.send();
×
123
        });
×
124

×
125
        this.apiApp.get('/traffic', this.authReadOnly, (_req, res) => {
×
126
            const serializedTraffic: Stuntman.LogEntry[] = [];
×
127
            for (const value of this.trafficStore.values()) {
×
128
                serializedTraffic.push(value);
×
129
            }
×
130
            res.json(serializedTraffic);
×
131
        });
×
132

×
133
        this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
×
134
            if (!req.params.ruleIdOrLabel) {
×
135
                throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleIdOrLabel' });
×
136
            }
×
137
            const serializedTraffic: Stuntman.LogEntry[] = [];
×
138
            for (const value of this.trafficStore.values()) {
×
139
                if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
×
140
                    serializedTraffic.push(value);
×
141
                }
×
142
            }
×
143
            res.json(serializedTraffic);
×
144
        });
×
145

×
146
        if (!this.webGuiOptions.disabled) {
×
147
            this.apiApp.set('views', __dirname + '/webgui');
×
148
            this.apiApp.set('view engine', 'pug');
×
149
            this.initWebGui();
×
150
        }
×
151

×
152
        this.apiApp.all(/.*/, (_req: Request, res: Response) => res.status(404).send());
×
153

×
154
        this.apiApp.use((error: Error | AppError, req: Request, res: Response) => {
×
155
            const ctx: RequestContext | null = RequestContext.get(req);
×
156
            const uuid = ctx?.uuid || uuidv4();
×
157
            if (error instanceof AppError && error.isOperational && res) {
×
158
                logger.error(error);
×
159
                res.status(error.httpCode).json({
×
160
                    error: { message: error.message, httpCode: error.httpCode, stack: error.stack },
×
161
                });
×
162
                return;
×
163
            }
×
164
            logger.error({ error: errorToLog(error), uuid }, 'unexpected error');
×
165
            if (res) {
×
166
                res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
×
167
                    error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
×
168
                });
×
169
                return;
×
170
            }
×
171
            // eslint-disable-next-line no-console
×
172
            console.log('API server encountered a critical error. Exiting');
×
173
            process.exit(1);
×
174
        });
×
175
    }
×
176

1✔
177
    private initWebGui() {
1✔
178
        if (!this.apiApp) {
×
179
            throw new Error('initialization error');
×
180
        }
×
181
        this.apiApp.get('/webgui/rules', this.authReadOnly, async (_req, res) => {
×
182
            const rules: Record<string, string> = {};
×
183
            for (const rule of await getRuleExecutor(this.options.mockUuid).getRules()) {
×
184
                rules[rule.id] = serializeJavascript(liveRuleToRule(rule), { unsafe: true });
×
185
            }
×
186
            res.render('rules', { rules: escapedSerialize(rules), INDEX_DTS, ruleKeys: Object.keys(rules) });
×
187
        });
×
188

×
189
        this.apiApp.get('/webgui/traffic', this.authReadOnly, async (_req, res) => {
×
190
            const serializedTraffic: Stuntman.LogEntry[] = [];
×
191
            for (const value of this.trafficStore.values()) {
×
192
                serializedTraffic.push(value);
×
193
            }
×
194
            res.render('traffic', {
×
195
                traffic: JSON.stringify(
×
196
                    serializedTraffic.sort((a, b) => b.originalRequest.timestamp - a.originalRequest.timestamp)
×
197
                ),
×
198
            });
×
199
        });
×
200

×
201
        // TODO make webui way better and safer, nicer formatting, eslint/prettier, blackjack and hookers
×
202

×
203
        this.apiApp.post('/webgui/rules/unsafesave', this.authReadWrite, async (req, res) => {
×
204
            const rule: Stuntman.Rule = new Function(req.body)();
×
205
            if (
×
206
                !rule ||
×
207
                !rule.id ||
×
208
                typeof rule.matches !== 'function' ||
×
209
                typeof rule.ttlSeconds !== 'number' ||
×
210
                rule.ttlSeconds > MAX_RULE_TTL_SECONDS
×
211
            ) {
×
212
                throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'Invalid rule' });
×
213
            }
×
214
            await getRuleExecutor(this.options.mockUuid).addRule(
×
215
                {
×
216
                    id: rule.id,
×
217
                    matches: rule.matches,
×
218
                    ttlSeconds: rule.ttlSeconds,
×
219
                    actions: rule.actions,
×
220
                    ...(rule.disableAfterUse !== undefined && { disableAfterUse: rule.disableAfterUse }),
×
221
                    ...(rule.isEnabled !== undefined && { isEnabled: rule.isEnabled }),
×
222
                    ...(rule.labels !== undefined && { labels: rule.labels }),
×
223
                    ...(rule.priority !== undefined && { priority: rule.priority }),
×
224
                    ...(rule.removeAfterUse !== undefined && { removeAfterUse: rule.removeAfterUse }),
×
225
                    ...(rule.storeTraffic !== undefined && { storeTraffic: rule.storeTraffic }),
×
226
                },
×
227
                true
×
228
            );
×
229
            res.send();
×
230
        });
×
231
    }
×
232

1✔
233
    public start() {
1✔
234
        if (this.server) {
×
235
            throw new Error('mock server already started');
×
236
        }
×
237
        this.initApi();
×
238
        if (!this.apiApp) {
×
239
            throw new Error('initialization error');
×
240
        }
×
241
        this.server = this.apiApp.listen(this.options.port, () => {
×
242
            logger.info(`API listening on ${this.options.port}`);
×
243
        });
×
244
    }
×
245

1✔
246
    public async stop(): Promise<void> {
1✔
247
        if (!this.server) {
2✔
248
            throw new Error('mock server not started');
2✔
249
        }
2✔
NEW
250
        return new Promise<void>((resolve) => {
×
NEW
251
            if (!this.server) {
×
NEW
252
                resolve();
×
NEW
253
                return;
×
NEW
254
            }
×
NEW
255
            this.server.close((error) => {
×
NEW
256
                if (error) {
×
NEW
257
                    logger.warn(error, 'problem closing server');
×
NEW
258
                }
×
NEW
259
                this.server = null;
×
NEW
260
                resolve();
×
NEW
261
            });
×
262
        });
×
263
    }
×
264
}
1✔
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

© 2026 Coveralls, Inc