update dependencies and devDependencies, fix lint issues
[squeep-api-dingus] / test / lib / dingus.js
1 'use strict';
2
3 const assert = require('node:assert');
4 const sinon = require('sinon');
5 const fs = require('fs');
6
7 const Dingus = require('../../lib/dingus');
8 const { DingusError, RouterNoMethodError } = require('../../lib/errors');
9 const Enum = require('../../lib/enum');
10
11 const noExpectedException = 'did not get expected exception';
12
13 const _nop = () => undefined;
14 const _logFn = (process.env['VERBOSE_TESTS'] && console.log) || _nop;
15 const noLogger = {
16 debug: _logFn,
17 error: _logFn,
18 };
19 sinon.spy(noLogger, 'debug');
20 sinon.spy(noLogger, 'error');
21
22 describe('Dingus', function () {
23 let dingus;
24 beforeEach(function () {
25 dingus = new Dingus(noLogger, {});
26 });
27 afterEach(function () {
28 sinon.restore();
29 });
30
31 describe('constructor', function () {
32 it('covers', function () {
33 const d = new Dingus();
34 assert(d);
35 });
36 }); // constructor
37
38 describe('_normalizePath', function () {
39 it('returns normal path', function () {
40 const p = '/a/b/c';
41 const r = dingus._normalizePath(p);
42 assert.strictEqual(r, p);
43 });
44 it('returns normal path', function () {
45 const p = '////a///b/./bar/..///c';
46 const expected = '/a/b/c';
47 const r = dingus._normalizePath(p);
48 assert.strictEqual(r, expected);
49 });
50 }); // _normalizePath
51
52 describe('_splitUrl', function () {
53 const nullObject = Object.create(null);
54
55 it('splits a simple path', function () {
56 const p = '/a/b/c';
57 const expected = {
58 pathPart: p,
59 queryParams: nullObject,
60 };
61 const r = dingus._splitUrl(p);
62 assert.deepStrictEqual(r, expected);
63 });
64 it('splits a path with trailing slash preserved', function () {
65 const p = '/a/b/c/';
66 const expected = {
67 pathPart: p,
68 queryParams: nullObject,
69 };
70 const r = dingus._splitUrl(p);
71 assert.deepStrictEqual(r, expected);
72 });
73 it('splits a path with trailing slash ignored', function () {
74 const p = '/a/b/c/';
75 const expected = {
76 pathPart: p,
77 queryParams: nullObject,
78 };
79 dingus.ignoreTrailingSlash = true;
80 const r = dingus._splitUrl(p);
81 assert.deepStrictEqual(r, expected);
82 });
83 it('splits a path with empty query string', function () {
84 const p = '/a/b/c?';
85 const expected = {
86 pathPart: '/a/b/c',
87 queryParams: nullObject,
88 };
89 const r = dingus._splitUrl(p);
90 assert.deepStrictEqual(r, expected);
91 });
92 it('splits a path with query string', function () {
93 const p = '/a/b/c?x=1&y=2&z';
94 const expected = {
95 pathPart: '/a/b/c',
96 queryParams: Object.assign(Object.create(null), {
97 x: '1',
98 y: '2',
99 z: '', // Subjective Editorial: disagree with the default querystring parser behavior here: null would be better than empty string, esp as result is null-prototyped object.
100 }),
101 };
102 const r = dingus._splitUrl(p);
103 assert.deepStrictEqual(r, expected);
104 });
105 }); // _splitUrl
106
107 describe('tagContext', function () {
108 let req, res, ctx;
109 beforeEach(function () {
110 req = {
111 getHeader: sinon.stub(),
112 setHeader: sinon.stub(),
113 };
114 res = {
115 getHeader: sinon.stub(),
116 setHeader: sinon.stub(),
117 };
118 ctx = {};
119 });
120 it ('sets id in context', function () {
121 const result = Dingus.tagContext(req, res, ctx);
122 assert.strictEqual(ctx.requestId, result);
123 assert(res.setHeader.called);
124 });
125 it ('sets provided header', function () {
126 req.getHeader.onCall(0).returns('abc'); // X-Request-ID
127 const result = Dingus.tagContext(req, res, ctx);
128 assert.strictEqual(ctx.requestId, result);
129 assert.strictEqual(res.setHeader.getCall(0).args[0], 'Request-ID');
130 assert.strictEqual(res.setHeader.getCall(1).args[0], 'X-Request-ID');
131 assert.strictEqual(res.setHeader.getCall(1).args[1], 'abc');
132 assert.strictEqual(res.setHeader.callCount, 2);
133 });
134 }); // tagContext
135
136 describe('clientAddressContext', function () {
137 let req, res, ctx;
138 let _tp;
139 before(function () {
140 _tp = dingus.trustProxy;
141 });
142 after(function () {
143 dingus.trustProxy = _tp;
144 });
145 beforeEach(function () {
146 req = {
147 getHeader: sinon.stub(),
148 setHeader: sinon.stub(),
149 connection: {},
150 };
151 res = {
152 getHeader: sinon.stub(),
153 setHeader: sinon.stub(),
154 };
155 ctx = {};
156 });
157 it ('covers untrusted proxy', function () {
158 dingus.trustProxy = false;
159 const expected = {
160 clientAddress: '',
161 clientProtocol: 'http',
162 };
163 dingus.clientAddressContext(req, res, ctx);
164 assert.deepStrictEqual(ctx, expected);
165 assert(!req.getHeader.called);
166 });
167 it ('covers missing', function () {
168 dingus.trustProxy = true;
169 const expected = {
170 clientAddress: '::1',
171 clientProtocol: 'https',
172 };
173 req.connection.remoteAddress = '::1';
174 req.connection.encrypted = true;
175 dingus.clientAddressContext(req, res, ctx);
176 assert(req.getHeader.called);
177 assert.deepStrictEqual(ctx, expected);
178 });
179 }); // clientAddressContext
180
181 describe('ingestCookie', function () {
182 let req, res, ctx;
183 beforeEach(function () {
184 req = {
185 getHeader: sinon.stub(),
186 };
187 ctx = {};
188 });
189 it('covers no header', function () {
190 const expected = {};
191 Dingus.ingestCookie(req, res, ctx);
192 assert.deepStrictEqual(ctx.cookie, expected);
193 });
194 it('covers non variable', function () {
195 req.getHeader.returns('foo');
196 const expected = {
197 foo: null,
198 };
199 Dingus.ingestCookie(req, res, ctx);
200 assert.deepStrictEqual(ctx.cookie, expected);
201 });
202 it('parses cookies', function () {
203 req.getHeader.returns('foo=bar; baz="quux"');
204 const expected = {
205 foo: 'bar',
206 baz: 'quux',
207 };
208 Dingus.ingestCookie(req, res, ctx);
209 assert.deepStrictEqual(ctx.cookie, expected);
210 });
211 it('parses nulls', function () {
212 req.getHeader.returns('foo=; bar=');
213 const expected = {
214 foo: '',
215 bar: '',
216 };
217 Dingus.ingestCookie(req, res, ctx);
218 assert.deepStrictEqual(ctx.cookie, expected);
219 });
220 it('parses non-uri-encoded', function () {
221 req.getHeader.returns('foo%=%qux');
222 const expected = {
223 'foo%': '%qux',
224 };
225 Dingus.ingestCookie(req, res, ctx);
226 assert.deepStrictEqual(ctx.cookie, expected);
227 });
228 it('covers nameless cookie', function () {
229 req.getHeader.returns('=bar');
230 const expected = {
231 };
232 Dingus.ingestCookie(req, res, ctx);
233 assert.deepStrictEqual(ctx.cookie, expected);
234
235 });
236 it('covers duplicate cookie', function () {
237 req.getHeader.returns('foo=bar; foo="quux"');
238 const expected = {
239 foo: 'bar',
240 };
241 Dingus.ingestCookie(req, res, ctx);
242 assert.deepStrictEqual(ctx.cookie, expected);
243 });
244 }); // ingestCookie
245
246 describe('getRequestContentType', function () {
247 let req;
248 beforeEach(function () {
249 req = {
250 getHeader: sinon.stub(),
251 setHeader: sinon.stub(),
252 };
253 });
254 it('handles missing header', function () {
255 const result = Dingus.getRequestContentType(req);
256 assert.strictEqual(result, '');
257 });
258 it('parses simple type', function () {
259 req.getHeader.onCall(0).returns(Enum.ContentType.ApplicationJson);
260 const result = Dingus.getRequestContentType(req);
261 assert.strictEqual(result, Enum.ContentType.ApplicationJson);
262 });
263 it('parses complex type', function () {
264 req.getHeader.onCall(0).returns('application/json ; charset=UTF-8');
265 const result = Dingus.getRequestContentType(req);
266 assert.strictEqual(result, Enum.ContentType.ApplicationJson);
267 });
268 }); // getRequestContentType
269
270 describe('setResponseContentType', function () {
271 let req, responseTypes;
272 beforeEach(function () {
273 responseTypes = [];
274 req = {
275 setHeader: sinon.stub(),
276 getHeader: sinon.stub(),
277 };
278 });
279 it('handles missing header', function () {
280 const result = Dingus.getResponseContentType(responseTypes, req);
281 assert.strictEqual(result, undefined);
282 });
283 it('behaves as expected', function () {
284 responseTypes.push(Enum.ContentType.ApplicationJson);
285 req.getHeader.onCall(0).returns('text, image/png;q=0.5, application/*;q=0.2, audio;q=0.1');
286 const result = Dingus.getResponseContentType(responseTypes, req);
287 assert.strictEqual(result, Enum.ContentType.ApplicationJson);
288 });
289 }); // setResponseContentType
290
291 describe('on', function () {
292 let stubOn;
293 beforeEach(function () {
294 stubOn = sinon.stub(dingus.router, 'on');
295 });
296 it('covers', function () {
297 dingus.on('GET', '/', () => {});
298 assert(stubOn.called);
299 });
300 }); // on
301
302 describe('setEndBodyHandler', function () {
303 let req, res, ctx, handler, origEnd, origWrite;
304 beforeEach(function () {
305 origEnd = sinon.stub();
306 origWrite = sinon.stub();
307 req = {};
308 res = {
309 write: origWrite,
310 end: origEnd,
311 };
312 ctx = {};
313 handler = sinon.stub();
314 });
315 it('collects body and handles', function () {
316 Dingus.setEndBodyHandler(req, res, ctx, handler);
317 res.write(Buffer.from('foo'));
318 res.write('baz');
319 res.write();
320 res.end('quux');
321 assert(origWrite.called);
322 assert(origEnd.called);
323 assert.deepStrictEqual(ctx.responseBody, Buffer.from('foobazquux'));
324 assert(handler.called);
325 });
326 }); // setEndBodyHandler
327
328 describe('setHeadHandler', function () {
329 let req, res, ctx, origEnd, origWrite;
330 beforeEach(function () {
331 origEnd = sinon.stub();
332 origWrite = sinon.stub();
333 req = {
334 method: 'HEAD',
335 };
336 res = {
337 end: origEnd,
338 write: origWrite,
339 setHeader: sinon.stub(),
340 };
341 ctx = {};
342 });
343 it('collects response without writing', function () {
344 Dingus.setHeadHandler(req, res, ctx);
345 res.write(Buffer.from('foo'));
346 res.write('baz');
347 res.write();
348 res.end('quux');
349 assert(!origWrite.called);
350 assert(origEnd.called);
351 assert.deepStrictEqual(ctx.responseBody, undefined);
352 });
353 it('collects response without writing, persists written data', function () {
354 Dingus.setHeadHandler(req, res, ctx, true);
355 res.write(Buffer.from('foo'));
356 res.write('baz');
357 res.write();
358 res.end('quux');
359 assert(!origWrite.called);
360 assert(origEnd.called);
361 assert.deepStrictEqual(ctx.responseBody, Buffer.from('foobazquux'));
362 });
363 it('ignores non-head method', function () {
364 req.method = 'GET';
365 Dingus.setHeadHandler(req, res, ctx);
366 res.write(Buffer.from('foo'));
367 res.end('bar');
368 assert(origWrite.called);
369 assert(origEnd.called);
370 });
371 }); // setHeadHandler
372
373 describe('addEncodingHeader', function () {
374 let res, encoding;
375 beforeEach(function () {
376 res = {
377 _headers: {},
378 // eslint-disable-next-line security/detect-object-injection
379 getHeader: (h) => res._headers[h],
380 // eslint-disable-next-line security/detect-object-injection
381 setHeader: (h, v) => res._headers[h] = v,
382 };
383 });
384 it('adds', function () {
385 encoding = 'gzip';
386 Dingus.addEncodingHeader(res, encoding);
387 assert.strictEqual(res._headers[Enum.Header.ContentEncoding], 'gzip');
388 });
389 it('extends', function () {
390 encoding = 'utf8';
391 Dingus.addEncodingHeader(res, encoding);
392 assert.strictEqual(res._headers[Enum.Header.ContentEncoding], 'utf8');
393 encoding = 'gzip';
394 Dingus.addEncodingHeader(res, encoding);
395 assert.strictEqual(res._headers[Enum.Header.ContentEncoding], 'gzip, utf8');
396 });
397 }); // addEncodingHeader
398
399 describe('dispatch', function () {
400 let pathsByLengthOrig;
401 let req, res, ctx;
402 let stubHandler;
403
404 beforeEach(function () {
405 req = {
406 url: '/',
407 method: 'GET',
408 setHeader: sinon.stub(),
409 getHeader: sinon.stub(),
410 };
411 res = {
412 statusCode: 200,
413 end: sinon.stub(),
414 setHeader: sinon.stub(),
415 hasHeader: sinon.stub(),
416 getHeader: sinon.stub(),
417 getHeaders: sinon.stub(),
418 };
419 ctx = {};
420 pathsByLengthOrig = dingus.pathsByLength;
421 sinon.spy(dingus, 'handlerMethodNotAllowed');
422 sinon.spy(dingus, 'handlerNotFound');
423 sinon.spy(dingus, 'handlerBadRequest');
424 sinon.spy(dingus, 'handlerInternalServerError');
425 sinon.spy(Dingus, 'setHeadHandler');
426 stubHandler = sinon.stub();
427 });
428 afterEach(function () {
429 dingus.pathsByLength = pathsByLengthOrig;
430 });
431
432 it('calls handler', async function () {
433 const urlPath = '/:id';
434 const method = 'GET';
435 dingus.on(method, urlPath, stubHandler);
436 req.url = '/abc';
437 req.method = method;
438
439 await dingus.dispatch(req, res, ctx);
440 assert(stubHandler.called);
441 assert(!dingus.handlerMethodNotAllowed.called);
442 assert(!dingus.handlerNotFound.called);
443 });
444 it('calls handler without context', async function () {
445 const urlPath = '/:id';
446 const method = 'GET';
447 dingus.on(method, urlPath, stubHandler);
448 req.url = '/abc';
449 req.method = method;
450
451 await dingus.dispatch(req, res);
452 assert(stubHandler.called);
453 assert(!dingus.handlerMethodNotAllowed.called);
454 assert(!dingus.handlerNotFound.called);
455 });
456 it('calls fallback handler', async function () {
457 const urlPath = '/abc/:id';
458 const method = 'GET';
459 dingus.on('*', urlPath, stubHandler);
460 req.url = '/abc/def';
461 req.method = method;
462
463 await dingus.dispatch(req, res, ctx);
464 assert(stubHandler.called);
465 assert(!dingus.handlerMethodNotAllowed.called);
466 assert(!dingus.handlerNotFound.called);
467 });
468 it('handles error in handler', async function () {
469 const urlPath = '/:id';
470 const method = 'GET';
471 dingus.on(method, urlPath, stubHandler);
472 req.url = '/abc';
473 req.method = method;
474 stubHandler.rejects(new Error('blah'));
475
476 await dingus.dispatch(req, res, ctx);
477 assert(stubHandler.called);
478 assert(!dingus.handlerMethodNotAllowed.called);
479 assert(!dingus.handlerNotFound.called);
480 });
481 it('calls unsupported method', async function () {
482 const urlPath = '/:id';
483 const method = 'POST';
484 dingus.on('GET', urlPath, stubHandler);
485 req.url = '/abc';
486 req.method = method;
487
488 await dingus.dispatch(req, res, ctx);
489 assert(!stubHandler.called);
490 assert(dingus.handlerMethodNotAllowed.called);
491 assert(!dingus.handlerNotFound.called);
492 });
493 it('does not lookup nonexistent path', async function () {
494 req.url = '/foo/bar';
495 req.method = 'GET';
496
497 await dingus.dispatch(req, res, ctx);
498 assert(!stubHandler.called);
499 assert(!dingus.handlerMethodNotAllowed.called);
500 assert(dingus.handlerNotFound.called);
501 });
502 it('covers unhandled dingus exception', async function () {
503 const expectedException = new DingusError('blah');
504 sinon.stub(dingus.router, 'lookup').throws(expectedException);
505
506 await dingus.dispatch(req, res, ctx);
507 assert(!stubHandler.called);
508 assert(dingus.handlerInternalServerError.called);
509 });
510 it('covers other exception', async function () {
511 const expectedException = new Error('blah');
512 sinon.stub(dingus.router, 'lookup').throws(expectedException);
513
514 await dingus.dispatch(req, res, ctx);
515 assert(!stubHandler.called);
516 assert(dingus.handlerInternalServerError.called);
517 });
518 it('covers bad uri', async function () {
519 req.url = '/%f';
520
521 await dingus.dispatch(req, res, ctx);
522 assert(dingus.handlerBadRequest.called);
523 });
524 it('calls handler with additional arguments', async function () {
525 dingus.on('GET', '/', stubHandler, 'foo', 'bar');
526 await dingus.dispatch(req, res, ctx);
527 assert(stubHandler.called);
528 assert.strictEqual(stubHandler.args[0][3], 'foo');
529 assert.strictEqual(stubHandler.args[0][4], 'bar');
530 });
531 describe('intrinsic HEAD handling', function () {
532 it('covers no intrinsic HEAD handling', async function () {
533 dingus.intrinsicHeadMethod = false;
534 dingus.on('GET', '/', stubHandler);
535 req.method = 'HEAD';
536 await dingus.dispatch(req, res, ctx);
537 assert(!stubHandler.called);
538 assert(dingus.handlerMethodNotAllowed.called);
539 });
540 it('calls HEAD setup and GET handler', async function () {
541 dingus.on('GET', '/', stubHandler);
542 req.method = 'HEAD';
543 await dingus.dispatch(req, res, ctx);
544 assert(Dingus.setHeadHandler.called);
545 assert(stubHandler.called);
546 });
547 it('covers no GET handler', async function () {
548 dingus.on('POST', '/', stubHandler);
549 req.method = 'HEAD';
550 await dingus.dispatch(req, res, ctx);
551 assert(!stubHandler.called);
552 assert(dingus.handlerMethodNotAllowed.called);
553 });
554 it('covers unexpected router error', async function () {
555 sinon.stub(dingus.router, 'lookup')
556 .onFirstCall().throws(new RouterNoMethodError())
557 .onSecondCall().throws(new DingusError())
558 ;
559 dingus.on('GET', '/', stubHandler);
560 req.method = 'HEAD';
561 await dingus.dispatch(req, res, ctx);
562 assert(dingus.handlerInternalServerError.called);
563 });
564 });
565 }); // dispatch
566
567 describe('parseBody', function () {
568 let ctx;
569 beforeEach(function () {
570 ctx = {};
571 });
572 it('does not parse unknown type', function () {
573 try {
574 dingus.parseBody('unknown/type', ctx);
575 assert.fail(noExpectedException);
576 } catch (e) {
577 assert.strictEqual(e.statusCode, 415);
578 }
579 });
580 it('parses json', function () {
581 const src = { foo: 'bar' };
582 const rawBody = JSON.stringify(src);
583 dingus.parseBody(Enum.ContentType.ApplicationJson, ctx, rawBody);
584 assert.deepStrictEqual(ctx.parsedBody, src);
585 });
586 it('handles unparsable json', function () {
587 const rawBody = 'not json';
588 try {
589 dingus.parseBody(Enum.ContentType.ApplicationJson, ctx, rawBody);
590 assert.fail(noExpectedException);
591 } catch (e) {
592 assert.strictEqual(e.statusCode, 400);
593 }
594 });
595 it('parses form', function () {
596 const expected = Object.assign(Object.create(null), {
597 foo: 'bar',
598 });
599 const rawBody = 'foo=bar';
600 dingus.parseBody('application/x-www-form-urlencoded', ctx, rawBody);
601 assert.deepStrictEqual(ctx.parsedBody, expected);
602 });
603
604 }); // parseBody
605
606 describe('bodyData', function () {
607 let res, resEvents;
608 beforeEach(function () {
609 resEvents = {};
610 res = {
611 // eslint-disable-next-line security/detect-object-injection
612 on: (ev, fn) => resEvents[ev] = fn,
613 };
614 });
615 it('provides data', async function () {
616 const p = dingus.bodyData(res);
617 resEvents['data'](Buffer.from('foo'));
618 resEvents['data'](Buffer.from('bar'));
619 resEvents['end']();
620 const result = await p;
621 assert.strictEqual(result, 'foobar');
622 });
623 it('handles error', async function () {
624 const p = dingus.bodyData(res);
625 resEvents['error']('foo');
626 try {
627 await p;
628 assert.fail(noExpectedException);
629 } catch (e) {
630 assert.strictEqual(e, 'foo');
631 }
632 });
633 it('limits size', async function () {
634 const p = dingus.bodyData(res, 8);
635 resEvents['data'](Buffer.from('foobar'));
636 resEvents['data'](Buffer.from('bazquux'));
637 try {
638 await p;
639 assert.fail(noExpectedException);
640 } catch (e) {
641 assert.strictEqual(e.statusCode, 413);
642 }
643 });
644 it('provides buffer', async function () {
645 const p = dingus.bodyData(res, 0, false);
646 const expected = Buffer.from('bleat');
647 resEvents['data'](expected);
648 resEvents['end']();
649 const result = await p;
650 assert.deepStrictEqual(result, expected);
651 });
652 }); // bodyData
653
654 describe('ingestBody', function () {
655 it('ingests json', async function () {
656 const req = {};
657 const res = {};
658 const ctx = {};
659 sinon.stub(dingus, 'bodyData').resolves('{"foo":"bar"}');
660 sinon.stub(Dingus, 'getRequestContentType').returns(Enum.ContentType.ApplicationJson);
661 await dingus.ingestBody(req, res, ctx);
662 assert.deepStrictEqual(ctx.parsedBody, { foo: 'bar' });
663 assert.deepStrictEqual(ctx.rawBody, undefined);
664 });
665 it('persists rawBody', async function () {
666 const req = {};
667 const res = {};
668 const ctx = {};
669 const body = '{"foo":"bar"}';
670 sinon.stub(dingus, 'bodyData').resolves(body);
671 sinon.stub(Dingus, 'getRequestContentType').returns(Enum.ContentType.ApplicationJson);
672 await dingus.ingestBody(req, res, ctx, { persistRawBody: true });
673 assert.deepStrictEqual(ctx.parsedBody, { foo: 'bar' });
674 assert.deepStrictEqual(ctx.rawBody, body);
675 });
676 it('skips parsing empty body', async function () {
677 const req = {};
678 const res = {};
679 const ctx = {};
680 const body = '';
681 sinon.stub(dingus, 'bodyData').resolves(body);
682 sinon.stub(Dingus, 'getRequestContentType').returns(Enum.ContentType.ApplicationJson);
683 sinon.spy(dingus, 'parseBody');
684 await dingus.ingestBody(req, res, ctx, { parseEmptyBody: false });
685 assert.deepStrictEqual(ctx.parsedBody, undefined);
686 assert(dingus.parseBody.notCalled);
687 });
688 }); // ingestBody
689
690 describe('setResponseType', function () {
691 let req, res, ctx;
692 let _sa; // Preserve strictAccept
693 before(function () {
694 _sa = dingus.strictAccept;
695 });
696 after(function () {
697 dingus.strictAccept = _sa;
698 });
699 beforeEach(function () {
700 ctx = {};
701 req = {};
702 res = {
703 setHeader: sinon.stub(),
704 };
705 sinon.stub(Dingus, 'getResponseContentType').returns();
706 });
707 it('rejects missing', function () {
708 dingus.strictAccept = true;
709 try {
710 dingus.setResponseType(['my/type'], req, res, ctx);
711 assert.fail(noExpectedException);
712 } catch (e) {
713 assert.strictEqual(e.statusCode, 406, 'did not get expected status code');
714 }
715 });
716 it('accepts missing', function () {
717 dingus.strictAccept = false;
718 dingus.setResponseType(['my/type'], req, res, ctx);
719 assert.strictEqual(ctx.responseType, 'my/type');
720 });
721
722 }); // setResponseType
723
724 describe('_readFileInfo', function () {
725 let stat, data, statRes, dataRes, filename;
726 beforeEach(function () {
727 sinon.stub(fs.promises, 'stat');
728 sinon.stub(fs.promises, 'readFile');
729 statRes = {
730 mtimeMs:1612553697186,
731 };
732 dataRes = 'data';
733 filename = 'dummy.txt';
734 });
735 it('succeeds', async function () {
736 fs.promises.stat.resolves(statRes);
737 fs.promises.readFile.resolves('data');
738 [stat, data] = await dingus._readFileInfo(filename);
739 assert.deepStrictEqual(stat, statRes);
740 assert.deepStrictEqual(data, dataRes);
741 });
742 it('returns null for non-existant file', async function () {
743 const noEnt = {
744 code: 'ENOENT',
745 };
746 fs.promises.stat.rejects(noEnt);
747 fs.promises.readFile.rejects(noEnt);
748 [stat, data] = await dingus._readFileInfo(filename);
749 assert.strictEqual(stat, null);
750 assert.strictEqual(data, null);
751 });
752 it('throws unexpected error', async function () {
753 const expectedException = new Error('blah');
754 fs.promises.stat.rejects(expectedException);
755 await assert.rejects(async () => {
756 await dingus._readFileInfo(filename);
757 }, expectedException);
758 });
759 }); // _readFileInfo
760
761 describe('_serveFileMetaHeaders', function () {
762 let res, directory, fileName;
763 beforeEach(function () {
764 sinon.stub(dingus, '_readFileInfo');
765 res = {
766 setHeader: sinon.stub(),
767 };
768 directory = '/path';
769 fileName = 'filename';
770 });
771 it('covers no meta file', async function() {
772 dingus._readFileInfo.resolves([null, null]);
773 const result = await dingus._serveFileMetaHeaders(res, directory, fileName);
774 assert(!res.setHeader.called);
775 assert.strictEqual(result, false);
776 });
777 it('adds extra headers', async function () {
778 dingus._readFileInfo.resolves([{}, Buffer.from(`Link: <https://example.com/>; rel="relation"
779 X-Folded-Header: data
780 data under
781 the fold
782 Content-Type: image/sgi
783 `)]);
784 const result = await dingus._serveFileMetaHeaders(res, directory, fileName);
785 assert(res.setHeader.called);
786 assert.strictEqual(result, true);
787 });
788 }); // _serveFileMetaHeaders
789
790 describe('serveFile', function () {
791 const path = require('path');
792 let ctx, req, res, directory, fileName, filestats;
793 beforeEach(function () {
794 directory = path.join(__dirname, '..', 'test-data');
795 fileName = 'example.html';
796 ctx = {};
797 req = {
798 _headers: {
799 [Enum.Header.Accept]: undefined,
800 [Enum.Header.IfModifiedSince]: undefined,
801 [Enum.Header.AcceptEncoding]: undefined,
802 [Enum.Header.IfNoneMatch]: undefined,
803 },
804 getHeader: (header) => {
805 if (header in req._headers) {
806 // eslint-disable-next-line security/detect-object-injection
807 return req._headers[header];
808 }
809 assert.fail(`unexpected getHeader ${header}`);
810 },
811 };
812 res = {
813 end: sinon.stub(),
814 getHeader: sinon.stub(),
815 getHeaders: sinon.stub(),
816 hasHeader: sinon.stub().returns(true),
817 setHeader: sinon.stub(),
818 };
819 filestats = {
820 dev: 39,
821 mode: 33188,
822 nlink: 1,
823 uid: 1002,
824 gid: 1002,
825 rdev: 0,
826 blksize: 512,
827 ino: 897653,
828 size: 8,
829 blocks: 17,
830 atimeMs: 1613253436842.815,
831 mtimeMs: 1603485933192.861,
832 ctimeMs: 1603485933192.861,
833 birthtimeMs: 0,
834 atime: '2021-02-13T21:57:16.843Z',
835 mtime: '2020-10-23T13:45:33.193Z',
836 ctime: '2020-10-23T13:45:33.193Z',
837 birthtime: '1970-01-01T00:00:00.000Z',
838 };
839 sinon.stub(dingus, 'handlerNotFound');
840 sinon.stub(fs.promises, 'stat').resolves(filestats);
841 sinon.spy(fs.promises, 'readFile');
842 });
843 it('serves a file', async function () {
844 await dingus.serveFile(req, res, ctx, directory, fileName);
845 assert(fs.promises.readFile.called);
846 assert(!dingus.handlerNotFound.called);
847 });
848 it('covers no meta headers', async function () {
849 dingus.staticMetadata = false;
850 await dingus.serveFile(req, res, ctx, directory, fileName);
851 assert(fs.promises.readFile.called);
852 assert(!dingus.handlerNotFound.called);
853 });
854 it('does not serve dot-file', async function () {
855 fileName = '.example';
856 await dingus.serveFile(req, res, ctx, directory, fileName);
857 assert(!fs.promises.readFile.called);
858 assert(dingus.handlerNotFound.called);
859 });
860 it('does not serve encoded navigation', async function () {
861 fileName = '/example.html';
862 await dingus.serveFile(req, res, ctx, directory, fileName);
863 assert(!fs.promises.readFile.called);
864 assert(dingus.handlerNotFound.called);
865 });
866 it('does not serve missing file', async function () {
867 fileName = 'no-file.here';
868 await dingus.serveFile(req, res, ctx, directory, fileName);
869 assert(dingus.handlerNotFound.called);
870 });
871 it('requires directory be specified', async function () {
872 await dingus.serveFile(req, res, ctx, '', fileName);
873 assert(!fs.promises.readFile.called);
874 assert(dingus.handlerNotFound.called);
875 });
876 it('covers fs error', async function () {
877 const expectedException = new Error('blah');
878 fs.promises.stat.restore();
879 sinon.stub(fs.promises, 'stat').rejects(expectedException);
880 try {
881 await dingus.serveFile(req, res, ctx, directory, fileName);
882 assert.fail('should have thrown');
883 } catch (e) {
884 assert.strictEqual(e, expectedException);
885 }
886 });
887 it('caches by modified', async function () {
888 req._headers[Enum.Header.IfModifiedSince] = 'Fri, 23 Oct 2020 23:11:16 GMT';
889 await dingus.serveFile(req, res, ctx, directory, fileName);
890 assert.strictEqual(res.statusCode, 304);
891 });
892 it('does not cache old modified', async function () {
893 req._headers[Enum.Header.IfModifiedSince] = 'Fri, 23 Oct 2020 01:11:16 GMT';
894 await dingus.serveFile(req, res, ctx, directory, fileName);
895 assert.notStrictEqual(res.statusCode, 304);
896 assert(!dingus.handlerNotFound.called);
897 });
898 it('caches ETag match', async function () {
899 req._headers[Enum.Header.IfNoneMatch] = '"zPPQVfXV36sgXq4fRLdsm+7rRMb8IUfb/eJ6N6mnwWs"';
900 await dingus.serveFile(req, res, ctx, directory, fileName);
901 assert.strictEqual(res.statusCode, 304);
902 });
903 it('does not cache ETag non-match', async function () {
904 req._headers[Enum.Header.IfNoneMatch] = '"foo", "bar"';
905 await dingus.serveFile(req, res, ctx, directory, fileName);
906 assert.notStrictEqual(res.statusCode, 304);
907 assert(!dingus.handlerNotFound.called);
908 });
909 it('handles no possible encodings', async function () {
910 req._headers[Enum.Header.AcceptEncoding] = '*;q=0';
911 await assert.rejects(async () => {
912 await dingus.serveFile(req, res, ctx, directory, fileName);
913 }, {
914 name: 'ResponseError',
915 });
916 });
917 it('handles a valid encoding', async function () {
918 req._headers[Enum.Header.AcceptEncoding] = 'gzip';
919 await dingus.serveFile(req, res, ctx, directory, fileName);
920 assert(res.end.called);
921 });
922 it('handles a valid encoding among others', async function () {
923 req._headers[Enum.Header.AcceptEncoding] = 'flarp, br, gzip';
924 fs.promises.stat.restore();
925 sinon.stub(fs.promises, 'stat')
926 .onCall(0).resolves(filestats) // identity file
927 .onCall(1).resolves(null) // br encoding
928 .onCall(2).resolves(filestats); // gzip encoding
929 await dingus.serveFile(req, res, ctx, directory, fileName);
930 assert(res.end.called);
931 });
932 it('handles misconfigured encoding', async function () {
933 Enum.EncodingType.Flarp = 'flarp';
934 req._headers[Enum.Header.AcceptEncoding] = 'flarp, gzip';
935 await dingus.serveFile(req, res, ctx, directory, fileName);
936 delete Enum.EncodingType.Flarp;
937 assert(res.end.called);
938 });
939 }); // serveFile
940
941 describe('renderError', function () {
942 let err;
943 beforeEach(function () {
944 err = {
945 statusCode: '200',
946 errorMessage: 'OK',
947 details: 'hunkydorey',
948 };
949 });
950 it('renders unknown type', function () {
951 const contentType = 'unknown/type';
952 const result = dingus.renderError(contentType, err);
953 assert.deepStrictEqual(result, 'OK\r\nhunkydorey');
954 });
955 it('renders text', function () {
956 const contentType = 'text/plain';
957 const result = dingus.renderError(contentType, err);
958 assert.deepStrictEqual(result, 'OK\r\nhunkydorey');
959 });
960 it('renders json', function () {
961 const contentType = Enum.ContentType.ApplicationJson;
962 const result = dingus.renderError(contentType, err);
963 assert.deepStrictEqual(result, JSON.stringify(err));
964 });
965 it('renders html without details', function () {
966 err = {
967 statusCode: '201',
968 errorMessage: 'Created',
969 };
970 const contentType = 'text/html';
971 const result = dingus.renderError(contentType, err);
972 assert.deepStrictEqual(result, `<!DOCTYPE html>
973 <html lang="en">
974 <head>
975 <title>${err.statusCode} ${err.errorMessage}</title>
976 </head>
977 <body>
978 <h1>${err.errorMessage}</h1>
979 </body>
980 </html>`);
981 });
982 it('renders html', function () {
983 const contentType = 'text/html';
984 const result = dingus.renderError(contentType, err);
985 assert.deepStrictEqual(result, `<!DOCTYPE html>
986 <html lang="en">
987 <head>
988 <title>${err.statusCode} ${err.errorMessage}</title>
989 </head>
990 <body>
991 <h1>${err.errorMessage}</h1>
992 <p>${err.details}</p>
993 </body>
994 </html>`);
995 });
996 it('renders html, multiple details', function () {
997 const contentType = 'text/html';
998 err.details = ['one detail', 'two detail'];
999 const result = dingus.renderError(contentType, err);
1000 assert.deepStrictEqual(result, `<!DOCTYPE html>
1001 <html lang="en">
1002 <head>
1003 <title>${err.statusCode} ${err.errorMessage}</title>
1004 </head>
1005 <body>
1006 <h1>${err.errorMessage}</h1>
1007 <p>one detail</p>
1008 <p>two detail</p>
1009 </body>
1010 </html>`);
1011 });
1012 }); // renderError
1013
1014 describe('sendErrorResponse', function () {
1015 let ctx, req, res;
1016 beforeEach(function () {
1017 ctx = {};
1018 req = {};
1019 res = {
1020 end: sinon.stub(),
1021 getHeader: sinon.stub(),
1022 getHeaders: sinon.stub(),
1023 hasHeader: sinon.stub().returns(true),
1024 setHeader: sinon.stub(),
1025 };
1026 sinon.stub(dingus, 'renderError');
1027 });
1028 it('covers', function () {
1029 const err = {
1030 statusCode: 444,
1031 };
1032 dingus.sendErrorResponse(err, req, res, ctx);
1033 assert(res.end.called);
1034 });
1035 }); // sendErrorResponse
1036
1037 describe('proxyPrefix', function () {
1038 let req, res, ctx, stubHandler, pfxDingus;
1039 const pfx = '/pfx';
1040
1041 beforeEach(function () {
1042 pfxDingus = new Dingus(console, { proxyPrefix: pfx });
1043 req = {
1044 setHeader: sinon.stub(),
1045 getHeader: sinon.stub(),
1046 };
1047 res = {
1048 statusCode: 200,
1049 end: sinon.stub(),
1050 setHeader: sinon.stub(),
1051 getHeader: sinon.stub(),
1052 };
1053 ctx = {};
1054 sinon.stub(pfxDingus, 'handlerMethodNotAllowed');
1055 sinon.stub(pfxDingus, 'handlerNotFound');
1056 stubHandler = sinon.stub();
1057 });
1058 afterEach(function () {
1059 sinon.restore();
1060 });
1061
1062 it('handles prefixed route', async function () {
1063 const urlPath = '/:id';
1064 const method = 'GET';
1065 pfxDingus.on(method, urlPath, stubHandler);
1066 req.url = pfx + '/abc';
1067 req.method = method;
1068
1069 await pfxDingus.dispatch(req, res, ctx);
1070 assert(stubHandler.called);
1071 assert(!pfxDingus.handlerMethodNotAllowed.called);
1072 assert(!pfxDingus.handlerNotFound.called);
1073 });
1074 it('does not handle prefixed route', async function () {
1075 const urlPath = '/:id';
1076 const method = 'GET';
1077 pfxDingus.on(method, urlPath, stubHandler);
1078 req.url = '/wrongpfx/abc';
1079 req.method = method;
1080
1081 await pfxDingus.dispatch(req, res, ctx);
1082 assert(!stubHandler.called);
1083 assert(!pfxDingus.handlerMethodNotAllowed.called);
1084 assert(pfxDingus.handlerNotFound.called);
1085 });
1086 }); // proxyPrefix
1087
1088 describe('handlerRedirect', function () {
1089 let req, res, ctx;
1090 beforeEach(function () {
1091 req = {
1092 getHeader: sinon.stub(),
1093 };
1094 res = {
1095 setHeader: sinon.stub(),
1096 end: sinon.stub(),
1097 };
1098 ctx = {};
1099 });
1100 it('covers', async function () {
1101 await dingus.handlerRedirect(req, res, ctx);
1102 assert(res.setHeader.called);
1103 assert(res.end.called);
1104 });
1105 it('covers non-defaults', async function () {
1106 await dingus.handlerRedirect(req, res, ctx, 308);
1107 assert(res.setHeader.called);
1108 assert(res.end.called);
1109 });
1110 }); // handlerRedirect
1111
1112 describe('handlerGetStaticFile', function () {
1113 let req, res, ctx;
1114 beforeEach(function () {
1115 req = {
1116 getHeader: sinon.stub(),
1117 };
1118 res = {
1119 setHeader: sinon.stub(),
1120 };
1121 ctx = {
1122 params: {
1123 file: '',
1124 },
1125 };
1126 sinon.stub(dingus, 'serveFile');
1127 });
1128 it('covers', async function () {
1129 await dingus.handlerGetStaticFile(req, res, ctx);
1130 assert(dingus.serveFile.called);
1131 });
1132 it('covers specified file', async function () {
1133 await dingus.handlerGetStaticFile(req, res, ctx, 'file.txt');
1134 assert(dingus.serveFile.called);
1135 });
1136 }); // handlerGetStaticFile
1137 });