From 499cfa8ce01ae805b712d54241fd9aa86fb3f4d4 Mon Sep 17 00:00:00 2001 From: Jonatan Nilsson Date: Sat, 26 Mar 2022 15:50:18 +0000 Subject: [PATCH] FileResponse: Completely finished all HTTP/1.1 support for file handling including range, preconditions, modified and so much more. Flaska: Refactored how headers and status are sent. --- flaska.mjs | 95 ++++++++- package.json | 2 +- test/client.mjs | 26 ++- test/fileResponse.test.mjs | 386 ++++++++++++++++++++++++++++++++++++- test/flaska.api.test.mjs | 49 +++-- test/flaska.in.test.mjs | 28 ++- test/flaska.out.test.mjs | 59 +++++- test/helper.mjs | 2 +- test/http.test.mjs | 81 +++++++- test/test.txt | 1 + 10 files changed, 673 insertions(+), 56 deletions(-) create mode 100644 test/test.txt diff --git a/flaska.mjs b/flaska.mjs index a611a1e..796f1b0 100644 --- a/flaska.mjs +++ b/flaska.mjs @@ -4,6 +4,7 @@ import path from 'path' import http from 'http' import stream from 'stream' import fs from 'fs/promises' +import fsSync from 'fs' import { URL } from 'url' import { Buffer } from 'buffer' import zlib from 'zlib' @@ -294,6 +295,88 @@ export class FileResponse { this.filepath = filepath this.stat = stat } + + handleRequest(ctx, useFs = fsSync) { + let etag = '"' + this.stat.ino + '-' + this.stat.size + '-' + this.stat.mtime.getTime() + '"' + let lastModified = this.stat.mtime.toUTCString() + let lastModifiedRounded = Date.parse(lastModified) + + if (ctx.req.headers['if-match'] && ctx.req.headers['if-match'] !== etag) { + throw new HttpError(412, `Request if-match pre-condition failed`) + } + if (ctx.req.headers['if-unmodified-since']) { + let check = Date.parse(ctx.req.headers['if-unmodified-since']) + if (!check || check < lastModifiedRounded) { + throw new HttpError(412, `Request if-unmodified-since pre-condition failed`) + } + } + + ctx.headers['Etag'] = etag + + if (ctx.req.headers['if-none-match']) { + let split = ctx.req.headers['if-none-match'].split(',') + for (let check of split) { + if (check.trim() === etag) { + ctx.status = 304 + return null + } + } + } else if (ctx.req.headers['if-modified-since']) { + let check = Date.parse(ctx.req.headers['if-modified-since']) + if (check >= lastModifiedRounded) { + ctx.status = 304 + return null + } + } + + let readOptions = {} + let size = this.stat.size + + if (ctx.req.headers['range']) { + let match = ctx.req.headers['range'].match(/bytes=(\d+)-(\d+)?/) + let ifRange = ctx.req.headers['if-range'] + if (ifRange) { + if (ifRange[0] === '"' && ifRange !== etag) { + match = null + } else if (ifRange[0] !== '"') { + let check = Date.parse(ifRange) + if (!check || check < lastModifiedRounded) { + match = null + } + } + } + if (match) { + let start = Number(match[1]) + let end = size - 1 + if (match[2]) { + end = Math.min(Number(match[2]), size - 1) + } + + if (start >= size) { + throw new HttpError(416, `Out of range start ${start} outside of ${size} bounds`) + } + + if (start <= end) { + size = end - start + 1 + readOptions.start = start + readOptions.end = end + ctx.headers['Content-Range'] = start + '-' + end + '/' + this.stat.size + ctx.status = 206 + } + } + } + + let ext = path.extname(this.filepath).slice(1) + let found = MimeTypeDb[ext] + if (found) { + ctx.type = found[found.length - 1] + } + + let stream = useFs.createReadStream(this.filepath, readOptions) + ctx.headers['Last-Modified'] = lastModified + ctx.headers['Content-Length'] = size + return stream + } } export class FlaskaRouter { @@ -579,6 +662,7 @@ ctx.state.nonce = nonce; } constructFunction += 'ctx.headers = {' + constructFunction += `'Date': new Date().toUTCString(),` for (let key of headerKeys) { if (key === 'Content-Security-Policy' && options.nonce.length) { let groups = options.defaultHeaders[key].split(';') @@ -822,7 +906,15 @@ ctx.state.nonce = nonce; } } - requestEnd(err, ctx) { + requestEnd(orgErr, ctx) { + let err = orgErr + if (ctx.body && ctx.body.handleRequest) { + try { + ctx.body = ctx.body.handleRequest(ctx) + } catch (newErr) { + err = newErr + } + } if (err) { try { this._onerror(err, ctx) @@ -835,7 +927,6 @@ ctx.state.nonce = nonce; return } - ctx.res.statusCode = ctx.status if (statuses.empty[ctx.status]) { ctx.res.writeHead(ctx.status, ctx.headers) return ctx.res.end() diff --git a/package.json b/package.json index 443f7c9..581a749 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flaska", - "version": "1.0.0", + "version": "1.1.0", "description": "Flaska is a micro web-framework for node. It is designed to be fast, simple and lightweight, and is distributed as a single file module with no dependencies.", "main": "flaska.mjs", "scripts": { diff --git a/test/client.mjs b/test/client.mjs index 742b479..5663c69 100644 --- a/test/client.mjs +++ b/test/client.mjs @@ -29,6 +29,12 @@ Client.prototype.customRequest = function(method = 'GET', path, body, options = headers: {}, })) + if (options.agent) { + opts.agent = options.agent + } + + // opts.agent = agent + const req = http.request(opts) req.on('error', (err) => { @@ -56,13 +62,21 @@ Client.prototype.customRequest = function(method = 'GET', path, body, options = } res.on('end', function () { - if (!output) return resolve(null) - try { - output = JSON.parse(output) - } catch (e) { - return reject(new Error(`${e.message} while decoding: ${output}`)) + if (options.getRaw) { + output = { + status: res.statusCode, + data: output, + headers: res.headers, + } + } else { + if (!output) return resolve(null) + try { + output = JSON.parse(output) + } catch (e) { + return reject(new Error(`${e.message} while decoding: ${output}`)) + } } - if (output.status && typeof(output.status) === 'number') { + if (!options.getRaw && output.status && typeof(output.status) === 'number') { let err = new Error(`Request failed [${output.status}]: ${output.message}`) err.body = output return reject(err) diff --git a/test/fileResponse.test.mjs b/test/fileResponse.test.mjs index dc5ffc6..eee070c 100644 --- a/test/fileResponse.test.mjs +++ b/test/fileResponse.test.mjs @@ -1,5 +1,6 @@ import { Eltro as t, assert, stub } from 'eltro' import { FileResponse } from '../flaska.mjs' +import { createCtx } from './helper.mjs' t.test('should add path and stat', function() { const assertPath = 'some/path/here' @@ -12,8 +13,389 @@ t.test('should add path and stat', function() { }) t.describe('CreateReader()', function() { - t.test('should return fileReader for file with correct seeking', function() { + const assertPath = '/some/path/here.png' + const assertBody = { a: 1 } + const assertSize = 12498 + const assertmTime = new Date('2021-04-02T11:22:33.777') + const roundedModified = new Date('2021-04-02T11:22:33') + const assertIno = 3569723027 + const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"` + const stat = { + size: assertSize, + mtime: assertmTime, + ino: assertIno, + } + + let tests = [ + [ + 'if no range', + function() {}, + ], + [ + 'if range start is higher than end', + function(headers) { headers['range'] = 'bytes=2000-1000' }, + ], + [ + 'if pre-condition if-match passes', + function(headers) { headers['if-match'] = assertEtag }, + ], + [ + 'if pre-condition if-unmodified-since passes', + function(headers) { headers['if-unmodified-since'] = roundedModified.toUTCString(); }, + ], + [ + 'if both pre-condition pass', + function(headers) { headers['if-unmodified-since'] = new Date(roundedModified.getTime() + 1000).toUTCString(); headers['if-match'] = assertEtag }, + ], + [ + 'if range is specified but if-range etag does not match', + function(headers) { + headers['if-range'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() + 1}"` + headers['range'] = 'bytes=1000-2000' + } + ], + [ + 'if range is specified but if-range modified by is older', + function(headers) { + headers['if-range'] = `${new Date(roundedModified.getTime() - 1000).toUTCString()}` + headers['range'] = 'bytes=1000-2000' + } + ], + [ + 'if range is specified but if-range is garbage', + function(headers) { + headers['if-range'] = `asdf` + headers['range'] = 'bytes=1000-2000' + } + ], + ] + + tests.forEach(function(test){ + t.test('return fileReader ' + test[0], function() { + + const stubCreateReadStream = stub().returns(assertBody) + let ctx = createCtx() + test[1](ctx.req.headers) + ctx.body = new FileResponse(assertPath, stat) + let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(checkBody, assertBody) + assert.strictEqual(stubCreateReadStream.firstCall[0], assertPath) + assert.deepStrictEqual(stubCreateReadStream.firstCall[1], {}) + assert.strictEqual(ctx.headers['Etag'], assertEtag) + assert.strictEqual(ctx.headers['Last-Modified'], assertmTime.toUTCString()) + assert.strictEqual(ctx.headers['Content-Length'], assertSize) + assert.strictEqual(ctx.type, 'image/png') + assert.notOk(ctx.headers['Content-Range']) + assert.strictEqual(ctx.status, 200) + }) + }) + + t.test('return fileReader with proper length with range', function() { + const assertPath = '/some/path/here.jpg' + const assertBody = { a: 1 } + const assertSize = 2000 + const assertmTime = new Date('2021-04-03T11:22:33.777') + const assertIno = 3569723027 + const stat = { + size: assertSize, + mtime: assertmTime, + ino: assertIno, + } + const stubCreateReadStream = stub().returns(assertBody) + let ctx = createCtx() + ctx.req.headers['range'] = 'bytes=0-1023' + ctx.body = new FileResponse(assertPath, stat) + let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(checkBody, assertBody) + assert.strictEqual(stubCreateReadStream.firstCall[0], assertPath) + assert.strictEqual(stubCreateReadStream.firstCall[1].start, 0) + assert.strictEqual(stubCreateReadStream.firstCall[1].end, 1023) + assert.strictEqual(ctx.headers['Etag'], `"${assertIno}-${assertSize}-${assertmTime.getTime()}"`) + assert.strictEqual(ctx.headers['Last-Modified'], assertmTime.toUTCString()) + assert.strictEqual(ctx.headers['Content-Length'], 1024) + assert.strictEqual(ctx.type, 'image/jpeg') + assert.strictEqual(ctx.headers['Content-Range'], `0-1023/${assertSize}`) + assert.strictEqual(ctx.status, 206) + }) + + t.test('return fileReader with proper length with range and if-range passes', function() { + const assertPath = '/some/path/here.jpg' + const assertBody = { a: 1 } + const assertSize = 2000 + const assertmTime = new Date('2021-04-03T11:22:33.777') + const roundedModified = new Date('2021-04-03T11:22:33') + const assertIno = 3569723027 + const stat = { + size: assertSize, + mtime: assertmTime, + ino: assertIno, + } + const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"` + let tests = [ + function(headers) { headers['if-range'] = roundedModified.toUTCString() }, + function(headers) { headers['if-range'] = assertEtag }, + ] + tests.forEach(function(check, i) { + const stubCreateReadStream = stub().returns(assertBody) + let ctx = createCtx() + ctx.req.headers['range'] = 'bytes=0-1023' + check(ctx.req.headers) + ctx.body = new FileResponse(assertPath, stat) + let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(checkBody, assertBody) + assert.strictEqual(stubCreateReadStream.firstCall[0], assertPath) + assert.strictEqual(stubCreateReadStream.firstCall[1].start, 0, `start missing in test ${i + 1}`) + assert.strictEqual(stubCreateReadStream.firstCall[1].end, 1023) + assert.strictEqual(ctx.headers['Etag'], `"${assertIno}-${assertSize}-${assertmTime.getTime()}"`) + assert.strictEqual(ctx.headers['Last-Modified'], assertmTime.toUTCString()) + assert.strictEqual(ctx.headers['Content-Length'], 1024) + assert.strictEqual(ctx.type, 'image/jpeg') + assert.strictEqual(ctx.headers['Content-Range'], `0-1023/${assertSize}`) + assert.strictEqual(ctx.status, 206) + }) + }) + + t.test('return fileReader with proper start if only start is specified', function() { + const assertSize = 2000 + const stat = { + size: assertSize, + mtime: new Date('2021-04-03T11:22:33.777'), + ino: 111, + } + + const testStart = [0, 1000, 1999] + testStart.forEach(function(start) { + const stubCreateReadStream = stub() + let ctx = createCtx() + ctx.req.headers['range'] = 'bytes=' + start + '-' + ctx.body = new FileResponse('file.png', stat) + ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(stubCreateReadStream.firstCall[1].start, start) + assert.strictEqual(stubCreateReadStream.firstCall[1].end, assertSize - 1) + assert.strictEqual(ctx.headers['Content-Length'], assertSize - start) + assert.strictEqual(ctx.headers['Content-Range'], `${start}-${assertSize - 1}/${assertSize}`) + assert.strictEqual(ctx.status, 206) + }) + }) + + t.test('should default to end if end overflows but start is valid', function() { + const assertSize = 2000 + const stat = { + size: assertSize, + mtime: new Date('2021-04-03T11:22:33.777'), + ino: 111, + } + + const testEnd = [2000, 3000, 99999] + testEnd.forEach(function(end) { + const stubCreateReadStream = stub() + let ctx = createCtx() + ctx.req.headers['range'] = 'bytes=1000-' + end + ctx.body = new FileResponse('file.png', stat) + ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(stubCreateReadStream.firstCall[1].start, 1000) + assert.strictEqual(stubCreateReadStream.firstCall[1].end, 1999) + assert.strictEqual(ctx.headers['Content-Length'], 1000) + assert.strictEqual(ctx.headers['Content-Range'], `1000-${assertSize - 1}/${assertSize}`) + assert.strictEqual(ctx.status, 206) + }) + }) + + t.test('should throw 416 if start is out of range', function() { const stubCreateReadStream = stub() - + let tests = [1000, 2000, 9999] + + tests.forEach(function(start) { + let ctx = createCtx() + ctx.body = new FileResponse('file.png', { + size: 1000, + mtime: new Date('2021-04-03T11:22:33.777'), + ino: 111, + }) + ctx.req.headers['range'] = `bytes=${start}-` + + assert.throws(function() { + ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + }, function(err) { + assert.strictEqual(err.status, 416) + assert.match(err.message, new RegExp(1000)) + assert.match(err.message, new RegExp(start)) + + assert.ok(ctx.headers['Etag']) + assert.notOk(stubCreateReadStream.called) + assert.notOk(ctx.headers['Last-Modified']) + assert.notOk(ctx.headers['Content-Length']) + assert.notOk(ctx.type) + assert.notOk(ctx.headers['Content-Range']) + return true + }) + }) + }) + + t.test('should return 304 if etag is found', function() { + const assertPath = '/some/path/here.png' + const assertNotBody = { a: 1 } + const assertSize = 12498 + const assertmTime = new Date('2021-04-02T11:22:33.777') + const assertIno = 3569723027 + const stat = { + size: assertSize, + mtime: assertmTime, + ino: assertIno, + } + const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"` + + let tests = [ + assertEtag, + `"asdf", "herp", ${assertEtag}`, + `"asdf", ${assertEtag}, "bla"`, + `${assertEtag}, "hello world"`, + `"asdf","herp",${assertEtag}`, + `"asdf",${assertEtag},"bla"`, + `${assertEtag},"hello world"`, + ] + tests.forEach(function(check) { + const stubCreateReadStream = stub().returns(assertNotBody) + let ctx = createCtx() + ctx.req.headers['if-none-match'] = check + ctx.body = new FileResponse(assertPath, stat) + let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(checkBody, null) + assert.strictEqual(ctx.status, 304) + assert.strictEqual(ctx.headers['Etag'], `"${assertIno}-${assertSize}-${assertmTime.getTime()}"`) + + assert.notOk(stubCreateReadStream.called) + + assert.notOk(ctx.headers['Last-Modified']) + assert.notOk(ctx.headers['Content-Length']) + assert.notOk(ctx.type) + assert.notOk(ctx.headers['Content-Range']) + }) + }) + + t.test('should return 304 if-last-modified is found', function() { + const assertPath = '/some/path/here.png' + const assertNotBody = { a: 1 } + const assertSize = 12498 + const assertmTime = new Date('2021-04-02T11:22:33.777') + const assertIno = 3569723027 + const stat = { + size: assertSize, + mtime: assertmTime, + ino: assertIno, + } + + let tests = [ + assertmTime.toUTCString(), + 'Fri, 02 Apr 2021 11:22:33 GMT', + 'Fri, 02 Apr 2021 11:22:34 GMT', + 'Fri, 02 Apr 2022 11:22:32 GMT', + ] + + tests.forEach(function(check) { + const stubCreateReadStream = stub().returns(assertNotBody) + let ctx = createCtx() + ctx.body = new FileResponse(assertPath, stat) + ctx.req.headers['if-modified-since'] = assertmTime.toUTCString() + let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(checkBody, null) + assert.strictEqual(ctx.status, 304) + assert.strictEqual(ctx.headers['Etag'], `"${assertIno}-${assertSize}-${assertmTime.getTime()}"`) + + assert.notOk(stubCreateReadStream.called) + assert.notOk(ctx.headers['Last-Modified']) + assert.notOk(ctx.headers['Content-Length']) + assert.notOk(ctx.type) + assert.notOk(ctx.headers['Content-Range']) + }) + }) + + t.test('should return 200 if etag or modified-by is not found', function() { + const assertPath = '/some/path/here.png' + const assertBody = { a: 1 } + const assertSize = 12498 + const assertmTime = new Date('2021-04-02T11:22:33.777') + const assertIno = 3569723027 + const stat = { + size: assertSize, + mtime: assertmTime, + ino: assertIno, + } + const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"` + const roundedModified = new Date('2021-04-02T11:22:33') + + let tests = [ + function(headers) { headers['if-none-match'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() + 1}"`; }, + function(headers) { headers['if-none-match'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() - 1}"`; }, + function(headers) { headers['if-none-match'] = `"asdf"`; }, + function(headers) { headers['if-none-match'] = `"asdf"`; headers['if-modified-since'] = roundedModified.toUTCString(); }, + function(headers) { headers['if-modified-since'] = new Date(roundedModified.getTime() - 1).toUTCString(); }, + function(headers) { headers['if-modified-since'] = 'asdfs' }, + ] + tests.forEach(function(check, i) { + const stubCreateReadStream = stub().returns(assertBody) + let ctx = createCtx() + check(ctx.req.headers) + ctx.body = new FileResponse(assertPath, stat) + let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + + assert.strictEqual(checkBody, assertBody, 'missing body in test ' + (i + 1)) + assert.strictEqual(stubCreateReadStream.firstCall[0], assertPath) + assert.deepStrictEqual(stubCreateReadStream.firstCall[1], {}) + assert.strictEqual(ctx.headers['Etag'], assertEtag) + assert.strictEqual(ctx.headers['Last-Modified'], assertmTime.toUTCString()) + assert.strictEqual(ctx.headers['Content-Length'], assertSize) + assert.strictEqual(ctx.type, 'image/png') + assert.notOk(ctx.headers['Content-Range']) + assert.strictEqual(ctx.status, 200) + }) + }) + + t.test('should return 412 if precondition fails', function() { + const stubCreateReadStream = stub() + const assertmTime = new Date('2021-04-02T11:22:33.777') + const roundedModified = new Date('2021-04-02T11:22:33') + const assertIno = 3569723027 + const assertSize = 12498 + const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"` + const stat = { + size: assertSize, + mtime: assertmTime, + ino: assertIno, + } + let tests = [ + function(headers) { headers['if-match'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() + 1}"` }, + function(headers) { headers['if-match'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() - 1}"` }, + function(headers) { headers['if-match'] = `asdf` }, + function(headers) { headers['if-unmodified-since'] = new Date(roundedModified.getTime() - 1).toUTCString(); }, + function(headers) { headers['if-unmodified-since'] = 'asdf'; }, + function(headers) { headers['if-match'] = assertEtag; headers['if-unmodified-since'] = 'asdf'; }, + function(headers) { headers['if-match'] = assertEtag; headers['if-unmodified-since'] = new Date(roundedModified.getTime() - 1000).toUTCString(); }, + function(headers) { headers['if-match'] = '"bla"'; headers['if-unmodified-since'] = roundedModified.toUTCString(); }, + ] + tests.forEach(function(check) { + let ctx = createCtx() + ctx.body = new FileResponse('file.png', stat) + check(ctx.req.headers) + + assert.throws(function() { + ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream }) + }, function(err) { + assert.strictEqual(err.status, 412) + assert.notOk(stubCreateReadStream.called) + assert.notOk(ctx.headers['Last-Modified']) + assert.notOk(ctx.headers['Content-Length']) + assert.notOk(ctx.type) + assert.notOk(ctx.headers['Content-Range']) + return true + }) + }) }) }) diff --git a/test/flaska.api.test.mjs b/test/flaska.api.test.mjs index a777478..e92b1cd 100644 --- a/test/flaska.api.test.mjs +++ b/test/flaska.api.test.mjs @@ -43,21 +43,25 @@ t.describe('#constructor', function() { flaska._before[0](ctx) - assert.deepStrictEqual(ctx.headers, { - 'Server': 'Flaska', - 'X-Content-Type-Options': 'nosniff', - 'Content-Security-Policy': `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; object-src 'none'; frame-ancestors 'none'`, - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Resource-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - }) + assert.deepEqual( + Object.keys(ctx.headers).sort(), + ['Server','X-Content-Type-Options','Content-Security-Policy','Cross-Origin-Opener-Policy','Cross-Origin-Resource-Policy','Cross-Origin-Embedder-Policy','Date'].sort() + ) + + assert.strictEqual(ctx.headers['Server'], 'Flaska') + assert.strictEqual(ctx.headers['X-Content-Type-Options'], 'nosniff') + assert.strictEqual(ctx.headers['Content-Security-Policy'], `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; object-src 'none'; frame-ancestors 'none'`) + assert.strictEqual(ctx.headers['Cross-Origin-Opener-Policy'], 'same-origin') + assert.strictEqual(ctx.headers['Cross-Origin-Resource-Policy'], 'same-origin') + assert.strictEqual(ctx.headers['Cross-Origin-Embedder-Policy'], 'require-corp') + assert.ok(new Date(ctx.headers['Date']).getDate()) + assert.strictEqual(flaska._after.length, 0) }) t.test('should have before ready setting headers on context if defaultHeaders is specified', function() { const defaultHeaders = { 'Server': 'nginx/1.16.1', - 'Date': 'Mon, 21 Mar\' 2022 07:26:01 GMT', 'Content-Type': 'applicat"ion/json; charset=utf-8', 'Content-Length': '1646', 'Connection': 'keep-alive', @@ -74,9 +78,16 @@ t.describe('#constructor', function() { let ctx = {} flaska._before[0](ctx) + + let keys = Object.keys(defaultHeaders) + console.log(Object.keys(ctx.headers).sort()) + console.log(keys.sort()) + assert.strictEqual(Object.keys(ctx.headers).length, keys.length + 1) + for (let key of keys) { + assert.strictEqual(ctx.headers[key], defaultHeaders[key]) + } + assert.ok(ctx.headers['Date']) - assert.notStrictEqual(ctx.headers, defaultHeaders) - assert.deepStrictEqual(ctx.headers, defaultHeaders) assert.strictEqual(flaska._after.length, 0) }) }) @@ -111,14 +122,14 @@ t.describe('#_nonce', function() { assert.strictEqual(flaska._noncesIndex, oldIndex - 1) assert.strictEqual(flaska._nonces[oldIndex], ctx.state.nonce) - assert.deepStrictEqual(ctx.headers, { - 'Server': 'Flaska', - 'X-Content-Type-Options': 'nosniff', - 'Content-Security-Policy': `default-src 'self'; style-src 'self' 'unsafe-inline' 'nonce-${ctx.state.nonce}'; img-src * data: blob:; object-src 'none'; frame-ancestors 'none'; script-src 'nonce-${ctx.state.nonce}'`, - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Resource-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - }) + + assert.strictEqual(ctx.headers['Server'], 'Flaska') + assert.strictEqual(ctx.headers['X-Content-Type-Options'], 'nosniff') + assert.strictEqual(ctx.headers['Content-Security-Policy'], `default-src 'self'; style-src 'self' 'unsafe-inline' 'nonce-${ctx.state.nonce}'; img-src * data: blob:; object-src 'none'; frame-ancestors 'none'; script-src 'nonce-${ctx.state.nonce}'`) + assert.strictEqual(ctx.headers['Cross-Origin-Opener-Policy'], 'same-origin') + assert.strictEqual(ctx.headers['Cross-Origin-Resource-Policy'], 'same-origin') + assert.strictEqual(ctx.headers['Cross-Origin-Embedder-Policy'], 'require-corp') + assert.ok(new Date(ctx.headers['Date']).getDate()) }) t.test('should always return nonce values even if it runs out in cache', function() { diff --git a/test/flaska.in.test.mjs b/test/flaska.in.test.mjs index e5916b5..30338cd 100644 --- a/test/flaska.in.test.mjs +++ b/test/flaska.in.test.mjs @@ -153,14 +153,18 @@ t.describe('#requestStart()', function() { assert.ok(ctx.query.get) assert.ok(ctx.query.set) assert.ok(ctx.query.delete) - assert.deepStrictEqual(ctx.headers, { - 'Server': 'Flaska', - 'X-Content-Type-Options': 'nosniff', - 'Content-Security-Policy': `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; object-src 'none'; frame-ancestors 'none'`, - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Resource-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - }) + assert.deepEqual( + Object.keys(ctx.headers).sort(), + ['Server','X-Content-Type-Options','Content-Security-Policy','Cross-Origin-Opener-Policy','Cross-Origin-Resource-Policy','Cross-Origin-Embedder-Policy','Date'].sort() + ) + + assert.strictEqual(ctx.headers['Server'], 'Flaska') + assert.strictEqual(ctx.headers['X-Content-Type-Options'], 'nosniff') + assert.strictEqual(ctx.headers['Content-Security-Policy'], `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; object-src 'none'; frame-ancestors 'none'`) + assert.strictEqual(ctx.headers['Cross-Origin-Opener-Policy'], 'same-origin') + assert.strictEqual(ctx.headers['Cross-Origin-Resource-Policy'], 'same-origin') + assert.strictEqual(ctx.headers['Cross-Origin-Embedder-Policy'], 'require-corp') + assert.ok(new Date(ctx.headers['Date']).getDate()) cb() } catch (err) { cb(err) } } @@ -194,8 +198,12 @@ t.describe('#requestStart()', function() { try { assert.ok(err) assert.strictEqual(err, assertError) - assert.notStrictEqual(ctx.headers, defaultHeaders) - assert.deepStrictEqual(ctx.headers, defaultHeaders) + let keys = Object.keys(defaultHeaders) + assert.strictEqual(Object.keys(ctx.headers).length, keys.length + 1) + for (let key of keys) { + assert.strictEqual(ctx.headers[key], defaultHeaders[key]) + } + assert.ok(ctx.headers['Date']) cb() } catch (err) { cb(err) } } diff --git a/test/flaska.out.test.mjs b/test/flaska.out.test.mjs index 7b601f1..9f74bec 100644 --- a/test/flaska.out.test.mjs +++ b/test/flaska.out.test.mjs @@ -1,5 +1,5 @@ import { Eltro as t, spy, assert, stub} from 'eltro' -import { Flaska, FlaskaRouter } from '../flaska.mjs' +import { FileResponse, Flaska, FlaskaRouter } from '../flaska.mjs' import { fakeHttp, createCtx } from './helper.mjs' const fakerHttp = fakeHttp() @@ -16,7 +16,6 @@ t.describe('#requestEnd()', function() { try { assert.strictEqual(ctx.status, assertStatus) assert.strictEqual(ctx.body, assertBody) - assert.strictEqual(ctx.res.statusCode, assertStatus) assert.strictEqual(ctx.headers['Content-Type'], 'application/json; charset=utf-8') assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength) assert.ok(body) @@ -71,7 +70,6 @@ t.describe('#requestEnd()', function() { try { assert.strictEqual(ctx.status, assertStatus) assert.strictEqual(ctx.body, assertBody) - assert.strictEqual(ctx.res.statusCode, assertStatus) assert.strictEqual(ctx.headers['Content-Type'], 'application/json; charset=utf-8') assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength) assert.ok(body) @@ -100,7 +98,6 @@ t.describe('#requestEnd()', function() { try { assert.strictEqual(ctx.status, assertStatus) assert.strictEqual(ctx.body, assertBody) - assert.strictEqual(ctx.res.statusCode, assertStatus) assert.strictEqual(ctx.headers['Content-Type'], 'text/plain; charset=utf-8') assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength) assert.ok(body) @@ -129,7 +126,6 @@ t.describe('#requestEnd()', function() { try { assert.strictEqual(ctx.status, assertStatus) assert.strictEqual(ctx.body, assertBody) - assert.strictEqual(ctx.res.statusCode, assertStatus) assert.strictEqual(ctx.headers['Content-Type'], 'text/plain; charset=utf-8') assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength) assert.ok(body) @@ -149,6 +145,52 @@ t.describe('#requestEnd()', function() { flaska.requestEnd(null, ctx) }) + t.test('call handleRequest if body contains it', function() { + let body = new FileResponse() + let assertBody = { a : 1 } + const ctx = createCtx({ + status: 204, + }) + ctx.body = body + const assertHandle = stub().returnWith(function(checkCtx, secondary) { + assert.notOk(secondary) + assert.strictEqual(checkCtx, ctx) + assert.notOk(ctx.res.writeHead.called) + return assertBody + }) + body.handleRequest = assertHandle + + let flaska = new Flaska({}, fakerHttp, fakeStream) + flaska.requestEnd(null, ctx) + assert.ok(assertHandle.called) + assert.ok(ctx.res.writeHead) + assert.strictEqual(ctx.body, assertBody) + }) + + t.test('call _onerror with error if handleRequest throws error', function() { + let body = new FileResponse() + const assertError = new Error('Secrets of the Goddess') + let assertBody = { a : 1 } + const ctx = createCtx({ + status: 204, + }) + ctx.body = body + const assertHandle = stub().throws(assertError) + body.handleRequest = assertHandle + + let flaska = new Flaska({}, fakerHttp, fakeStream) + flaska._onerror = stub().returnWith(function(err, checkCtx) { + assert.strictEqual(err, assertError) + checkCtx.body = assertBody + }) + flaska.requestEnd(null, ctx) + assert.ok(assertHandle.called) + assert.ok(flaska._onerror.called) + assert.strictEqual(flaska._onerror.firstCall[0], assertError) + assert.ok(ctx.res.writeHead) + assert.strictEqual(ctx.body, assertBody) + }) + t.test('call res and end correctly when dealing with custom type', function(cb) { const assertStatus = 209 const assertBody = 'test' @@ -156,7 +198,6 @@ t.describe('#requestEnd()', function() { let onFinish = function(body) { try { assert.strictEqual(ctx.status, assertStatus) - assert.strictEqual(ctx.res.statusCode, assertStatus) assert.strictEqual(ctx.headers['Content-Type'], assertType) assert.strictEqual(body, assertBody) assert.ok(ctx.res.writeHead.called) @@ -183,7 +224,6 @@ t.describe('#requestEnd()', function() { let onFinish = function(source, target, callback) { try { assert.strictEqual(ctx.status, assertStatus) - assert.strictEqual(ctx.res.statusCode, assertStatus) assert.strictEqual(ctx.headers['Content-Type'], assertType) assert.strictEqual(source, assertBody) assert.strictEqual(target, ctx.res) @@ -208,7 +248,7 @@ t.describe('#requestEnd()', function() { t.test('call pipe should have default type', function(cb) { let onFinish = function(source, target) { try { - assert.strictEqual(ctx.res.statusCode, 200) + assert.strictEqual(ctx.status, 200) assert.strictEqual(ctx.headers['Content-Type'], 'application/octet-stream') assert.strictEqual(source, ctx.body) assert.strictEqual(target, ctx.res) @@ -237,7 +277,7 @@ t.describe('#requestEnd()', function() { t.test(`call pipe with file extension ${test[0]} should mimetype ${test[1]}`, function(cb) { let onFinish = function(source, target) { try { - assert.strictEqual(ctx.res.statusCode, 200) + assert.strictEqual(ctx.status, 200) assert.strictEqual(ctx.headers['Content-Type'], test[1]) assert.strictEqual(source, ctx.body) assert.strictEqual(target, ctx.res) @@ -277,7 +317,6 @@ t.describe('#requestEnd()', function() { let onFinish = function(body) { try { assert.strictEqual(ctx.status, assertStatus) - assert.strictEqual(ctx.res.statusCode, assertStatus) assert.strictEqual(ctx.res.setHeader.callCount, 0) assert.ok(ctx.res.writeHead.called) assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status) diff --git a/test/helper.mjs b/test/helper.mjs index 5695dcf..29b40a6 100644 --- a/test/helper.mjs +++ b/test/helper.mjs @@ -41,7 +41,7 @@ export function createRes(def) { }) } -export function createCtx(def, endHandler) { +export function createCtx(def = {}, endHandler = null) { return defaults(def, { req: createReq(), res: createRes({ end: endHandler || spy() }), diff --git a/test/http.test.mjs b/test/http.test.mjs index e6607b7..c4568d1 100644 --- a/test/http.test.mjs +++ b/test/http.test.mjs @@ -1,9 +1,10 @@ import fsSync from 'fs' +import http from 'http' import fs from 'fs/promises' import formidable from 'formidable' import { Eltro as t, assert, stub } from 'eltro' import { setTimeout } from 'timers/promises' -import { Flaska, JsonHandler, FormidableHandler } from '../flaska.mjs' +import { Flaska, JsonHandler, FormidableHandler, FileResponse } from '../flaska.mjs' import Client from './client.mjs' const port = 51024 @@ -37,6 +38,11 @@ flaska.get('/file', function(ctx) { file = fsSync.createReadStream('./test/test.png') ctx.body = file }) +flaska.get('/filehandle', function(ctx) { + return fs.stat('./test/test.txt').then(function(stat) { + ctx.body = new FileResponse('./test/test.txt', stat) + }) +}) flaska.post('/file/upload', FormidableHandler(formidable, { uploadDir: './test/upload' }), function(ctx) { @@ -139,7 +145,9 @@ t.describe('/timeout', function() { let err = await assert.isRejected(client.customRequest('GET', '/timeout', JSON.stringify({}), { timeout: 20 })) - await setTimeout(20) + while (!log.info.called) { + await setTimeout(10) + } assert.match(err.message, /timed out/) assert.notOk(log.error.called) @@ -156,7 +164,9 @@ t.describe('/file', function() { await client.customRequest('GET', '/file', null, { toPipe: target }) - await setTimeout(20) + while (!target.closed && !file.closed) { + await setTimeout(10) + } assert.ok(target.closed) assert.ok(file.closed) @@ -186,8 +196,6 @@ t.describe('/file', function() { req.destroy() - await setTimeout(20) - while (!file.closed) { await setTimeout(10) } @@ -200,6 +208,69 @@ t.describe('/file', function() { }) }) +t.describe('/filehandle', function() { + const agent = new http.Agent({ + keepAlive: true, + maxSockets: 1, + keepAliveMsecs: 3000, + }) + + t.test('server should send correctly', async function() { + log.error.reset() + + let res = await client.customRequest('GET', '/filehandle', null, { getRaw: true, agent: agent }) + assert.strictEqual(res.status, 200) + assert.strictEqual(res.headers['content-length'], '11') + assert.strictEqual(res.headers['content-type'], 'text/plain') + assert.match(res.headers['etag'], /\"\d+-11-\d+"/) + assert.ok(res.headers['last-modified']) + let d = new Date(Date.parse(res.headers['last-modified'])) + let etag = res.headers['etag'] + assert.ok(d.getTime()) + + res = await client.customRequest('GET', '/filehandle', null, { getRaw: true, agent: agent, + headers: { + 'If-Modified-Since': d.toUTCString() + }, + }) + assert.strictEqual(res.status, 304) + assert.strictEqual(res.data, '') + assert.strictEqual(res.headers['etag'], etag) + + res = await client.customRequest('GET', '/filehandle', null, { getRaw: true, agent: agent, + headers: { + 'If-None-Match': etag + }, + }) + assert.strictEqual(res.status, 304) + assert.strictEqual(res.data, '') + assert.strictEqual(res.headers['etag'], etag) + + res = await client.customRequest('GET', '/filehandle', null, { getRaw: true, agent: agent, + headers: { + 'Range': 'bytes=2-5' + }, + }) + assert.strictEqual(res.status, 206) + assert.strictEqual(res.data, 'llo ') + assert.strictEqual(res.headers['content-length'], '4') + + + res = await client.customRequest('GET', '/filehandle', null, { getRaw: true, agent: agent, + headers: { + 'Range': 'bytes=0-0' + }, + }) + assert.strictEqual(res.status, 206) + assert.strictEqual(res.data, 'H') + assert.strictEqual(res.headers['content-length'], '1') + }) + + t.after(function() { + agent.destroy() + }) +}) + t.describe('/file/upload', function() { t.test('server should upload file', async function() { let res = await client.upload('/file/upload', './test/test.png') diff --git a/test/test.txt b/test/test.txt new file mode 100644 index 0000000..5e1c309 --- /dev/null +++ b/test/test.txt @@ -0,0 +1 @@ +Hello World \ No newline at end of file