diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000..3e31ee181 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,4 @@ +coverage: + parsers: + javascript: + enable_partials: yes diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index a1c8ab884..000000000 --- a/.eslintrc.yml +++ /dev/null @@ -1 +0,0 @@ -extends: koa diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..ac9e2646e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +open_collective: koajs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..a17d5bcf7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 5 + versioning-strategy: increase-if-necessary diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 000000000..fc11c74c0 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,27 @@ +name: Node.js CI + +on: + push: + branch: master + pull_request: + branch: master + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.17.0, 12.x, 14.x, 16.x, 18.x, 20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm run lint + - run: npm test -- --coverage --maxWorkers 2 + - run: npx codecov diff --git a/.gitignore b/.gitignore index 9df514315..cd2f2f2cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules test.js coverage npm-debug.log +.idea +*.iml +dist diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 43c97e719..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 23b51eb43..000000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -sudo: false -language: node_js -node_js: - - 7 - - 8 - - 9 -cache: - directories: - - wrk/bin - - node_modules -before_script: - - npm prune - - "[ ! -f wrk/bin/wrk ] && rm -rf wrk && git clone https://github.com/wg/wrk.git && make -C wrk && mkdir wrk/bin && mv wrk/wrk wrk/bin || true" - - export PATH=$PATH:$PWD/wrk/bin/ -script: - - npm run lint - - npm run test-cov - - npm run bench -after_script: - - npm install codecov - - ./node_modules/.bin/codecov diff --git a/AUTHORS b/AUTHORS index 027d3ff98..a44b8375a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,13 +1,18 @@ +小菜 Aaron Heckmann Adam L Adam Lau Aesop Wolf AlexeyKhristov +Alexsey Amit Portnoy +Anton Harniakou Arjun +Asiel Leal Avindra Goolcharan Bartol Karuza Ben Reinhart +Bernie Stern Bryan Bess C.T. Lin Chiahao Lin @@ -24,27 +29,34 @@ Fangdun Cai Felix Becker Filip Skokan Francisco Presencia +Gao Sheng George Chung Gilles De Mey +Grand Guilherme Pacheco HanHor Wu Hartley Melamed +Hrvoje Šimić Hugh Kennedy Ian Storm Taylor Ilkka Oksanen Ivan Kleshnin Ivan Lyons Jacob Bass +JamesWang Jan Buschtöns Jan Carlo Viray +Jason Macgowan Jed Schmidt Jeff Moore Jesus Rodriguez Jesús Rodríguez Rodríguez Jingwei "John" Liu Johan Bergström +Jonas Zhang <106856363@qq.com> Jonathan Ong Jonathan Ong +Joseph Lin Julian Gruber Kareem Kwong Karl Böhlmark @@ -57,6 +69,7 @@ Louis DeScioli Luke Bousfield Malcolm Marceli.no +Mars Wong Martin Iwanowski Martin Iwanowski Martin fl0w Iwanowski @@ -65,40 +78,51 @@ Mathieu Gallé-Tessonneau Matthew Chase Whittemore Matthew King Matthew Mueller +Mengdi Gao Michaël Zasso Michał Gołębiowski-Owczarek Nathan Rajlich New Now Nohow +Nick McCurdy Nicolae Vartolomei PatrickJS +Paul Anderson +Pedro Pablo Aste Kompen Peeyush Kushwaha Phillip Alexander PlasmaPower Prayag Verma Qiming zhao Remek Ambroziak +Riceball LEE Richard Marmorstein Rico Sta. Cruz Robert Sköld Robin Pokorný +Ruben Bridgewater Rui Marinho Rui Marinho Ryunosuke SATO Saad Quadri Santiago Sotomayor +Sergei Osipov Shaun Warman +Shawn Cheung <958033967@qq.com> +Shawn Sit Slobodan Stojanovic Sonny Piers Sterling Williams Stéphane Bisinger TJ Holowaychuk TJ Holowaychuk +Taehwan, No Tejas Manohar Teoman Soygul Thiago Lagden Tiago Ribeiro Tim Schaub Todor Stoychev +Tomas Ruud Travis Jeffery Usman Hussain Veselin Todorov @@ -131,6 +155,7 @@ gyson haoxin haoxin iamchenxin +initial-wu jeromew joehecn jongleberry diff --git a/History.md b/History.md index 0ffa2539b..d7715afab 100644 --- a/History.md +++ b/History.md @@ -1,4 +1,264 @@ +3.0.0-alpha.1 / 2023-04-12 +================== + +**fixes** + * [[`e98b8d1`](http://github.com/koajs/koa/commit/e98b8d1918376dc2957aa62906bf5893bef66c4c)] - fix: can not get currentContext in error handler (#1758) (Gxkl <>) + +3.0.0-alpha.0 / 2023-01-02 +================== + +## Breaking Changes + +- Supports node@12+ only. +- Removes generator deprecation messages. + Generators are no longer supported. + Koa no longer asserts if generators are used. +- Set `content-length: 0` if body is explicitly set to `null` @ognjenjevremovic #1528 + +## Features + +- Use asyncLocalStorage to get current context from app, e.g.: `const ctx = app.currentContext`. + +## Fixes + +- fix: Do not response Content-Length if Transfer-Encoding is defined #1562 @charlyzeng +- fix: Set body to `null` if `ctx.type = json` and `ctx.body = null` #1059 @likegun + +2.13.1 / 2021-01-04 +================== + +**fixes** + * [[`b5472f4`](http://github.com/koajs/koa/commit/b5472f4cbb87349becae36b4a9ad5f76a825abb8)] - fix: make ESM transpiled CommonJS play nice for TS folks, fix #1513 (#1518) (miwnwski <>) + * [[`68d97d6`](http://github.com/koajs/koa/commit/68d97d69e4536065504bf9ef1e348a66b3f35709)] - fix: fixed order of vulnerability disclosure addresses (niftylettuce <>) + +**others** + * [[`b4398f5`](http://github.com/koajs/koa/commit/b4398f5d68f9546167419f394a686afdcb5e10e2)] - correct verb tense in doc (#1512) (Matan Shavit <<71092861+matanshavit@users.noreply.github.com>>) + * [[`39e1a5a`](http://github.com/koajs/koa/commit/39e1a5a380aa2bbc4e2d164e8e4bf37cfd512516)] - fixed multiple grammatical errors in docs. (#1497) (Hridayesh Sharma <>) + * [[`aeb5d19`](http://github.com/koajs/koa/commit/aeb5d1984dcc5f8e3386f8f9724807ae6f3aa1c4)] - docs: added niftylettuce@gmail.com to vulnerability disclosure (niftylettuce <>) + * [[`6e1093b`](http://github.com/koajs/koa/commit/6e1093be27b41135c8e67fce108743d54e9cab67)] - docs: remove babel from readme (#1494) (miwnwski <>) + * [[`38cb591`](http://github.com/koajs/koa/commit/38cb591254ff5f65a04e8fb57be293afe697c46e)] - docs: update specific for auto response status (AlbertAZ1992 <>) + * [[`2224cd9`](http://github.com/koajs/koa/commit/2224cd9b6a648e7ac2eb27eac332e7d6de7db26c)] - docs: remove babel ref. (#1488) (Imed Jaberi <>) + * [[`d51f983`](http://github.com/koajs/koa/commit/d51f98328c3b84493cc6bda0732aabb69e20e3a1)] - docs: fix assert example for response (#1489) (Imed Jaberi <>) + * [[`f8b49b8`](http://github.com/koajs/koa/commit/f8b49b859363ad6c3d9ea5c11ee62341407ceafd)] - chore: fix grammatical and spelling errors in comments and tests (#1490) (Matt Kubej <>) + * [[`d1c9263`](http://github.com/koajs/koa/commit/d1c92638c95d799df2fdff5576b96fc43a62813f)] - deps: update depd >> v2.0.0 (#1482) (imed jaberi <>) + +2.13.0 / 2020-06-21 +================== + +**features** + * [[`bbcde76`](http://github.com/koajs/koa/commit/bbcde76f5cb5b67bbcd3201791cf0ef648fd3a8b)] - feat: support esm (#1474) (ZYSzys <>) + +**others** + * [[`20e58cf`](http://github.com/koajs/koa/commit/20e58cf3e4f20fc5d5886df1d0ac6dd8c33bd202)] - test: imporve coverage to 100% (dead-horse <>) + * [[`4a40d63`](http://github.com/koajs/koa/commit/4a40d633c4b4a203c6656078f9952ccef65c5875)] - build: use prepare instead of prepublish (dead-horse <>) + * [[`226ba8c`](http://github.com/koajs/koa/commit/226ba8c8e81e83da48e7bf137be3f146d03f40b8)] - build: use prepublish instead of prepack (dead-horse <>) + +2.12.1 / 2020-06-13 +================== + +**fixes** + * [[`e2030c7`](http://github.com/koajs/koa/commit/e2030c7249c7ae24e28158d8eae405a02fefc9f8)] - fix: Improve checks for Error in onerror handlers (#1468) (Julien Wajsberg <>) + +**others** + * [[`5208c5e`](http://github.com/koajs/koa/commit/5208c5e15d35b3653fce6b8ed68d09865abea843)] - chore: Use single console.error() statement in error handler (#1471) (Mike Vosseller <>) + +2.12.0 / 2020-05-18 +================== + +**features** + * [[`0d2f421`](http://github.com/koajs/koa/commit/0d2f421c265350d3d84e1bc261572954479f27d3)] - feat: error handler treat err.statusCode as the same as err.status (#1460) (Vijay Krishnavanshi <>) + * [[`8d52105`](http://github.com/koajs/koa/commit/8d52105a34234be9e771ff3b76b43e4e30328943)] - feat: allow bodyless responses for non empty status codes (#1447) (ejose19 <<8742215+ejose19@users.noreply.github.com>>) + +**others** + * [[`faeaff5`](http://github.com/koajs/koa/commit/faeaff5c149a81a188ab8e5af0b994029e45acbb)] - fox: remove `error-inject` and fix error handling (#1409) (Konstantin Vyatkin <>) + * [[`f7c732f`](http://github.com/koajs/koa/commit/f7c732fd06f724505e9090add4d977e667da55a8)] - docs: fixed incorrect onerror example (#1459) (Paul Annekov <>) + * [[`143d8f7`](http://github.com/koajs/koa/commit/143d8f72f2a232b4c97eac00e7811015911e4f7c)] - Always use strict equality. (#1225) (Yazan Medanat <>) + * [[`6b6b0dd`](http://github.com/koajs/koa/commit/6b6b0ddf7aff073e65493c6efaffab8331c0331c)] - docs(api): add app.use chainability note (#1449) (Zac Anger <>) + * [[`8ddab48`](http://github.com/koajs/koa/commit/8ddab48cbdbca1e6d1cc8c3ddae45491db524d51)] - docs: Document response status with empty body (#1445) (Marc-Aurèle DARCHE <<152407+madarche@users.noreply.github.com>>) + * [[`7deedb2`](http://github.com/koajs/koa/commit/7deedb235274223f1b9da46dee296545b23598de)] - docs: Updating context.md with the latest cookies opts (#1433) (Brad Ito <>) + * [[`3e97a10`](http://github.com/koajs/koa/commit/3e97a106bb846d9337737011bb85149ddd797229)] - docs(links): remove Google+ link (#1439) (laffachan <<45162759+laffachan@users.noreply.github.com>>) + * [[`eda2760`](http://github.com/koajs/koa/commit/eda27608f7d39ede86d7b402aae64b1867ce31c6)] - build: Drop unused Travis sudo: false directive (#1416) (Olle Jonsson <>) + +2.11.0 / 2019-10-28 +================== + +**features** + * [[`422e539`](http://github.com/koajs/koa/commit/422e539e8989e65ba43ecc39ddbaa3c4f755d465)] - feat: support app.proxyIPHeader and app.maxIpsCount to make ctx.ips more security (Yiyu He <>) + * [[`d48d88e`](http://github.com/koajs/koa/commit/d48d88ee17b780c02123e6d657274cab456e943e)] - feat: implement response.has (#1397) (Konstantin Vyatkin <>) + +**others** + * [[`4dc56f6`](http://github.com/koajs/koa/commit/4dc56f6d04e8f5fe12ba53a8a776653b3d7b60ed)] - chore: update ESLint and plugins/configs (#1407) (Konstantin Vyatkin <>) + * [[`be7d334`](http://github.com/koajs/koa/commit/be7d334778481639294cdf87f5c359a230aeb65b)] - chore: removes code duplication at handling HEAD method (#1400) (Konstantin Vyatkin <>) + * [[`f155785`](http://github.com/koajs/koa/commit/f155785e2bb42b5ddf0a8156401c6dafdf57ba8b)] - chore: support `writableEnded` (#1402) (Konstantin Vyatkin <>) + * [[`b968688`](http://github.com/koajs/koa/commit/b968688afe2c727ae141f50aa983d481dbc1dbbf)] - chore: add FUNDING.yml (#1403) (Konstantin Vyatkin <>) + * [[`4f96829`](http://github.com/koajs/koa/commit/4f968298f97394e488297ec32c8e927a3a322076)] - chore: remove isJSON in res.length (#1399) (Konstantin Vyatkin <>) + * [[`8be5626`](http://github.com/koajs/koa/commit/8be5626bbb54e6c899a1b71d22411709126d9fea)] - build: enable codecov partial coverage and use bash uploader (#1396) (Konstantin Vyatkin <>) + * [[`ef5c43b`](http://github.com/koajs/koa/commit/ef5c43bcbcf31819e032c3b7ae7654b7f8e9358b)] - chore: use rest params (#1393) (Konstantin Vyatkin <>) + +2.10.0 / 2019-10-12 +================== + +**features** + * [[`d7f7f77`](http://github.com/koajs/koa/commit/d7f7f77689e2eaef050686be2bdf3e72881a79ac)] - feat: support sameSite=none cookies (bump cookies dependency) (#1390) (Filip Skokan <>) + +2.9.0 / 2019-10-12 +================== + +**features** + * [[`2d1c598`](http://github.com/koajs/koa/commit/2d1c5981869e0fe6f5bc71b5c5582accfd125cc6)] - feat: export HttpError from http-errors library (Micheal Hill <>) + +**others** + * [[`cf70dbc`](http://github.com/koajs/koa/commit/cf70dbc6d2ba62bf1eb12b563dd5ecd27af6e2be)] - Chore: Use https in readme (#1389) (谭九鼎 <<109224573@qq.com>>) + +2.8.2 / 2019-09-28 +================== + +**fixes** + * [[`54e8fab`](http://github.com/koajs/koa/commit/54e8fab3e3d907bbb264caf3e28a24773d0d6fdb)] - fix: encode redirect url if not already encoded (#1384) (fengmk2 <>) + +**others** + * [[`817b498`](http://github.com/koajs/koa/commit/817b49830571b45a8aec6b1fc1525434f5798c58)] - test: fix body test (#1375) (Robert Nagy <>) + * [[`f75d445`](http://github.com/koajs/koa/commit/f75d4455359ecdf30eeb676e2c7f31d4cf7b42ed)] - test: fix end after end (#1374) (Robert Nagy <>) + +2.8.1 / 2019-08-19 +================== + +**fixes** + * [[`287e589`](http://github.com/koajs/koa/commit/287e589ac773d3738b2aa7d40e0b6d43dde5261b)] - fix: make options more compatibility (dead-horse <>) + +2.8.0 / 2019-08-19 +================== + +**features** + * [[`5afff89`](http://github.com/koajs/koa/commit/5afff89eca0efe7081309dc2d123309e825df221)] - feat: accept options in the Application constructor (#1372) (Jake <>) + +**fixes** + * [[`ff70bdc`](http://github.com/koajs/koa/commit/ff70bdc75a30a37f63fc1f7d8cbae3204df3d982)] - fix: typo on document (#1355) (Jeff <>) + +**others** + * [[`3b23865`](http://github.com/koajs/koa/commit/3b23865340cfba075f61f7dba0ea31fcc27260ec)] - docs: parameter of request.get is case-insensitive (#1373) (Gunnlaugur Thor Briem <>) + * [[`a245d18`](http://github.com/koajs/koa/commit/a245d18a131341feec4f87659746954e78cae780)] - docs: Update response.socket (#1357) (Jeff <>) + * [[`d1d65dd`](http://github.com/koajs/koa/commit/d1d65dd29d7bbaf9ea42eaa5fcb0da3fb4df98e9)] - chore(deps): install egg-bin, mm as devDeps not deps (#1366) (Edvard Chen <>) + * [[`2c86b10`](http://github.com/koajs/koa/commit/2c86b10feafd868ebd071dda3a222e6f51972b5d)] - test: remove jest and use egg-bin(mocha) (#1363) (Yiyu He <>) + * [[`219bf22`](http://github.com/koajs/koa/commit/219bf22237b11bc375e2e110b93db512f1acfdd4)] - docs(context): update link (#1354) (Peng Jie <>) + * [[`52a6737`](http://github.com/koajs/koa/commit/52a673703a87a93c0f6a8552e6bd73caba66d2eb)] - chore: ignore Intellij IDEA project files (#1361) (Imon-Haque <<38266345+Imon-Haque@users.noreply.github.com>>) + * [[`b9e3546`](http://github.com/koajs/koa/commit/b9e35469d3bbd0a1ee92e0a815ce2512904d4a18)] - docs(api): fix keygrip link (#1350) (Peng Jie <>) + * [[`d4bdb5e`](http://github.com/koajs/koa/commit/d4bdb5ed9e2fe06ec44698b66c029f624135a0ab)] - chore: update eslint and fix lint errors (dead-horse <>) + * [[`12960c4`](http://github.com/koajs/koa/commit/12960c437cc25c53e682cfe5bff06d74a5bb1eb9)] - build: test on 8/10/12 (dead-horse <>) + * [[`00e8f7a`](http://github.com/koajs/koa/commit/00e8f7a1b7603aabdb7fb3567f485cb1c2076702)] - docs: ctx.type aliases ctx.response, not ctx.request (#1343) (Alex Berk <>) + * [[`62f29eb`](http://github.com/koajs/koa/commit/62f29eb0c4dee01170a5511615e5bcc9faca26ca)] - docs(context): update cookies link (#1348) (Peng Jie <>) + * [[`b7fc526`](http://github.com/koajs/koa/commit/b7fc526ea49894f366153bd32997e02568c0b8a6)] - docs: fix typo in cookie path default value docs (#1340) (Igor Adamenko <>) + * [[`23f7f54`](http://github.com/koajs/koa/commit/23f7f545abfe1fb6499cd61cc8ff41fd86cef4a0)] - chore: simplify variable (#1332) (kzhang <>) + * [[`132c9ee`](http://github.com/koajs/koa/commit/132c9ee63f92a586a120ed3bd6b7ef023badb8bb)] - docs: Clarify the format of request.headers (#1325) (Dobes Vandermeer <>) + * [[`5810f27`](http://github.com/koajs/koa/commit/5810f279a4caeda115f39e429c9671795613abf8)] - docs: Removed Document in Progress note in Koa vs Express (#1336) (Andrew Peterson <>) + * [[`75233d9`](http://github.com/koajs/koa/commit/75233d974a30af6e3b8ab38a73e5ede67172fc1c)] - chore: Consider removing this return statement; it will be ignored. (#1322) (Vern Brandl <>) + * [[`04e07fd`](http://github.com/koajs/koa/commit/04e07fdc620841068f12b8edf36f27e6592a0a18)] - test: Buffer() is deprecated due to security and usability issues. so use the Buffer.alloc() instead (#1321) (Vern Brandl <>) + * [[`130e363`](http://github.com/koajs/koa/commit/130e363856747b487652f04b5550056d7778e43a)] - docs: use 'fs-extra' instead of 'fs-promise' (#1309) (rosald <<35028438+rosald@users.noreply.github.com>>) + * [[`2f2078b`](http://github.com/koajs/koa/commit/2f2078bf998bd3f44289ebd17eeccf5e12e4c134)] - chore: Update PR-welcome badge url (#1299) (James George <>) + +2.7.0 / 2019-01-28 +================== + +**features** + * [[`b7bfa71`](http://github.com/koajs/koa/commit/b7bfa7113b8d1af49a57ab767f24a599ed92044f)] - feat: change set status assert, allowing valid custom statuses (#1308) (Martin Iwanowski <>) + +**others** + * [[`72f325b`](http://github.com/koajs/koa/commit/72f325b78edd0dc2aac940a76ce5f644005ce4c3)] - chore: add pr welcoming badge (#1291) (James George <>) + * [[`b15115b`](http://github.com/koajs/koa/commit/b15115b2cbfffe15827cd5e4368267d417b72f08)] - chore: Reduce unnecessary variable declarations (#1298) (call me saisai <<1457358080@qq.com>>) + * [[`ad91ce2`](http://github.com/koajs/koa/commit/ad91ce2346cb34e5d5a49d07dd952d15f6c832a3)] - chore: license 2019 (dead-horse <>) + * [[`b25e79d`](http://github.com/koajs/koa/commit/b25e79dfb599777a38157bd419395bd28369ee86)] - Mark two examples as live for the corresponding documentation change in https://github.com/koajs/koajs.com/pull/38. (#1031) (Francisco Ryan Tolmasky I <>) + * [[`d9ef603`](http://github.com/koajs/koa/commit/d9ef60398e88f2c2f958ab2b159d38052ffe7f8a)] - chore: Optimize array split (#1295) (Mikhail Bodrov <>) + * [[`9be8583`](http://github.com/koajs/koa/commit/9be858312553002841725b617050aaff3c48951d)] - chore: replace ~~ with Math.trunc in res.length (option) (#1288) (jeremiG <>) + * [[`7e46c20`](http://github.com/koajs/koa/commit/7e46c2058cb5994809eab5f4dbb12f21e937c72b)] - docs: add link to the license file (#1290) (James George <>) + * [[`48993ad`](http://github.com/koajs/koa/commit/48993ade9b0831fbce28d94b3b0963a4b0dccbdd)] - docs: Document other body types (#1285) (Douglas Wade <>) + * [[`acb388b`](http://github.com/koajs/koa/commit/acb388bc0546b48fca11dce8aa7a595af2cda5e2)] - docs: Add security vulnerability disclosure instructions to the Readme (#1283) (Douglas Wade <>) + * [[`a007198`](http://github.com/koajs/koa/commit/a007198fa23c19902b1f3ffb81498629e0e9c875)] - docs: Document ctx.app.emit (#1284) (Douglas Wade <>) + * [[`f90e825`](http://github.com/koajs/koa/commit/f90e825da9d505c11b4262c50cd54553f979c300)] - docs: response.set(fields) won't overwrites previous header fields(#1282) (Douglas Wade <>) + * [[`fc93c05`](http://github.com/koajs/koa/commit/fc93c05f68398f30abc46fd16ae6c673a1eee099)] - docs: update readme to add babel 7 instructions (#1274) (Vikram Rangaraj <>) + * [[`5560f72`](http://github.com/koajs/koa/commit/5560f729124f022ffed00085aafea43dded7fb03)] - chore: use the ability of `content-type` lib directly (#1276) (Jordan <>) + +2.6.2 / 2018-11-10 +================== + +**fixes** + * [[`9905199`](http://github.com/koajs/koa/commit/99051992a9f45eb0dd79e062681d6f5d366deb41)] - fix: Status message is not supported on HTTP/2 (#1264) (André Cruz <>) + +**others** + * [[`325792a`](http://github.com/koajs/koa/commit/325792aee92de0ba6fea306657933fc63dc00474)] - docs: add table of contents for guide.md (#1267) (ZYSzys <>) + * [[`71aaa29`](http://github.com/koajs/koa/commit/71aaa29591d6681f8579486f18d32ba1ee651a5b)] - docs: fix spelling in throw docs (#1269) (Martin Iwanowski <>) + * [[`bc81ca9`](http://github.com/koajs/koa/commit/bc81ca9414296234c764b7306a19ba72b2e59b52)] - chore: use res instead of this.res (#1271) (Jordan <>) + * [[`0251b38`](http://github.com/koajs/koa/commit/0251b38a8405471892c5eeaba7c8d54bd7028214)] - test: node v11 on travis (#1265) (Martin Iwanowski <>) + * [[`88b92b4`](http://github.com/koajs/koa/commit/88b92b43153f21609aee71d47abcd4dc27a6586d)] - doc: updated docs for throw() to pass status as first param. (#1268) (Waleed Ashraf <>) + +2.6.1 / 2018-10-23 +================== + +**fixes** + * [[`4964242`](http://github.com/koajs/koa/commit/49642428342e5f291eb9d690802e83ed830623b5)] - fix: use X-Forwarded-Host first on app.proxy present (#1263) (fengmk2 <>) + +2.6.0 / 2018-10-23 +================== + +**features** + * [[`9c5c58b`](http://github.com/koajs/koa/commit/9c5c58b18363494976185e7ddc790ac63de840ed)] - feat: use :authority header of http2 requests as host (#1262) (Martin Michaelis <>) + * [[`9146024`](http://github.com/koajs/koa/commit/9146024e1094e8bb871ab15d1b7fc556a710732f)] - feat: response.attachment append a parameter: options from contentDisposition (#1240) (小雷 <<863837949@qq.com>>) + +**others** + * [[`d32623b`](http://github.com/koajs/koa/commit/d32623baa7a6273d47be67d587ad4ea0ecffc5de)] - docs: Update error-handling.md (#1239) (urugator <>) + +2.5.3 / 2018-09-11 +================== + +**fixes** + * [[`2ee32f5`](http://github.com/koajs/koa/commit/2ee32f50b88b383317e33cc0a4bfaa5f2eadead7)] - fix: pin debug@~3.1.0 avoid deprecated warnning (#1245) (fengmk2 <>) + +**others** + * [[`2180839`](http://github.com/koajs/koa/commit/2180839eda2cb16edcfda46ccfe24711680af850)] - docs: Update koa-vs-express.md (#1230) (Clayton Ray <>) + +2.5.2 / 2018-07-12 +================== + + * deps: upgrade all dependencies + * perf: avoid stringify when set header (#1220) + * perf: cache content type's result (#1218) + * perf: lazy init cookies and ip when first time use it (#1216) + * chore: fix comment & approve cov (#1214) + * docs: fix grammar + * test&cov: add test case (#1211) + * Lazily initialize `request.accept` and delegate `context.accept` (#1209) + * fix: use non deprecated custom inspect (#1198) + * Simplify processes in the getter `request.protocol` (#1203) + * docs: better demonstrate middleware flow (#1195) + * fix: Throw a TypeError instead of a AssertionError (#1199) + * chore: mistake in a comment (#1201) + * chore: use this.res.socket insteadof this.ctx.req.socket (#1177) + * chore: Using "listenerCount" instead of "listeners" (#1184) + +2.5.1 / 2018-04-27 +================== + + * test: node v10 on travis (#1182) + * fix tests: remove unnecessary assert doesNotThrow and api calls (#1170) + * use this.response insteadof this.ctx.response (#1163) + * deps: remove istanbul (#1151) + * Update guide.md (#1150) + +2.5.0 / 2018-02-11 +================== + + * feat: ignore set header/status when header sent (#1137) + * run coverage using --runInBand (#1141) + * [Update] license year to 2018 (#1130) + * docs: small grammatical fix in api docs index (#1111) + * docs: fixed typo (#1112) + * docs: capitalize K in word koa (#1126) + * Error handling: on non-error throw try to stringify if error is an object (#1113) + * Use eslint-config-koa (#1105) + * Update mgol's name in AUTHORS, add .mailmap (#1100) + * Avoid generating package locks instead of ignoring them (#1108) + * chore: update copyright year to 2017 (#1095) + 2.4.1 / 2017-11-06 ================== diff --git a/LICENSE b/LICENSE index 65c374b62..a70628748 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ (The MIT License) -Copyright (c) 2017 Koa contributors +Copyright (c) 2019 Koa contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/Readme.md b/Readme.md index cdc4117ed..7e1001269 100644 --- a/Readme.md +++ b/Readme.md @@ -1,11 +1,12 @@ -koa middleware framework for nodejs +Koa middleware framework for nodejs [![gitter][gitter-image]][gitter-url] [![NPM version][npm-image]][npm-url] - [![build status][travis-image]][travis-url] + [![build status][github-action-image]][github-action-url] [![Test coverage][coveralls-image]][coveralls-url] [![OpenCollective Backers][backers-image]](#backers) [![OpenCollective Sponsors][sponsors-image]](#sponsors) + [![PR's Welcome][pr-welcoming-image]][pr-welcoming-url] Expressive HTTP middleware framework for node.js to make web applications and APIs more enjoyable to write. Koa's middleware stack flows in a stack-like manner, allowing you to perform actions downstream then filter and manipulate the response upstream. @@ -16,13 +17,13 @@ ## Installation -Koa requires __node v7.6.0__ or higher for ES2015 and async function support. +Koa requires __node v12.17.0__ or higher for ES2015 and async function support. ``` $ npm install koa ``` -## Hello koa +## Hello Koa ```js const Koa = require('koa'); @@ -38,10 +39,8 @@ app.listen(3000); ## Getting started - - [Kick-Off-Koa](https://github.com/koajs/kick-off-koa) - An intro to koa via a set of self-guided workshops. - - [Workshop](https://github.com/koajs/workshop) - A workshop to learn the basics of koa, Express' spiritual successor. - - [Introduction Screencast](http://knowthen.com/episode-3-koajs-quickstart-guide/) - An introduction to installing and getting started with Koa - + - [Kick-Off-Koa](https://github.com/koajs/kick-off-koa) - An intro to Koa via a set of self-guided workshops. + - [Guide](docs/guide.md) - Go straight to the docs. ## Middleware @@ -135,7 +134,7 @@ app.use(async (ctx, next) => { ``` The `Context` object also provides shortcuts for methods on its `request` and `response`. In the prior -examples, `ctx.type` can be used instead of `ctx.request.type` and `ctx.accepts` can be used +examples, `ctx.type` can be used instead of `ctx.response.type` and `ctx.accepts` can be used instead of `ctx.request.accepts`. For more information on `Request`, `Response` and `Context`, see the [Request API Reference](docs/api/request.md), @@ -159,34 +158,6 @@ Learn more about the application object in the [Application API Reference](docs/ - [FAQ](docs/faq.md) - [API documentation](docs/api/index.md) -## Babel setup - -If you're not using `node v7.6+`, we recommend setting up `babel` with [`babel-preset-env`](https://github.com/babel/babel-preset-env): - -```bash -$ npm install babel-register babel-preset-env --save -``` - -Setup `babel-register` in your entry file: - -```js -require('babel-register'); -``` - -And have your `.babelrc` setup: - -```json -{ - "presets": [ - ["env", { - "targets": { - "node": true - } - }] - ] -} -``` - ## Troubleshooting Check the [Troubleshooting Guide](docs/troubleshooting.md) or [Debugging Koa](docs/guide.md#debugging-koa) in @@ -198,17 +169,21 @@ the general Koa guide. $ npm test ``` +## Reporting vulnerabilities + +To report a security vulnerability, please do not open an issue, as this notifies attackers of the vulnerability. Instead, please email [dead_horse](mailto:heyiyu.deadhorse@gmail.com), [jonathanong](mailto:me@jongleberry.com), and [niftylettuce](mailto:niftylettuce@gmail.com) to disclose. + ## Authors See [AUTHORS](AUTHORS). ## Community + - [KoaJS Slack Group](https://join.slack.com/t/koa-js/shared_invite/zt-5pjgthmb-1JeKDbByqqcARtlPbtf~vQ) - [Badgeboard](https://koajs.github.io/badgeboard) and list of official modules - [Examples](https://github.com/koajs/examples) - [Middleware](https://github.com/koajs/koa/wiki) list - [Wiki](https://github.com/koajs/koa/wiki) - - [G+ Community](https://plus.google.com/communities/101845768320796750641) - [Reddit Community](https://www.reddit.com/r/koajs) - [Mailing list](https://groups.google.com/forum/#!forum/koajs) - [中文文档 v1.x](https://github.com/guo-yu/koa-guide) @@ -296,12 +271,12 @@ Become a sponsor and get your logo on our README on Github with a link to your s # License - MIT + [MIT](https://github.com/koajs/koa/blob/master/LICENSE) [npm-image]: https://img.shields.io/npm/v/koa.svg?style=flat-square [npm-url]: https://www.npmjs.com/package/koa -[travis-image]: https://img.shields.io/travis/koajs/koa/master.svg?style=flat-square -[travis-url]: https://travis-ci.org/koajs/koa +[github-action-image]: https://github.com/koajs/koa/actions/workflows/node.js.yml/badge.svg +[github-action-url]: https://github.com/koajs/koa/actions/workflows/node.js.yml [coveralls-image]: https://img.shields.io/codecov/c/github/koajs/koa.svg?style=flat-square [coveralls-url]: https://codecov.io/github/koajs/koa?branch=master [backers-image]: https://opencollective.com/koajs/backers/badge.svg?style=flat-square @@ -309,3 +284,5 @@ Become a sponsor and get your logo on our README on Github with a link to your s [gitter-image]: https://img.shields.io/gitter/room/koajs/koa.svg?style=flat-square [gitter-url]: https://gitter.im/koajs/koa?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge [#koajs]: https://webchat.freenode.net/?channels=#koajs +[pr-welcoming-image]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square +[pr-welcoming-url]: https://github.com/koajs/koa/pull/new diff --git a/__tests__/.eslintrc.yml b/__tests__/.eslintrc.yml new file mode 100644 index 000000000..d18a96e14 --- /dev/null +++ b/__tests__/.eslintrc.yml @@ -0,0 +1,12 @@ +env: + jest: true + +rules: + space-before-blocks: [2, {functions: never, keywords: always}] + no-unused-expressions: 0 + node/no-deprecated-api: 'warn' + quote-props: 'warn' + no-prototype-builtins: 'warn' + array-bracket-spacing: 'warn' + object-curly-spacing: 'warn' + dot-notation: 'warn' diff --git a/__tests__/application/compose.js b/__tests__/application/compose.js new file mode 100644 index 000000000..f8ad05859 --- /dev/null +++ b/__tests__/application/compose.js @@ -0,0 +1,70 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('app.compose', () => { + it('should work with default compose ', async () => { + const app = new Koa() + const calls = [] + + app.use((ctx, next) => { + calls.push(1) + return next().then(() => { + calls.push(4) + }) + }) + + app.use((ctx, next) => { + calls.push(2) + return next().then(() => { + calls.push(3) + }) + }) + + const server = app.listen() + + await request(server) + .get('/') + .expect(404) + + assert.deepStrictEqual(calls, [1, 2, 3, 4]) + }) + + it('should work with configurable compose', async () => { + const calls = [] + let count = 0 + const app = new Koa({ + compose (fns) { + return async (ctx) => { + const dispatch = async () => { + count++ + const fn = fns.shift() + fn && fn(ctx, dispatch) + } + dispatch() + } + } + }) + + app.use((ctx, next) => { + calls.push(1) + next() + calls.push(4) + }) + app.use((ctx, next) => { + calls.push(2) + next() + calls.push(3) + }) + + const server = app.listen() + + await request(server) + .get('/') + + assert.deepStrictEqual(calls, [1, 2, 3, 4]) + assert.equal(count, 3) + }) +}) diff --git a/__tests__/application/context.js b/__tests__/application/context.js new file mode 100644 index 000000000..26ba2c6fd --- /dev/null +++ b/__tests__/application/context.js @@ -0,0 +1,33 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('app.context', () => { + const app1 = new Koa() + app1.context.msg = 'hello' + const app2 = new Koa() + + it('should merge properties', () => { + app1.use((ctx, next) => { + assert.strictEqual(ctx.msg, 'hello') + ctx.status = 204 + }) + + return request(app1.listen()) + .get('/') + .expect(204) + }) + + it('should not affect the original prototype', () => { + app2.use((ctx, next) => { + assert.strictEqual(ctx.msg, undefined) + ctx.status = 204 + }) + + return request(app2.listen()) + .get('/') + .expect(204) + }) +}) diff --git a/__tests__/application/currentContext.js b/__tests__/application/currentContext.js new file mode 100644 index 000000000..339a3ee07 --- /dev/null +++ b/__tests__/application/currentContext.js @@ -0,0 +1,109 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('app.currentContext', () => { + it('should throw error if AsyncLocalStorage not support', () => { + if (require('async_hooks').AsyncLocalStorage) return + assert.throws(() => new Koa({ asyncLocalStorage: true }), + /Requires node 12\.17\.0 or higher to enable asyncLocalStorage/) + }) + + it('should get currentContext return context when asyncLocalStorage enable', async () => { + if (!require('async_hooks').AsyncLocalStorage) return + + const app = new Koa({ asyncLocalStorage: true }) + + app.use(async ctx => { + assert(ctx === app.currentContext) + await new Promise(resolve => { + setTimeout(() => { + assert(ctx === app.currentContext) + resolve() + }, 1) + }) + await new Promise(resolve => { + assert(ctx === app.currentContext) + setImmediate(() => { + assert(ctx === app.currentContext) + resolve() + }) + }) + assert(ctx === app.currentContext) + app.currentContext.body = 'ok' + }) + + const requestServer = async () => { + assert(app.currentContext === undefined) + await request(app.callback()).get('/').expect('ok') + assert(app.currentContext === undefined) + } + + await Promise.all([ + requestServer(), + requestServer(), + requestServer(), + requestServer(), + requestServer() + ]) + }) + + it('should get currentContext return undefined when asyncLocalStorage disable', async () => { + const app = new Koa() + + app.use(async ctx => { + assert(app.currentContext === undefined) + ctx.body = 'ok' + }) + + await request(app.callback()).get('/').expect('ok') + }) + + it('should get currentContext return context in error handler when asyncLocalStorage enable', async () => { + const app = new Koa({ asyncLocalStorage: true }) + + app.use(async () => { + throw new Error('error message') + }) + + const handleError = new Promise((resolve, reject) => { + app.on('error', (err, ctx) => { + try { + assert.strictEqual(err.message, 'error message') + assert.strictEqual(app.currentContext, ctx) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + await request(app.callback()).get('/').expect('Internal Server Error') + await handleError + }) + + it('should get currentContext return undefined in error handler when asyncLocalStorage disable', async () => { + const app = new Koa() + + app.use(async () => { + throw new Error('error message') + }) + + const handleError = new Promise((resolve, reject) => { + app.on('error', (err, ctx) => { + try { + assert.strictEqual(err.message, 'error message') + assert.strictEqual(app.currentContext, undefined) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + await request(app.callback()).get('/').expect('Internal Server Error') + await handleError + }) +}) diff --git a/__tests__/application/index.js b/__tests__/application/index.js new file mode 100644 index 000000000..0fdd61369 --- /dev/null +++ b/__tests__/application/index.js @@ -0,0 +1,99 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('app', () => { + // ignore test on Node.js v18 + (/^v18\./.test(process.version) ? it.skip : it)('should handle socket errors', done => { + const app = new Koa() + + app.use((ctx, next) => { + // triggers ctx.socket.writable == false + ctx.socket.emit('error', new Error('boom')) + }) + + app.on('error', err => { + assert.strictEqual(err.message, 'boom') + done() + }) + + request(app.callback()) + .get('/') + .end(() => {}) + }) + + it('should not .writeHead when !socket.writable', done => { + const app = new Koa() + + app.use((ctx, next) => { + // set .writable to false + ctx.socket.writable = false + ctx.status = 204 + // throw if .writeHead or .end is called + ctx.res.writeHead = + ctx.res.end = () => { + throw new Error('response sent') + } + }) + + // hackish, but the response should occur in a single tick + setImmediate(done) + + request(app.callback()) + .get('/') + .end(() => {}) + }) + + it('should set development env when NODE_ENV missing', () => { + const NODE_ENV = process.env.NODE_ENV + process.env.NODE_ENV = '' + const app = new Koa() + process.env.NODE_ENV = NODE_ENV + assert.strictEqual(app.env, 'development') + }) + + it('should set env from the constructor', () => { + const env = 'custom' + const app = new Koa({ env }) + assert.strictEqual(app.env, env) + }) + + it('should set proxy flag from the constructor', () => { + const proxy = true + const app = new Koa({ proxy }) + assert.strictEqual(app.proxy, proxy) + }) + + it('should set signed cookie keys from the constructor', () => { + const keys = ['customkey'] + const app = new Koa({ keys }) + assert.strictEqual(app.keys, keys) + }) + + it('should set subdomainOffset from the constructor', () => { + const subdomainOffset = 3 + const app = new Koa({ subdomainOffset }) + assert.strictEqual(app.subdomainOffset, subdomainOffset) + }) + + it('should set compose from the constructor', () => { + const compose = () => (ctx) => {} + const app = new Koa({ compose }) + assert.strictEqual(app.compose, compose) + }) + + it('should have a static property exporting `HttpError` from http-errors library', () => { + const CreateError = require('http-errors') + + assert.notEqual(Koa.HttpError, undefined) + assert.deepStrictEqual(Koa.HttpError, CreateError.HttpError) + assert.throws(() => { throw new CreateError(500, 'test error') }, Koa.HttpError) + }) + + it('should export createAsyncCtxStorageMiddleware function', () => { + const app = new Koa() + assert.strictEqual(typeof app.createAsyncCtxStorageMiddleware, 'function') + }) +}) diff --git a/__tests__/application/inspect.js b/__tests__/application/inspect.js new file mode 100644 index 000000000..6ef1c777f --- /dev/null +++ b/__tests__/application/inspect.js @@ -0,0 +1,20 @@ +'use strict' + +const assert = require('assert') +const util = require('util') +const Koa = require('../..') +const app = new Koa() + +describe('app.inspect()', () => { + it('should work', () => { + const str = util.inspect(app) + assert.strictEqual("{ subdomainOffset: 2, proxy: false, env: 'test' }", str) + }) + + it('should return a json representation', () => { + assert.deepStrictEqual( + { subdomainOffset: 2, proxy: false, env: 'test' }, + app.inspect() + ) + }) +}) diff --git a/__tests__/application/onerror.js b/__tests__/application/onerror.js new file mode 100644 index 000000000..b38526792 --- /dev/null +++ b/__tests__/application/onerror.js @@ -0,0 +1,62 @@ +'use strict' + +const assert = require('assert') +const Koa = require('../..') + +describe('app.onerror(err)', () => { + it('should throw an error if a non-error is given', () => { + const app = new Koa() + + assert.throws(() => { + app.onerror('foo') + }, TypeError, 'non-error thrown: foo') + }) + + it('should accept errors coming from other scopes', () => { + const ExternError = require('vm').runInNewContext('Error') + + const app = new Koa() + const error = Object.assign(new ExternError('boom'), { + status: 418, + expose: true + }) + + assert.doesNotThrow(() => app.onerror(error)) + }) + + it('should do nothing if status is 404', () => { + const app = new Koa() + const err = new Error() + + err.status = 404 + + const spy = jest.spyOn(console, 'error') + app.onerror(err) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('should do nothing if .silent', () => { + const app = new Koa() + app.silent = true + const err = new Error() + + const spy = jest.spyOn(console, 'error') + app.onerror(err) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('should log the error to stderr', () => { + const app = new Koa() + app.env = 'dev' + + const err = new Error() + err.stack = 'Foo' + + const spy = jest.spyOn(console, 'error') + app.onerror(err) + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) +}) diff --git a/__tests__/application/request.js b/__tests__/application/request.js new file mode 100644 index 000000000..5ef103165 --- /dev/null +++ b/__tests__/application/request.js @@ -0,0 +1,33 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('app.request', () => { + const app1 = new Koa() + app1.request.message = 'hello' + const app2 = new Koa() + + it('should merge properties', () => { + app1.use((ctx, next) => { + assert.strictEqual(ctx.request.message, 'hello') + ctx.status = 204 + }) + + return request(app1.listen()) + .get('/') + .expect(204) + }) + + it('should not affect the original prototype', () => { + app2.use((ctx, next) => { + assert.strictEqual(ctx.request.message, undefined) + ctx.status = 204 + }) + + return request(app2.listen()) + .get('/') + .expect(204) + }) +}) diff --git a/__tests__/application/respond.js b/__tests__/application/respond.js new file mode 100644 index 000000000..46e6c6012 --- /dev/null +++ b/__tests__/application/respond.js @@ -0,0 +1,911 @@ +'use strict' + +const request = require('supertest') +const statuses = require('statuses') +const assert = require('assert') +const Koa = require('../..') +const fs = require('fs') + +describe('app.respond', () => { + describe('when ctx.respond === false', () => { + it('should function (ctx)', () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = 'Hello' + ctx.respond = false + + const res = ctx.res + res.statusCode = 200 + setImmediate(() => { + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Length', '3') + res.end('lol') + }) + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + .expect('lol') + }) + + it('should ignore set header after header sent', () => { + const app = new Koa() + app.use(ctx => { + ctx.body = 'Hello' + ctx.respond = false + + const res = ctx.res + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Length', '3') + res.end('lol') + ctx.set('foo', 'bar') + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + .expect('lol') + .expect(res => { + assert(!res.headers.foo) + }) + }) + + it('should ignore set status after header sent', () => { + const app = new Koa() + app.use(ctx => { + ctx.body = 'Hello' + ctx.respond = false + + const res = ctx.res + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.setHeader('Content-Length', '3') + res.end('lol') + ctx.status = 201 + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + .expect('lol') + }) + }) + + describe('when this.type === null', () => { + it('should not send Content-Type header', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = '' + ctx.type = null + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(200) + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + }) + + describe('when HEAD is used', () => { + it('should not respond with the body', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = 'Hello' + }) + + const server = app.listen() + + const res = await request(server) + .head('/') + .expect(200) + + assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8') + assert.strictEqual(res.headers['content-length'], '5') + assert(!res.text) + }) + + it('should keep json headers', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = { hello: 'world' } + }) + + const server = app.listen() + + const res = await request(server) + .head('/') + .expect(200) + + assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + assert.strictEqual(res.headers['content-length'], '17') + assert(!res.text) + }) + + it('should keep string headers', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = 'hello world' + }) + + const server = app.listen() + + const res = await request(server) + .head('/') + .expect(200) + + assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8') + assert.strictEqual(res.headers['content-length'], '11') + assert(!res.text) + }) + + it('should keep buffer headers', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = Buffer.from('hello world') + }) + + const server = app.listen() + + const res = await request(server) + .head('/') + .expect(200) + + assert.strictEqual(res.headers['content-type'], 'application/octet-stream') + assert.strictEqual(res.headers['content-length'], '11') + assert(!res.text) + }) + + it('should keep stream header if set manually', async () => { + const app = new Koa() + + const { length } = fs.readFileSync('package.json') + + app.use(ctx => { + ctx.length = length + ctx.body = fs.createReadStream('package.json') + }) + + const server = app.listen() + + const res = await request(server) + .head('/') + .expect(200) + + assert.strictEqual(~~res.header['content-length'], length) + assert(!res.text) + }) + + it('should respond with a 404 if no body was set', () => { + const app = new Koa() + + app.use(ctx => { + + }) + + const server = app.listen() + + return request(server) + .head('/') + .expect(404) + }) + + it('should respond with a 200 if body = ""', () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = '' + }) + + const server = app.listen() + + return request(server) + .head('/') + .expect(200) + }) + + it('should not overwrite the content-type', () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 200 + ctx.type = 'application/javascript' + }) + + const server = app.listen() + + return request(server) + .head('/') + .expect('content-type', /application\/javascript/) + .expect(200) + }) + }) + + describe('when no middleware is present', () => { + it('should 404', () => { + const app = new Koa() + + const server = app.listen() + + return request(server) + .get('/') + .expect(404) + }) + }) + + describe('when res has already been written to', () => { + it('should not cause an app error', () => { + const app = new Koa() + + app.use((ctx, next) => { + const res = ctx.res + ctx.status = 200 + res.setHeader('Content-Type', 'text/html') + res.write('Hello') + }) + + app.on('error', err => { throw err }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + }) + + it('should send the right body', () => { + const app = new Koa() + + app.use((ctx, next) => { + const res = ctx.res + ctx.status = 200 + res.setHeader('Content-Type', 'text/html') + res.write('Hello') + return new Promise(resolve => { + setTimeout(() => { + res.end('Goodbye') + resolve() + }, 0) + }) + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + .expect('HelloGoodbye') + }) + }) + + describe('when .body is missing', () => { + describe('with status=400', () => { + it('should respond with the associated status message', () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 400 + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(400) + .expect('Content-Length', '11') + .expect('Bad Request') + }) + }) + + describe('with status=204', () => { + it('should respond without a body', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 204 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(204) + .expect('') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + }) + + describe('with status=205', () => { + it('should respond without a body', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 205 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(205) + .expect('') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + }) + + describe('with status=304', () => { + it('should respond without a body', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 304 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(304) + .expect('') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + }) + + describe('with custom status=700', () => { + it('should respond with the associated status message', async () => { + const app = new Koa() + statuses['700'] = 'custom status' + + app.use(ctx => { + ctx.status = 700 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(700) + .expect('custom status') + + assert.strictEqual(res.res.statusMessage, 'custom status') + }) + }) + + describe('with custom statusMessage=ok', () => { + it('should respond with the custom status message', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 200 + ctx.message = 'ok' + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(200) + .expect('ok') + + assert.strictEqual(res.res.statusMessage, 'ok') + }) + }) + + describe('with custom status without message', () => { + it('should respond with the status code number', () => { + const app = new Koa() + + app.use(ctx => { + ctx.res.statusCode = 701 + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(701) + .expect('701') + }) + }) + }) + + describe('when .body is a null', () => { + it('should respond 204 by default', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = null + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(204) + .expect('') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + + it('should respond 204 with status=200', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 200 + ctx.body = null + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(204) + .expect('') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + + it('should respond 205 with status=205', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 205 + ctx.body = null + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(205) + .expect('') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + + it('should respond 304 with status=304', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 304 + ctx.body = null + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(304) + .expect('') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + }) + + describe('when .body is a string', () => { + it('should respond', () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = 'Hello' + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect('Hello') + }) + }) + + describe('when .body is a Buffer', () => { + it('should respond', () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = Buffer.from('Hello') + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + .expect(Buffer.from('Hello')) + }) + }) + + describe('when .body is a Stream', () => { + it('should respond', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = fs.createReadStream('package.json') + ctx.set('Content-Type', 'application/json; charset=utf-8') + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect('Content-Type', 'application/json; charset=utf-8') + + const pkg = require('../../package') + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'content-length'), false) + assert.deepStrictEqual(res.body, pkg) + }) + + it('should strip content-length when overwriting', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = 'hello' + ctx.body = fs.createReadStream('package.json') + ctx.set('Content-Type', 'application/json; charset=utf-8') + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect('Content-Type', 'application/json; charset=utf-8') + + const pkg = require('../../package') + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'content-length'), false) + assert.deepStrictEqual(res.body, pkg) + }) + + it('should keep content-length if not overwritten', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.length = fs.readFileSync('package.json').length + ctx.body = fs.createReadStream('package.json') + ctx.set('Content-Type', 'application/json; charset=utf-8') + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect('Content-Type', 'application/json; charset=utf-8') + + const pkg = require('../../package') + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'content-length'), true) + assert.deepStrictEqual(res.body, pkg) + }) + + it('should keep content-length if overwritten with the same stream', + async () => { + const app = new Koa() + + app.use(ctx => { + ctx.length = fs.readFileSync('package.json').length + const stream = fs.createReadStream('package.json') + ctx.body = stream + ctx.body = stream + ctx.set('Content-Type', 'application/json; charset=utf-8') + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect('Content-Type', 'application/json; charset=utf-8') + + const pkg = require('../../package') + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'content-length'), true) + assert.deepStrictEqual(res.body, pkg) + }) + + it('should handle errors', done => { + const app = new Koa() + + app.use(ctx => { + ctx.set('Content-Type', 'application/json; charset=utf-8') + ctx.body = fs.createReadStream('does not exist') + }) + + const server = app.listen() + + request(server) + .get('/') + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect(404) + .end(done) + }) + + it('should handle errors when no content status', () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 204 + ctx.body = fs.createReadStream('does not exist') + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(204) + }) + + it('should handle all intermediate stream body errors', done => { + const app = new Koa() + + app.use(ctx => { + ctx.body = fs.createReadStream('does not exist') + ctx.body = fs.createReadStream('does not exist') + ctx.body = fs.createReadStream('does not exist') + }) + + const server = app.listen() + + request(server) + .get('/') + .expect(404) + .end(done) + }) + }) + + describe('when .body is an Object', () => { + it('should respond with json', () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = { hello: 'world' } + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect('{"hello":"world"}') + }) + describe('and headers sent', () => { + it('should respond with json body and headers', () => { + const app = new Koa() + + app.use(ctx => { + ctx.length = 17 + ctx.type = 'json' + ctx.set('foo', 'bar') + ctx.res.flushHeaders() + ctx.body = { hello: 'world' } + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect('Content-Length', '17') + .expect('foo', 'bar') + .expect('{"hello":"world"}') + }) + }) + }) + + describe('when an error occurs', () => { + it('should emit "error" on the app', done => { + const app = new Koa() + + app.use(ctx => { + throw new Error('boom') + }) + + app.on('error', err => { + assert.strictEqual(err.message, 'boom') + done() + }) + + request(app.callback()) + .get('/') + .end(() => {}) + }) + + describe('with an .expose property', () => { + it('should expose the message', () => { + const app = new Koa() + + app.use(ctx => { + const err = new Error('sorry!') + err.status = 403 + err.expose = true + throw err + }) + + return request(app.callback()) + .get('/') + .expect(403, 'sorry!') + }) + }) + + describe('with a .status property', () => { + it('should respond with .status', () => { + const app = new Koa() + + app.use(ctx => { + const err = new Error('s3 explodes') + err.status = 403 + throw err + }) + + return request(app.callback()) + .get('/') + .expect(403, 'Forbidden') + }) + }) + + it('should respond with 500', () => { + const app = new Koa() + + app.use(ctx => { + throw new Error('boom!') + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(500, 'Internal Server Error') + }) + + it('should be catchable', () => { + const app = new Koa() + + app.use((ctx, next) => { + return next().then(() => { + ctx.body = 'Hello' + }).catch(() => { + ctx.body = 'Got error' + }) + }) + + app.use((ctx, next) => { + throw new Error('boom!') + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200, 'Got error') + }) + }) + + describe('when status and body property', () => { + it('should 200', () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 304 + ctx.body = 'hello' + ctx.status = 200 + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + .expect('hello') + }) + + it('should 204', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.status = 200 + ctx.body = 'hello' + ctx.set('content-type', 'text/plain; charset=utf8') + ctx.status = 204 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(204) + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + }) + }) + + describe('with explicit null body', () => { + it('should preserve given status', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = null + ctx.status = 404 + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(404) + .expect('') + .expect({}) + }) + it('should respond with correct headers', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = null + ctx.status = 401 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(401) + .expect('') + .expect({}) + + assert.equal(Object.prototype.hasOwnProperty.call(res.headers, 'transfer-encoding'), false) + assert.equal(Object.prototype.hasOwnProperty.call(res.headers, 'Content-Type'), false) + assert.equal(Object.prototype.hasOwnProperty.call(res.headers, 'content-length'), true) + }) + + it('should return content-length equal to 0', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = null + ctx.status = 401 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(401) + .expect('') + .expect({}) + + assert.equal(res.headers['content-length'], 0) + }) + it('should not overwrite the content-length', async () => { + const app = new Koa() + + app.use(ctx => { + ctx.body = null + ctx.length = 10 + ctx.status = 404 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(404) + .expect('') + .expect({}) + + assert.equal(res.headers['content-length'], 0) + }) + }) +}) diff --git a/__tests__/application/response.js b/__tests__/application/response.js new file mode 100644 index 000000000..91586ec64 --- /dev/null +++ b/__tests__/application/response.js @@ -0,0 +1,99 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('app.response', () => { + const app1 = new Koa() + app1.response.msg = 'hello' + const app2 = new Koa() + const app3 = new Koa() + const app4 = new Koa() + const app5 = new Koa() + const app6 = new Koa() + const app7 = new Koa() + + it('should merge properties', () => { + app1.use((ctx, next) => { + assert.strictEqual(ctx.response.msg, 'hello') + ctx.status = 204 + }) + + return request(app1.listen()) + .get('/') + .expect(204) + }) + + it('should not affect the original prototype', () => { + app2.use((ctx, next) => { + assert.strictEqual(ctx.response.msg, undefined) + ctx.status = 204 + }) + + return request(app2.listen()) + .get('/') + .expect(204) + }) + + it('should not include status message in body for http2', async () => { + app3.use((ctx, next) => { + ctx.req.httpVersionMajor = 2 + ctx.status = 404 + }) + const response = await request(app3.listen()) + .get('/') + .expect(404) + assert.strictEqual(response.text, '404') + }) + + it('should set ._explicitNullBody correctly', async () => { + app4.use((ctx, next) => { + ctx.body = null + assert.strictEqual(ctx.response._explicitNullBody, true) + }) + + return request(app4.listen()) + .get('/') + .expect(204) + }) + + it('should not set ._explicitNullBody incorrectly', async () => { + app5.use((ctx, next) => { + ctx.body = undefined + assert.strictEqual(ctx.response._explicitNullBody, undefined) + ctx.body = '' + assert.strictEqual(ctx.response._explicitNullBody, undefined) + ctx.body = false + assert.strictEqual(ctx.response._explicitNullBody, undefined) + }) + + return request(app5.listen()) + .get('/') + .expect(204) + }) + + it('should add Content-Length when Transfer-Encoding is not defined', () => { + app6.use((ctx, next) => { + ctx.body = 'hello world' + }) + + return request(app6.listen()) + .get('/') + .expect('Content-Length', '11') + .expect(200) + }) + + it('should not add Content-Length when Transfer-Encoding is defined', () => { + app7.use((ctx, next) => { + ctx.set('Transfer-Encoding', 'chunked') + ctx.body = 'hello world' + assert.strictEqual(ctx.response.get('Content-Length'), undefined) + }) + + return request(app7.listen()) + .get('/') + .expect('Transfer-Encoding', 'chunked') + .expect(200) + }) +}) diff --git a/__tests__/application/toJSON.js b/__tests__/application/toJSON.js new file mode 100644 index 000000000..ff26bfbb5 --- /dev/null +++ b/__tests__/application/toJSON.js @@ -0,0 +1,17 @@ +'use strict' + +const assert = require('assert') +const Koa = require('../..') + +describe('app.toJSON()', () => { + it('should work', () => { + const app = new Koa() + const obj = app.toJSON() + + assert.deepStrictEqual({ + subdomainOffset: 2, + proxy: false, + env: 'test' + }, obj) + }) +}) diff --git a/__tests__/application/use.js b/__tests__/application/use.js new file mode 100644 index 000000000..7ed9a0b17 --- /dev/null +++ b/__tests__/application/use.js @@ -0,0 +1,93 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('app.use(fn)', () => { + it('should compose middleware', async () => { + const app = new Koa() + const calls = [] + + app.use((ctx, next) => { + calls.push(1) + return next().then(() => { + calls.push(6) + }) + }) + + app.use((ctx, next) => { + calls.push(2) + return next().then(() => { + calls.push(5) + }) + }) + + app.use((ctx, next) => { + calls.push(3) + return next().then(() => { + calls.push(4) + }) + }) + + const server = app.listen() + + await request(server) + .get('/') + .expect(404) + + assert.deepStrictEqual(calls, [1, 2, 3, 4, 5, 6]) + }) + + it('should compose mixed middleware', async () => { + const app = new Koa() + const calls = [] + + app.use((ctx, next) => { + calls.push(1) + return next().then(() => { + calls.push(6) + }) + }) + + app.use(async (ctx, next) => { + calls.push(2) + await next() + calls.push(5) + }) + + app.use((ctx, next) => { + calls.push(3) + return next().then(() => { + calls.push(4) + }) + }) + + const server = app.listen() + + await request(server) + .get('/') + .expect(404) + + assert.deepStrictEqual(calls, [1, 2, 3, 4, 5, 6]) + }) + + // https://github.com/koajs/koa/pull/530#issuecomment-148138051 + it('should catch thrown errors in non-async functions', () => { + const app = new Koa() + + app.use(ctx => ctx.throw('Not Found', 404)) + + return request(app.callback()) + .get('/') + .expect(404) + }) + + it('should throw error for non-function', () => { + const app = new Koa(); + + [null, undefined, 0, false, 'not a function'].forEach(v => { + assert.throws(() => app.use(v), /middleware must be a function!/) + }) + }) +}) diff --git a/__tests__/context/assert.js b/__tests__/context/assert.js new file mode 100644 index 000000000..9de95f526 --- /dev/null +++ b/__tests__/context/assert.js @@ -0,0 +1,18 @@ +'use strict' + +const context = require('../../test-helpers/context') +const assert = require('assert') + +describe('ctx.assert(value, status)', () => { + it('should throw an error', () => { + const ctx = context() + + try { + ctx.assert(false, 404) + throw new Error('asdf') + } catch (err) { + assert.strictEqual(err.status, 404) + assert.strictEqual(err.expose, true) + } + }) +}) diff --git a/__tests__/context/cookies.js b/__tests__/context/cookies.js new file mode 100644 index 000000000..38bafe009 --- /dev/null +++ b/__tests__/context/cookies.js @@ -0,0 +1,118 @@ +'use strict' + +const assert = require('assert') +const request = require('supertest') +const Koa = require('../..') + +describe('ctx.cookies', () => { + describe('ctx.cookies.set()', () => { + it('should set an unsigned cookie', async () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.cookies.set('name', 'jon') + ctx.status = 204 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(204) + + const cookie = res.headers['set-cookie'].some(cookie => /^name=/.test(cookie)) + assert.strictEqual(cookie, true) + }) + + describe('with .signed', () => { + describe('when no .keys are set', () => { + it('should error', () => { + const app = new Koa() + + app.use((ctx, next) => { + try { + ctx.cookies.set('foo', 'bar', { signed: true }) + } catch (err) { + ctx.body = err.message + } + }) + + return request(app.callback()) + .get('/') + .expect('.keys required for signed cookies') + }) + }) + + it('should send a signed cookie', async () => { + const app = new Koa() + + app.keys = ['a', 'b'] + + app.use((ctx, next) => { + ctx.cookies.set('name', 'jon', { signed: true }) + ctx.status = 204 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(204) + + const cookies = res.headers['set-cookie'] + + assert.strictEqual(cookies.some(cookie => /^name=/.test(cookie)), true) + assert.strictEqual(cookies.some(cookie => /(,|^)name\.sig=/.test(cookie)), true) + }) + }) + + describe('with secure', () => { + it('should get secure from request', async () => { + const app = new Koa() + + app.proxy = true + app.keys = ['a', 'b'] + + app.use(ctx => { + ctx.cookies.set('name', 'jon', { signed: true }) + ctx.status = 204 + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .set('x-forwarded-proto', 'https') // mock secure + .expect(204) + + const cookies = res.headers['set-cookie'] + assert.strictEqual(cookies.some(cookie => /^name=/.test(cookie)), true) + assert.strictEqual(cookies.some(cookie => /(,|^)name\.sig=/.test(cookie)), true) + assert.strictEqual(cookies.every(cookie => /secure/.test(cookie)), true) + }) + }) + }) + + describe('ctx.cookies=', () => { + it('should override cookie work', async () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.cookies = { + set (key, value) { + ctx.set(key, value) + } + } + ctx.cookies.set('name', 'jon') + ctx.status = 204 + }) + + const server = app.listen() + + await request(server) + .get('/') + .expect('name', 'jon') + .expect(204) + }) + }) +}) diff --git a/__tests__/context/inspect.js b/__tests__/context/inspect.js new file mode 100644 index 000000000..2381e8bda --- /dev/null +++ b/__tests__/context/inspect.js @@ -0,0 +1,22 @@ +'use strict' + +const prototype = require('../../lib/context') +const assert = require('assert') +const util = require('util') +const context = require('../../test-helpers/context') + +describe('ctx.inspect()', () => { + it('should return a json representation', () => { + const ctx = context() + const toJSON = ctx.toJSON(ctx) + + assert.deepStrictEqual(toJSON, ctx.inspect()) + assert.deepStrictEqual(util.inspect(toJSON), util.inspect(ctx)) + }) + + // console.log(require.cache) will call prototype.inspect() + it('should not crash when called on the prototype', () => { + assert.deepStrictEqual(prototype, prototype.inspect()) + assert.deepStrictEqual(util.inspect(prototype.inspect()), util.inspect(prototype)) + }) +}) diff --git a/__tests__/context/onerror.js b/__tests__/context/onerror.js new file mode 100644 index 000000000..9b0b014ab --- /dev/null +++ b/__tests__/context/onerror.js @@ -0,0 +1,293 @@ +'use strict' + +const assert = require('assert') +const request = require('supertest') +const Koa = require('../..') +const context = require('../../test-helpers/context') + +describe('ctx.onerror(err)', () => { + it('should respond', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.body = 'something else' + + ctx.throw(418, 'boom') + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(418) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Content-Length', '4') + }) + + it('should unset all headers', async () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.set('Vary', 'Accept-Encoding') + ctx.set('X-CSRF-Token', 'asdf') + ctx.body = 'response' + + ctx.throw(418, 'boom') + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(418) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Content-Length', '4') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'vary'), false) + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'x-csrf-token'), false) + }) + + it('should set headers specified in the error', async () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.set('Vary', 'Accept-Encoding') + ctx.set('X-CSRF-Token', 'asdf') + ctx.body = 'response' + + throw Object.assign(new Error('boom'), { + status: 418, + expose: true, + headers: { + 'X-New-Header': 'Value' + } + }) + }) + + const server = app.listen() + + const res = await request(server) + .get('/') + .expect(418) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('X-New-Header', 'Value') + + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'vary'), false) + assert.strictEqual(Object.prototype.hasOwnProperty.call(res.headers, 'x-csrf-token'), false) + }) + + it('should ignore error after headerSent', done => { + const app = new Koa() + + app.on('error', err => { + assert.strictEqual(err.message, 'mock error') + assert.strictEqual(err.headerSent, true) + done() + }) + + app.use(async ctx => { + ctx.status = 200 + ctx.set('X-Foo', 'Bar') + ctx.flushHeaders() + await Promise.reject(new Error('mock error')) + ctx.body = 'response' + }) + + request(app.callback()) + .get('/') + .expect('X-Foo', 'Bar') + .expect(200, () => {}) + }) + + it('should set status specified in the error using statusCode', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.body = 'something else' + const err = new Error('Not found') + err.statusCode = 404 + throw err + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(404) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Not Found') + }) + + describe('when invalid err.statusCode', () => { + describe('not number', () => { + it('should respond 500', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.body = 'something else' + const err = new Error('some error') + err.statusCode = 'notnumber' + throw err + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(500) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Internal Server Error') + }) + }) + }) + + describe('when invalid err.status', () => { + describe('not number', () => { + it('should respond 500', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.body = 'something else' + const err = new Error('some error') + err.status = 'notnumber' + throw err + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(500) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Internal Server Error') + }) + }) + describe('when ENOENT error', () => { + it('should respond 404', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.body = 'something else' + const err = new Error('test for ENOENT') + err.code = 'ENOENT' + throw err + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(404) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Not Found') + }) + }) + describe('not http status code', () => { + it('should respond 500', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.body = 'something else' + const err = new Error('some error') + err.status = 9999 + throw err + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(500) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Internal Server Error') + }) + }) + }) + + describe('when error from another scope thrown', () => { + it('should handle it like a normal error', async () => { + const ExternError = require('vm').runInNewContext('Error') + + const app = new Koa() + const error = Object.assign(new ExternError('boom'), { + status: 418, + expose: true + }) + app.use((ctx, next) => { + throw error + }) + + const server = app.listen() + + const gotRightErrorPromise = new Promise((resolve, reject) => { + app.on('error', receivedError => { + try { + assert.strictEqual(receivedError, error) + resolve() + } catch (e) { + reject(e) + } + }) + }) + + await request(server) + .get('/') + .expect(418) + + await gotRightErrorPromise + }) + }) + + describe('when non-error thrown', () => { + it('should respond with non-error thrown message', () => { + const app = new Koa() + + app.use((ctx, next) => { + throw 'string error' // eslint-disable-line no-throw-literal + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(500) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Internal Server Error') + }) + + it('should use res.getHeaderNames() accessor when available', () => { + let removed = 0 + const ctx = context() + + ctx.app.emit = () => {} + ctx.res = { + getHeaderNames: () => ['content-type', 'content-length'], + removeHeader: () => removed++, + end: () => {}, + emit: () => {} + } + + ctx.onerror(new Error('error')) + + assert.strictEqual(removed, 2) + }) + + it('should stringify error if it is an object', done => { + const app = new Koa() + + app.on('error', err => { + assert.strictEqual(err.message, 'non-error thrown: {"key":"value"}') + done() + }) + + app.use(async ctx => { + throw { key: 'value' } // eslint-disable-line no-throw-literal + }) + + request(app.callback()) + .get('/') + .expect(500) + .expect('Internal Server Error', () => {}) + }) + }) +}) diff --git a/__tests__/context/state.js b/__tests__/context/state.js new file mode 100644 index 000000000..c33ba3064 --- /dev/null +++ b/__tests__/context/state.js @@ -0,0 +1,21 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') + +describe('ctx.state', () => { + it('should provide a ctx.state namespace', () => { + const app = new Koa() + + app.use(ctx => { + assert.deepStrictEqual(ctx.state, {}) + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(404) + }) +}) diff --git a/__tests__/context/throw.js b/__tests__/context/throw.js new file mode 100644 index 000000000..cc3f1f031 --- /dev/null +++ b/__tests__/context/throw.js @@ -0,0 +1,197 @@ +'use strict' + +const context = require('../../test-helpers/context') +const assert = require('assert') + +describe('ctx.throw(msg)', () => { + it('should set .status to 500', () => { + const ctx = context() + + try { + ctx.throw('boom') + } catch (err) { + assert.strictEqual(err.status, 500) + assert.strictEqual(err.expose, false) + } + }) +}) + +describe('ctx.throw(err)', () => { + it('should set .status to 500', () => { + const ctx = context() + const err = new Error('test') + + try { + ctx.throw(err) + } catch (err) { + assert.strictEqual(err.status, 500) + assert.strictEqual(err.message, 'test') + assert.strictEqual(err.expose, false) + } + }) +}) + +describe('ctx.throw(err, status)', () => { + it('should throw the error and set .status', () => { + const ctx = context() + const error = new Error('test') + + try { + ctx.throw(error, 422) + } catch (err) { + assert.strictEqual(err.status, 422) + assert.strictEqual(err.message, 'test') + assert.strictEqual(err.expose, true) + } + }) +}) + +describe('ctx.throw(status, err)', () => { + it('should throw the error and set .status', () => { + const ctx = context() + const error = new Error('test') + + try { + ctx.throw(422, error) + } catch (err) { + assert.strictEqual(err.status, 422) + assert.strictEqual(err.message, 'test') + assert.strictEqual(err.expose, true) + } + }) +}) + +describe('ctx.throw(msg, status)', () => { + it('should throw an error', () => { + const ctx = context() + + try { + ctx.throw('name required', 400) + } catch (err) { + assert.strictEqual(err.message, 'name required') + assert.strictEqual(err.status, 400) + assert.strictEqual(err.expose, true) + } + }) +}) + +describe('ctx.throw(status, msg)', () => { + it('should throw an error', () => { + const ctx = context() + + try { + ctx.throw(400, 'name required') + } catch (err) { + assert.strictEqual(err.message, 'name required') + assert.strictEqual(400, err.status) + assert.strictEqual(true, err.expose) + } + }) +}) + +describe('ctx.throw(status)', () => { + it('should throw an error', () => { + const ctx = context() + + try { + ctx.throw(400) + } catch (err) { + assert.strictEqual(err.message, 'Bad Request') + assert.strictEqual(err.status, 400) + assert.strictEqual(err.expose, true) + } + }) + + describe('when not valid status', () => { + it('should not expose', () => { + const ctx = context() + + try { + const err = new Error('some error') + err.status = -1 + ctx.throw(err) + } catch (err) { + assert.strictEqual(err.message, 'some error') + assert.strictEqual(err.expose, false) + } + }) + }) +}) + +describe('ctx.throw(status, msg, props)', () => { + it('should mixin props', () => { + const ctx = context() + + try { + ctx.throw(400, 'msg', { prop: true }) + } catch (err) { + assert.strictEqual(err.message, 'msg') + assert.strictEqual(err.status, 400) + assert.strictEqual(err.expose, true) + assert.strictEqual(err.prop, true) + } + }) + + describe('when props include status', () => { + it('should be ignored', () => { + const ctx = context() + + try { + ctx.throw(400, 'msg', { + prop: true, + status: -1 + }) + } catch (err) { + assert.strictEqual(err.message, 'msg') + assert.strictEqual(err.status, 400) + assert.strictEqual(err.expose, true) + assert.strictEqual(err.prop, true) + } + }) + }) +}) + +describe('ctx.throw(msg, props)', () => { + it('should mixin props', () => { + const ctx = context() + + try { + ctx.throw('msg', { prop: true }) + } catch (err) { + assert.strictEqual(err.message, 'msg') + assert.strictEqual(err.status, 500) + assert.strictEqual(err.expose, false) + assert.strictEqual(err.prop, true) + } + }) +}) + +describe('ctx.throw(status, props)', () => { + it('should mixin props', () => { + const ctx = context() + + try { + ctx.throw(400, { prop: true }) + } catch (err) { + assert.strictEqual(err.message, 'Bad Request') + assert.strictEqual(err.status, 400) + assert.strictEqual(err.expose, true) + assert.strictEqual(err.prop, true) + } + }) +}) + +describe('ctx.throw(err, props)', () => { + it('should mixin props', () => { + const ctx = context() + + try { + ctx.throw(new Error('test'), { prop: true }) + } catch (err) { + assert.strictEqual(err.message, 'test') + assert.strictEqual(err.status, 500) + assert.strictEqual(err.expose, false) + assert.strictEqual(err.prop, true) + } + }) +}) diff --git a/__tests__/context/toJSON.js b/__tests__/context/toJSON.js new file mode 100644 index 000000000..165e4d81e --- /dev/null +++ b/__tests__/context/toJSON.js @@ -0,0 +1,37 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.toJSON()', () => { + it('should return a json representation', () => { + const ctx = context() + + ctx.req.method = 'POST' + ctx.req.url = '/items' + ctx.req.headers['content-type'] = 'text/plain' + ctx.status = 200 + ctx.body = '

Hey

' + + const obj = JSON.parse(JSON.stringify(ctx)) + const req = obj.request + const res = obj.response + + assert.deepStrictEqual({ + method: 'POST', + url: '/items', + header: { + 'content-type': 'text/plain' + } + }, req) + + assert.deepStrictEqual({ + status: 200, + message: 'OK', + header: { + 'content-type': 'text/html; charset=utf-8', + 'content-length': '10' + } + }, res) + }) +}) diff --git a/__tests__/load-with-esm.js b/__tests__/load-with-esm.js new file mode 100644 index 000000000..4c4d1040d --- /dev/null +++ b/__tests__/load-with-esm.js @@ -0,0 +1,49 @@ +const assert = require('assert') + +let importESM = () => {} + +describe.skip('Load with esm', () => { + beforeAll(function () { + // ESM support is flagged on v12.x. + const majorVersion = +process.version.split('.')[0].slice(1) + if (majorVersion < 12) { + this.skip() + } else { + // eslint-disable-next-line no-eval + importESM = eval('(specifier) => import(specifier)') + } + }) + + it('should default export koa', async () => { + const exported = await importESM('koa') + const required = require('../') + assert.strictEqual(exported.default, required) + }) + + it('should match exports own property names', async () => { + const exported = new Set(Object.getOwnPropertyNames(await importESM('koa'))) + const required = new Set(Object.getOwnPropertyNames(require('../'))) + + // Remove constructor properties + default export. + for (const k of ['prototype', 'length', 'name']) { + required.delete(k) + } + + // Commented out to "fix" CommonJS, ESM, bundling issue. + // @see https://github.com/koajs/koa/issues/1513 + // exported.delete('default'); + + assert.strictEqual(exported.size, required.size) + assert.strictEqual([...exported].every(property => required.has(property)), true) + }) + + it('CommonJS exports default property', async () => { + const required = require('../') + assert.strictEqual(Object.prototype.hasOwnProperty.call(required, 'default'), true) + }) + + it('CommonJS exports default property referencing self', async () => { + const required = require('../') + assert.strictEqual(required.default, required) + }) +}) diff --git a/__tests__/request/accept.js b/__tests__/request/accept.js new file mode 100644 index 000000000..ab3abf09f --- /dev/null +++ b/__tests__/request/accept.js @@ -0,0 +1,26 @@ +'use strict' + +const Accept = require('accepts') +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.accept', () => { + it('should return an Accept instance', () => { + const ctx = context() + ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain' + assert(ctx.accept instanceof Accept) + }) +}) + +describe('ctx.accept=', () => { + it('should replace the accept object', () => { + const ctx = context() + ctx.req.headers.accept = 'text/plain' + assert.deepStrictEqual(ctx.accepts(), ['text/plain']) + + const request = context.request() + request.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain' + ctx.accept = Accept(request.req) + assert.deepStrictEqual(ctx.accepts(), ['text/html', 'text/plain', 'image/jpeg', 'application/*']) + }) +}) diff --git a/__tests__/request/accepts.js b/__tests__/request/accepts.js new file mode 100644 index 000000000..f7b258c0f --- /dev/null +++ b/__tests__/request/accepts.js @@ -0,0 +1,93 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.accepts(types)', () => { + describe('with no arguments', () => { + describe('when Accept is populated', () => { + it('should return all accepted types', () => { + const ctx = context() + ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain' + assert.deepStrictEqual(ctx.accepts(), ['text/html', 'text/plain', 'image/jpeg', 'application/*']) + }) + }) + }) + + describe('with no valid types', () => { + describe('when Accept is populated', () => { + it('should return false', () => { + const ctx = context() + ctx.req.headers.accept = 'application/*;q=0.2, image/jpeg;q=0.8, text/html, text/plain' + assert.strictEqual(ctx.accepts('image/png', 'image/tiff'), false) + }) + }) + + describe('when Accept is not populated', () => { + it('should return the first type', () => { + const ctx = context() + assert.strictEqual(ctx.accepts('text/html', 'text/plain', 'image/jpeg', 'application/*'), 'text/html') + }) + }) + }) + + describe('when extensions are given', () => { + it('should convert to mime types', () => { + const ctx = context() + ctx.req.headers.accept = 'text/plain, text/html' + assert.strictEqual(ctx.accepts('html'), 'html') + assert.strictEqual(ctx.accepts('.html'), '.html') + assert.strictEqual(ctx.accepts('txt'), 'txt') + assert.strictEqual(ctx.accepts('.txt'), '.txt') + assert.strictEqual(ctx.accepts('png'), false) + }) + }) + + describe('when an array is given', () => { + it('should return the first match', () => { + const ctx = context() + ctx.req.headers.accept = 'text/plain, text/html' + assert.strictEqual(ctx.accepts(['png', 'text', 'html']), 'text') + assert.strictEqual(ctx.accepts(['png', 'html']), 'html') + }) + }) + + describe('when multiple arguments are given', () => { + it('should return the first match', () => { + const ctx = context() + ctx.req.headers.accept = 'text/plain, text/html' + assert.strictEqual(ctx.accepts('png', 'text', 'html'), 'text') + assert.strictEqual(ctx.accepts('png', 'html'), 'html') + }) + }) + + describe('when value present in Accept is an exact match', () => { + it('should return the type', () => { + const ctx = context() + ctx.req.headers.accept = 'text/plain, text/html' + assert.strictEqual(ctx.accepts('text/html'), 'text/html') + assert.strictEqual(ctx.accepts('text/plain'), 'text/plain') + }) + }) + + describe('when value present in Accept is a type match', () => { + it('should return the type', () => { + const ctx = context() + ctx.req.headers.accept = 'application/json, */*' + assert.strictEqual(ctx.accepts('text/html'), 'text/html') + assert.strictEqual(ctx.accepts('text/plain'), 'text/plain') + assert.strictEqual(ctx.accepts('image/png'), 'image/png') + }) + }) + + describe('when value present in Accept is a subtype match', () => { + it('should return the type', () => { + const ctx = context() + ctx.req.headers.accept = 'application/json, text/*' + assert.strictEqual(ctx.accepts('text/html'), 'text/html') + assert.strictEqual(ctx.accepts('text/plain'), 'text/plain') + assert.strictEqual(ctx.accepts('image/png'), false) + assert.strictEqual(ctx.accepts('png'), false) + }) + }) +}) diff --git a/test/request/acceptsCharsets.js b/__tests__/request/acceptsCharsets.js similarity index 53% rename from test/request/acceptsCharsets.js rename to __tests__/request/acceptsCharsets.js index 0f09200e2..a087dedd1 100644 --- a/test/request/acceptsCharsets.js +++ b/__tests__/request/acceptsCharsets.js @@ -1,52 +1,51 @@ +'use strict' -'use strict'; - -const assert = require('assert'); -const context = require('../helpers/context'); +const assert = require('assert') +const context = require('../../test-helpers/context') describe('ctx.acceptsCharsets()', () => { describe('with no arguments', () => { describe('when Accept-Charset is populated', () => { it('should return accepted types', () => { - const ctx = context(); - ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; - assert.deepEqual(ctx.acceptsCharsets(), ['utf-8', 'utf-7', 'iso-8859-1']); - }); - }); - }); + const ctx = context() + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5' + assert.deepStrictEqual(ctx.acceptsCharsets(), ['utf-8', 'utf-7', 'iso-8859-1']) + }) + }) + }) describe('with multiple arguments', () => { describe('when Accept-Charset is populated', () => { describe('if any types match', () => { it('should return the best fit', () => { - const ctx = context(); - ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; - assert.equal(ctx.acceptsCharsets('utf-7', 'utf-8'), 'utf-8'); - }); - }); + const ctx = context() + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5' + assert.strictEqual(ctx.acceptsCharsets('utf-7', 'utf-8'), 'utf-8') + }) + }) describe('if no types match', () => { it('should return false', () => { - const ctx = context(); - ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; - assert.equal(ctx.acceptsCharsets('utf-16'), false); - }); - }); - }); + const ctx = context() + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5' + assert.strictEqual(ctx.acceptsCharsets('utf-16'), false) + }) + }) + }) describe('when Accept-Charset is not populated', () => { it('should return the first type', () => { - const ctx = context(); - assert.equal(ctx.acceptsCharsets('utf-7', 'utf-8'), 'utf-7'); - }); - }); - }); + const ctx = context() + assert.strictEqual(ctx.acceptsCharsets('utf-7', 'utf-8'), 'utf-7') + }) + }) + }) describe('with an array', () => { it('should return the best fit', () => { - const ctx = context(); - ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5'; - assert.equal(ctx.acceptsCharsets(['utf-7', 'utf-8']), 'utf-8'); - }); - }); -}); + const ctx = context() + ctx.req.headers['accept-charset'] = 'utf-8, iso-8859-1;q=0.2, utf-7;q=0.5' + assert.strictEqual(ctx.acceptsCharsets(['utf-7', 'utf-8']), 'utf-8') + }) + }) +}) diff --git a/__tests__/request/acceptsEncodings.js b/__tests__/request/acceptsEncodings.js new file mode 100644 index 000000000..837c61f49 --- /dev/null +++ b/__tests__/request/acceptsEncodings.js @@ -0,0 +1,42 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.acceptsEncodings()', () => { + describe('with no arguments', () => { + describe('when Accept-Encoding is populated', () => { + it('should return accepted types', () => { + const ctx = context() + ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2' + assert.deepStrictEqual(ctx.acceptsEncodings(), ['gzip', 'compress', 'identity']) + assert.strictEqual(ctx.acceptsEncodings('gzip', 'compress'), 'gzip') + }) + }) + + describe('when Accept-Encoding is not populated', () => { + it('should return identity', () => { + const ctx = context() + assert.deepStrictEqual(ctx.acceptsEncodings(), ['identity']) + assert.strictEqual(ctx.acceptsEncodings('gzip', 'deflate', 'identity'), 'identity') + }) + }) + }) + + describe('with multiple arguments', () => { + it('should return the best fit', () => { + const ctx = context() + ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2' + assert.strictEqual(ctx.acceptsEncodings('compress', 'gzip'), 'gzip') + assert.strictEqual(ctx.acceptsEncodings('gzip', 'compress'), 'gzip') + }) + }) + + describe('with an array', () => { + it('should return the best fit', () => { + const ctx = context() + ctx.req.headers['accept-encoding'] = 'gzip, compress;q=0.2' + assert.strictEqual(ctx.acceptsEncodings(['compress', 'gzip']), 'gzip') + }) + }) +}) diff --git a/__tests__/request/acceptsLanguages.js b/__tests__/request/acceptsLanguages.js new file mode 100644 index 000000000..bba961106 --- /dev/null +++ b/__tests__/request/acceptsLanguages.js @@ -0,0 +1,51 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.acceptsLanguages(langs)', () => { + describe('with no arguments', () => { + describe('when Accept-Language is populated', () => { + it('should return accepted types', () => { + const ctx = context() + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt' + assert.deepStrictEqual(ctx.acceptsLanguages(), ['es', 'pt', 'en']) + }) + }) + }) + + describe('with multiple arguments', () => { + describe('when Accept-Language is populated', () => { + describe('if any types types match', () => { + it('should return the best fit', () => { + const ctx = context() + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt' + assert.strictEqual(ctx.acceptsLanguages('es', 'en'), 'es') + }) + }) + + describe('if no types match', () => { + it('should return false', () => { + const ctx = context() + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt' + assert.strictEqual(ctx.acceptsLanguages('fr', 'au'), false) + }) + }) + }) + + describe('when Accept-Language is not populated', () => { + it('should return the first type', () => { + const ctx = context() + assert.strictEqual(ctx.acceptsLanguages('es', 'en'), 'es') + }) + }) + }) + + describe('with an array', () => { + it('should return the best fit', () => { + const ctx = context() + ctx.req.headers['accept-language'] = 'en;q=0.8, es, pt' + assert.strictEqual(ctx.acceptsLanguages(['es', 'en']), 'es') + }) + }) +}) diff --git a/__tests__/request/charset.js b/__tests__/request/charset.js new file mode 100644 index 000000000..3987a68cc --- /dev/null +++ b/__tests__/request/charset.js @@ -0,0 +1,35 @@ +'use strict' + +const request = require('../../test-helpers/context').request +const assert = require('assert') + +describe('req.charset', () => { + describe('with no content-type present', () => { + it('should return ""', () => { + const req = request() + assert(req.charset === '') + }) + }) + + describe('with charset present', () => { + it('should return ""', () => { + const req = request() + req.header['content-type'] = 'text/plain' + assert(req.charset === '') + }) + }) + + describe('with a charset', () => { + it('should return the charset', () => { + const req = request() + req.header['content-type'] = 'text/plain; charset=utf-8' + assert.strictEqual(req.charset, 'utf-8') + }) + + it('should return "" if content-type is invalid', () => { + const req = request() + req.header['content-type'] = 'application/json; application/text; charset=utf-8' + assert.strictEqual(req.charset, '') + }) + }) +}) diff --git a/__tests__/request/fresh.js b/__tests__/request/fresh.js new file mode 100644 index 000000000..027a0c51c --- /dev/null +++ b/__tests__/request/fresh.js @@ -0,0 +1,49 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.fresh', () => { + describe('the request method is not GET and HEAD', () => { + it('should return false', () => { + const ctx = context() + ctx.req.method = 'POST' + assert.strictEqual(ctx.fresh, false) + }) + }) + + describe('the response is non-2xx', () => { + it('should return false', () => { + const ctx = context() + ctx.status = 404 + ctx.req.method = 'GET' + ctx.req.headers['if-none-match'] = '123' + ctx.set('ETag', '123') + assert.strictEqual(ctx.fresh, false) + }) + }) + + describe('the response is 2xx', () => { + describe('and etag matches', () => { + it('should return true', () => { + const ctx = context() + ctx.status = 200 + ctx.req.method = 'GET' + ctx.req.headers['if-none-match'] = '123' + ctx.set('ETag', '123') + assert.strictEqual(ctx.fresh, true) + }) + }) + + describe('and etag does not match', () => { + it('should return false', () => { + const ctx = context() + ctx.status = 200 + ctx.req.method = 'GET' + ctx.req.headers['if-none-match'] = '123' + ctx.set('ETag', 'hey') + assert.strictEqual(ctx.fresh, false) + }) + }) + }) +}) diff --git a/__tests__/request/get.js b/__tests__/request/get.js new file mode 100644 index 000000000..cf111caaa --- /dev/null +++ b/__tests__/request/get.js @@ -0,0 +1,17 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.get(name)', () => { + it('should return the field value', () => { + const ctx = context() + ctx.req.headers.host = 'http://google.com' + ctx.req.headers.referer = 'http://google.com' + assert.strictEqual(ctx.get('HOST'), 'http://google.com') + assert.strictEqual(ctx.get('Host'), 'http://google.com') + assert.strictEqual(ctx.get('host'), 'http://google.com') + assert.strictEqual(ctx.get('referer'), 'http://google.com') + assert.strictEqual(ctx.get('referrer'), 'http://google.com') + }) +}) diff --git a/__tests__/request/header.js b/__tests__/request/header.js new file mode 100644 index 000000000..5799c8c50 --- /dev/null +++ b/__tests__/request/header.js @@ -0,0 +1,17 @@ +'use strict' + +const assert = require('assert') +const request = require('../../test-helpers/context').request + +describe('req.header', () => { + it('should return the request header object', () => { + const req = request() + assert.deepStrictEqual(req.header, req.req.headers) + }) + + it('should set the request header object', () => { + const req = request() + req.header = { 'X-Custom-Headerfield': 'Its one header, with headerfields' } + assert.deepStrictEqual(req.header, req.req.headers) + }) +}) diff --git a/__tests__/request/headers.js b/__tests__/request/headers.js new file mode 100644 index 000000000..55c0eeb36 --- /dev/null +++ b/__tests__/request/headers.js @@ -0,0 +1,17 @@ +'use strict' + +const assert = require('assert') +const request = require('../../test-helpers/context').request + +describe('req.headers', () => { + it('should return the request header object', () => { + const req = request() + assert.deepStrictEqual(req.headers, req.req.headers) + }) + + it('should set the request header object', () => { + const req = request() + req.headers = { 'X-Custom-Headerfield': 'Its one header, with headerfields' } + assert.deepStrictEqual(req.headers, req.req.headers) + }) +}) diff --git a/__tests__/request/host.js b/__tests__/request/host.js new file mode 100644 index 000000000..0dfc8005d --- /dev/null +++ b/__tests__/request/host.js @@ -0,0 +1,96 @@ +'use strict' + +const request = require('../../test-helpers/context').request +const assert = require('assert') + +describe('req.host', () => { + it('should return host with port', () => { + const req = request() + req.header.host = 'foo.com:3000' + assert.strictEqual(req.host, 'foo.com:3000') + }) + + describe('with no host present', () => { + it('should return ""', () => { + const req = request() + assert.strictEqual(req.host, '') + }) + }) + + describe('when less then HTTP/2', () => { + it('should not use :authority header', () => { + const req = request({ + httpVersionMajor: 1, + httpVersion: '1.1' + }) + req.header[':authority'] = 'foo.com:3000' + req.header.host = 'bar.com:8000' + assert.strictEqual(req.host, 'bar.com:8000') + }) + }) + + describe('when HTTP/2', () => { + it('should use :authority header', () => { + const req = request({ + httpVersionMajor: 2, + httpVersion: '2.0' + }) + req.header[':authority'] = 'foo.com:3000' + req.header.host = 'bar.com:8000' + assert.strictEqual(req.host, 'foo.com:3000') + }) + + it('should use host header as fallback', () => { + const req = request({ + httpVersionMajor: 2, + httpVersion: '2.0' + }) + req.header.host = 'bar.com:8000' + assert.strictEqual(req.host, 'bar.com:8000') + }) + }) + + describe('when X-Forwarded-Host is present', () => { + describe('and proxy is not trusted', () => { + it('should be ignored on HTTP/1', () => { + const req = request() + req.header['x-forwarded-host'] = 'bar.com' + req.header.host = 'foo.com' + assert.strictEqual(req.host, 'foo.com') + }) + + it('should be ignored on HTTP/2', () => { + const req = request({ + httpVersionMajor: 2, + httpVersion: '2.0' + }) + req.header['x-forwarded-host'] = 'proxy.com:8080' + req.header[':authority'] = 'foo.com:3000' + req.header.host = 'bar.com:8000' + assert.strictEqual(req.host, 'foo.com:3000') + }) + }) + + describe('and proxy is trusted', () => { + it('should be used on HTTP/1', () => { + const req = request() + req.app.proxy = true + req.header['x-forwarded-host'] = 'bar.com, baz.com' + req.header.host = 'foo.com' + assert.strictEqual(req.host, 'bar.com') + }) + + it('should be used on HTTP/2', () => { + const req = request({ + httpVersionMajor: 2, + httpVersion: '2.0' + }) + req.app.proxy = true + req.header['x-forwarded-host'] = 'proxy.com:8080' + req.header[':authority'] = 'foo.com:3000' + req.header.host = 'bar.com:8000' + assert.strictEqual(req.host, 'proxy.com:8080') + }) + }) + }) +}) diff --git a/__tests__/request/hostname.js b/__tests__/request/hostname.js new file mode 100644 index 000000000..a47e8bf9a --- /dev/null +++ b/__tests__/request/hostname.js @@ -0,0 +1,72 @@ +'use strict' + +const request = require('../../test-helpers/context').request +const assert = require('assert') + +describe('req.hostname', () => { + it('should return hostname void of port', () => { + const req = request() + req.header.host = 'foo.com:3000' + assert.strictEqual(req.hostname, 'foo.com') + }) + + describe('with no host present', () => { + it('should return ""', () => { + const req = request() + assert.strictEqual(req.hostname, '') + }) + }) + + describe('with IPv6 in host', () => { + it('should parse localhost void of port', () => { + const req = request() + req.header.host = '[::1]' + assert.strictEqual(req.hostname, '[::1]') + }) + + it('should parse localhost with port 80', () => { + const req = request() + req.header.host = '[::1]:80' + assert.strictEqual(req.hostname, '[::1]') + }) + + it('should parse localhost with non-special schema port', () => { + const req = request() + req.header.host = '[::1]:1337' + assert.strictEqual(req.hostname, '[::1]') + }) + + it('should reduce IPv6 with non-special schema port as hostname', () => { + const req = request() + req.header.host = '[2001:cdba:0000:0000:0000:0000:3257:9652]:1337' + assert.strictEqual(req.hostname, '[2001:cdba::3257:9652]') + }) + + it('should return empty string when invalid', () => { + const req = request() + req.header.host = '[invalidIPv6]' + assert.strictEqual(req.hostname, '') + }) + }) + + describe('when X-Forwarded-Host is present', () => { + describe('and proxy is not trusted', () => { + it('should be ignored', () => { + const req = request() + req.header['x-forwarded-host'] = 'bar.com' + req.header.host = 'foo.com' + assert.strictEqual(req.hostname, 'foo.com') + }) + }) + + describe('and proxy is trusted', () => { + it('should be used', () => { + const req = request() + req.app.proxy = true + req.header['x-forwarded-host'] = 'bar.com, baz.com' + req.header.host = 'foo.com' + assert.strictEqual(req.hostname, 'bar.com') + }) + }) + }) +}) diff --git a/__tests__/request/href.js b/__tests__/request/href.js new file mode 100644 index 000000000..6e8226bed --- /dev/null +++ b/__tests__/request/href.js @@ -0,0 +1,50 @@ +'use strict' + +const assert = require('assert') +const Stream = require('stream') +const http = require('http') +const Koa = require('../../') +const context = require('../../test-helpers/context') + +describe('ctx.href', () => { + it('should return the full request url', () => { + const socket = new Stream.Duplex() + const req = { + url: '/users/1?next=/dashboard', + headers: { + host: 'localhost' + }, + socket, + __proto__: Stream.Readable.prototype + } + const ctx = context(req) + assert.strictEqual(ctx.href, 'http://localhost/users/1?next=/dashboard') + // change it also work + ctx.url = '/foo/users/1?next=/dashboard' + assert.strictEqual(ctx.href, 'http://localhost/users/1?next=/dashboard') + }) + + it('should work with `GET http://example.com/foo`', done => { + const app = new Koa() + app.use(ctx => { + ctx.body = ctx.href + }) + app.listen(function () { + const address = this.address() + http.get({ + host: 'localhost', + path: 'http://example.com/foo', + port: address.port + }, res => { + assert.strictEqual(res.statusCode, 200) + let buf = '' + res.setEncoding('utf8') + res.on('data', s => { buf += s }) + res.on('end', () => { + assert.strictEqual(buf, 'http://example.com/foo') + done() + }) + }) + }) + }) +}) diff --git a/__tests__/request/idempotent.js b/__tests__/request/idempotent.js new file mode 100644 index 000000000..6358e0b3d --- /dev/null +++ b/__tests__/request/idempotent.js @@ -0,0 +1,25 @@ +'use strict' + +const assert = require('assert') +const request = require('../../test-helpers/context').request + +describe('ctx.idempotent', () => { + describe('when the request method is idempotent', () => { + it('should return true', () => { + ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'].forEach(check) + function check (method) { + const req = request() + req.method = method + assert.strictEqual(req.idempotent, true) + } + }) + }) + + describe('when the request method is not idempotent', () => { + it('should return false', () => { + const req = request() + req.method = 'POST' + assert.strictEqual(req.idempotent, false) + }) + }) +}) diff --git a/__tests__/request/inspect.js b/__tests__/request/inspect.js new file mode 100644 index 000000000..0a4af5fc1 --- /dev/null +++ b/__tests__/request/inspect.js @@ -0,0 +1,35 @@ +'use strict' + +const request = require('../../test-helpers/context').request +const assert = require('assert') +const util = require('util') + +describe('req.inspect()', () => { + describe('with no request.req present', () => { + it('should return null', () => { + const req = request() + req.method = 'GET' + delete req.req + assert(undefined === req.inspect()) + assert(util.inspect(req) === 'undefined') + }) + }) + + it('should return a json representation', () => { + const req = request() + req.method = 'GET' + req.url = 'example.com' + req.header.host = 'example.com' + + const expected = { + method: 'GET', + url: 'example.com', + header: { + host: 'example.com' + } + } + + assert.deepStrictEqual(req.inspect(), expected) + assert.deepStrictEqual(util.inspect(req), util.inspect(expected)) + }) +}) diff --git a/__tests__/request/ip.js b/__tests__/request/ip.js new file mode 100644 index 000000000..a176f3617 --- /dev/null +++ b/__tests__/request/ip.js @@ -0,0 +1,58 @@ +'use strict' + +const assert = require('assert') +const Stream = require('stream') +const Koa = require('../..') +const Request = require('../../test-helpers/context').request + +describe('req.ip', () => { + describe('with req.ips present', () => { + it('should return req.ips[0]', () => { + const app = new Koa() + const req = { headers: {}, socket: new Stream.Duplex() } + app.proxy = true + req.headers['x-forwarded-for'] = '127.0.0.1' + req.socket.remoteAddress = '127.0.0.2' + const request = Request(req, undefined, app) + assert.strictEqual(request.ip, '127.0.0.1') + }) + }) + + describe('with no req.ips present', () => { + it('should return req.socket.remoteAddress', () => { + const req = { socket: new Stream.Duplex() } + req.socket.remoteAddress = '127.0.0.2' + const request = Request(req) + assert.strictEqual(request.ip, '127.0.0.2') + }) + + describe('with req.socket.remoteAddress not present', () => { + it('should return an empty string', () => { + const socket = new Stream.Duplex() + Object.defineProperty(socket, 'remoteAddress', { + get: () => undefined, // So that the helper doesn't override it with a reasonable value + set: () => {} + }) + assert.strictEqual(Request({ socket }).ip, '') + }) + }) + }) + + it('should be lazy inited and cached', () => { + const req = { socket: new Stream.Duplex() } + req.socket.remoteAddress = '127.0.0.2' + const request = Request(req) + assert.strictEqual(request.ip, '127.0.0.2') + req.socket.remoteAddress = '127.0.0.1' + assert.strictEqual(request.ip, '127.0.0.2') + }) + + it('should reset ip work', () => { + const req = { socket: new Stream.Duplex() } + req.socket.remoteAddress = '127.0.0.2' + const request = Request(req) + assert.strictEqual(request.ip, '127.0.0.2') + request.ip = '127.0.0.1' + assert.strictEqual(request.ip, '127.0.0.1') + }) +}) diff --git a/__tests__/request/ips.js b/__tests__/request/ips.js new file mode 100644 index 000000000..b88dce654 --- /dev/null +++ b/__tests__/request/ips.js @@ -0,0 +1,70 @@ +'use strict' + +const assert = require('assert') +const request = require('../../test-helpers/context').request + +describe('req.ips', () => { + describe('when X-Forwarded-For is present', () => { + describe('and proxy is not trusted', () => { + it('should be ignored', () => { + const req = request() + req.app.proxy = false + req.header['x-forwarded-for'] = '127.0.0.1,127.0.0.2' + assert.deepStrictEqual(req.ips, []) + }) + }) + + describe('and proxy is trusted', () => { + it('should be used', () => { + const req = request() + req.app.proxy = true + req.header['x-forwarded-for'] = '127.0.0.1,127.0.0.2' + assert.deepStrictEqual(req.ips, ['127.0.0.1', '127.0.0.2']) + }) + }) + }) + + describe('when options.proxyIpHeader is present', () => { + describe('and proxy is not trusted', () => { + it('should be ignored', () => { + const req = request() + req.app.proxy = false + req.app.proxyIpHeader = 'x-client-ip' + req.header['x-client-ip'] = '127.0.0.1,127.0.0.2' + assert.deepStrictEqual(req.ips, []) + }) + }) + + describe('and proxy is trusted', () => { + it('should be used', () => { + const req = request() + req.app.proxy = true + req.app.proxyIpHeader = 'x-client-ip' + req.header['x-client-ip'] = '127.0.0.1,127.0.0.2' + assert.deepStrictEqual(req.ips, ['127.0.0.1', '127.0.0.2']) + }) + }) + }) + + describe('when options.maxIpsCount is present', () => { + describe('and proxy is not trusted', () => { + it('should be ignored', () => { + const req = request() + req.app.proxy = false + req.app.maxIpsCount = 1 + req.header['x-forwarded-for'] = '127.0.0.1,127.0.0.2' + assert.deepStrictEqual(req.ips, []) + }) + }) + + describe('and proxy is trusted', () => { + it('should be used', () => { + const req = request() + req.app.proxy = true + req.app.maxIpsCount = 1 + req.header['x-forwarded-for'] = '127.0.0.1,127.0.0.2' + assert.deepStrictEqual(req.ips, ['127.0.0.2']) + }) + }) + }) +}) diff --git a/__tests__/request/is.js b/__tests__/request/is.js new file mode 100644 index 000000000..f29e77288 --- /dev/null +++ b/__tests__/request/is.js @@ -0,0 +1,102 @@ +'use strict' + +const context = require('../../test-helpers/context') +const assert = require('assert') + +describe('ctx.is(type)', () => { + it('should ignore params', () => { + const ctx = context() + ctx.header['content-type'] = 'text/html; charset=utf-8' + ctx.header['transfer-encoding'] = 'chunked' + + assert.strictEqual(ctx.is('text/*'), 'text/html') + }) + + describe('when no body is given', () => { + it('should return null', () => { + const ctx = context() + + assert.strictEqual(ctx.is(), null) + assert.strictEqual(ctx.is('image/*'), null) + assert.strictEqual(ctx.is('image/*', 'text/*'), null) + }) + }) + + describe('when no content type is given', () => { + it('should return false', () => { + const ctx = context() + ctx.header['transfer-encoding'] = 'chunked' + + assert.strictEqual(ctx.is(), false) + assert.strictEqual(ctx.is('image/*'), false) + assert.strictEqual(ctx.is('text/*', 'image/*'), false) + }) + }) + + describe('give no types', () => { + it('should return the mime type', () => { + const ctx = context() + ctx.header['content-type'] = 'image/png' + ctx.header['transfer-encoding'] = 'chunked' + + assert.strictEqual(ctx.is(), 'image/png') + }) + }) + + describe('given one type', () => { + it('should return the type or false', () => { + const ctx = context() + ctx.header['content-type'] = 'image/png' + ctx.header['transfer-encoding'] = 'chunked' + + assert.strictEqual(ctx.is('png'), 'png') + assert.strictEqual(ctx.is('.png'), '.png') + assert.strictEqual(ctx.is('image/png'), 'image/png') + assert.strictEqual(ctx.is('image/*'), 'image/png') + assert.strictEqual(ctx.is('*/png'), 'image/png') + + assert.strictEqual(ctx.is('jpeg'), false) + assert.strictEqual(ctx.is('.jpeg'), false) + assert.strictEqual(ctx.is('image/jpeg'), false) + assert.strictEqual(ctx.is('text/*'), false) + assert.strictEqual(ctx.is('*/jpeg'), false) + }) + }) + + describe('given multiple types', () => { + it('should return the first match or false', () => { + const ctx = context() + ctx.header['content-type'] = 'image/png' + ctx.header['transfer-encoding'] = 'chunked' + + assert.strictEqual(ctx.is('png'), 'png') + assert.strictEqual(ctx.is('.png'), '.png') + assert.strictEqual(ctx.is('text/*', 'image/*'), 'image/png') + assert.strictEqual(ctx.is('image/*', 'text/*'), 'image/png') + assert.strictEqual(ctx.is('image/*', 'image/png'), 'image/png') + assert.strictEqual(ctx.is('image/png', 'image/*'), 'image/png') + + assert.strictEqual(ctx.is(['text/*', 'image/*']), 'image/png') + assert.strictEqual(ctx.is(['image/*', 'text/*']), 'image/png') + assert.strictEqual(ctx.is(['image/*', 'image/png']), 'image/png') + assert.strictEqual(ctx.is(['image/png', 'image/*']), 'image/png') + + assert.strictEqual(ctx.is('jpeg'), false) + assert.strictEqual(ctx.is('.jpeg'), false) + assert.strictEqual(ctx.is('text/*', 'application/*'), false) + assert.strictEqual(ctx.is('text/html', 'text/plain', 'application/json; charset=utf-8'), false) + }) + }) + + describe('when Content-Type: application/x-www-form-urlencoded', () => { + it('should match "urlencoded"', () => { + const ctx = context() + ctx.header['content-type'] = 'application/x-www-form-urlencoded' + ctx.header['transfer-encoding'] = 'chunked' + + assert.strictEqual(ctx.is('urlencoded'), 'urlencoded') + assert.strictEqual(ctx.is('json', 'urlencoded'), 'urlencoded') + assert.strictEqual(ctx.is('urlencoded', 'json'), 'urlencoded') + }) + }) +}) diff --git a/__tests__/request/length.js b/__tests__/request/length.js new file mode 100644 index 000000000..ef54a5f11 --- /dev/null +++ b/__tests__/request/length.js @@ -0,0 +1,17 @@ +'use strict' + +const request = require('../../test-helpers/context').request +const assert = require('assert') + +describe('ctx.length', () => { + it('should return length in content-length', () => { + const req = request() + req.header['content-length'] = '10' + assert.strictEqual(req.length, 10) + }) + + it('should return undefined with no content-length present', () => { + const req = request() + assert.strictEqual(req.length, undefined) + }) +}) diff --git a/__tests__/request/origin.js b/__tests__/request/origin.js new file mode 100644 index 000000000..fa897367d --- /dev/null +++ b/__tests__/request/origin.js @@ -0,0 +1,24 @@ +'use strict' + +const assert = require('assert') +const Stream = require('stream') +const context = require('../../test-helpers/context') + +describe('ctx.origin', () => { + it('should return the origin of url', () => { + const socket = new Stream.Duplex() + const req = { + url: '/users/1?next=/dashboard', + headers: { + host: 'localhost' + }, + socket, + __proto__: Stream.Readable.prototype + } + const ctx = context(req) + assert.strictEqual(ctx.origin, 'http://localhost') + // change it also work + ctx.url = '/foo/users/1?next=/dashboard' + assert.strictEqual(ctx.origin, 'http://localhost') + }) +}) diff --git a/__tests__/request/path.js b/__tests__/request/path.js new file mode 100644 index 000000000..d52723a92 --- /dev/null +++ b/__tests__/request/path.js @@ -0,0 +1,39 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') +const parseurl = require('parseurl') + +describe('ctx.path', () => { + it('should return the pathname', () => { + const ctx = context() + ctx.url = '/login?next=/dashboard' + assert.strictEqual(ctx.path, '/login') + }) +}) + +describe('ctx.path=', () => { + it('should set the pathname', () => { + const ctx = context() + ctx.url = '/login?next=/dashboard' + + ctx.path = '/logout' + assert.strictEqual(ctx.path, '/logout') + assert.strictEqual(ctx.url, '/logout?next=/dashboard') + }) + + it('should change .url but not .originalUrl', () => { + const ctx = context({ url: '/login' }) + ctx.path = '/logout' + assert.strictEqual(ctx.url, '/logout') + assert.strictEqual(ctx.originalUrl, '/login') + assert.strictEqual(ctx.request.originalUrl, '/login') + }) + + it('should not affect parseurl', () => { + const ctx = context({ url: '/login?foo=bar' }) + ctx.path = '/login' + const url = parseurl(ctx.req) + assert.strictEqual(url.path, '/login?foo=bar') + }) +}) diff --git a/__tests__/request/protocol.js b/__tests__/request/protocol.js new file mode 100644 index 000000000..13fc3690e --- /dev/null +++ b/__tests__/request/protocol.js @@ -0,0 +1,53 @@ +'use strict' + +const assert = require('assert') +const request = require('../../test-helpers/context').request + +describe('req.protocol', () => { + describe('when encrypted', () => { + it('should return "https"', () => { + const req = request() + req.req.socket = { encrypted: true } + assert.strictEqual(req.protocol, 'https') + }) + }) + + describe('when unencrypted', () => { + it('should return "http"', () => { + const req = request() + req.req.socket = {} + assert.strictEqual(req.protocol, 'http') + }) + }) + + describe('when X-Forwarded-Proto is set', () => { + describe('and proxy is trusted', () => { + it('should be used', () => { + const req = request() + req.app.proxy = true + req.req.socket = {} + req.header['x-forwarded-proto'] = 'https, http' + assert.strictEqual(req.protocol, 'https') + }) + + describe('and X-Forwarded-Proto is empty', () => { + it('should return "http"', () => { + const req = request() + req.app.proxy = true + req.req.socket = {} + req.header['x-forwarded-proto'] = '' + assert.strictEqual(req.protocol, 'http') + }) + }) + }) + + describe('and proxy is not trusted', () => { + it('should not be used', () => { + const req = request() + req.req.socket = {} + req.header['x-forwarded-proto'] = 'https, http' + assert.strictEqual(req.protocol, 'http') + }) + }) + }) +}) diff --git a/__tests__/request/query.js b/__tests__/request/query.js new file mode 100644 index 000000000..688cd225d --- /dev/null +++ b/__tests__/request/query.js @@ -0,0 +1,42 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.query', () => { + describe('when missing', () => { + it('should return an empty object', () => { + const ctx = context({ url: '/' }) + assert(!Object.keys(ctx.query).length) + }) + + it('should return the same object each time it\'s accessed', () => { + const ctx = context({ url: '/' }) + ctx.query.a = '2' + assert.strictEqual(ctx.query.a, '2') + }) + }) + + it('should return a parsed query string', () => { + const ctx = context({ url: '/?page=2' }) + assert.strictEqual(ctx.query.page, '2') + }) +}) + +describe('ctx.query=', () => { + it('should stringify and replace the query string and search', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.query = { page: 2, color: 'blue' } + assert.strictEqual(ctx.url, '/store/shoes?page=2&color=blue') + assert.strictEqual(ctx.querystring, 'page=2&color=blue') + assert.strictEqual(ctx.search, '?page=2&color=blue') + }) + + it('should change .url but not .originalUrl', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.query = { page: 2 } + assert.strictEqual(ctx.url, '/store/shoes?page=2') + assert.strictEqual(ctx.originalUrl, '/store/shoes') + assert.strictEqual(ctx.request.originalUrl, '/store/shoes') + }) +}) diff --git a/__tests__/request/querystring.js b/__tests__/request/querystring.js new file mode 100644 index 000000000..fc65963f2 --- /dev/null +++ b/__tests__/request/querystring.js @@ -0,0 +1,53 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') +const parseurl = require('parseurl') + +describe('ctx.querystring', () => { + it('should return the querystring', () => { + const ctx = context({ url: '/store/shoes?page=2&color=blue' }) + assert.strictEqual(ctx.querystring, 'page=2&color=blue') + }) + + describe('when ctx.req not present', () => { + it('should return an empty string', () => { + const ctx = context() + ctx.request.req = null + assert.strictEqual(ctx.querystring, '') + }) + }) +}) + +describe('ctx.querystring=', () => { + it('should replace the querystring', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.querystring = 'page=2&color=blue' + assert.strictEqual(ctx.url, '/store/shoes?page=2&color=blue') + assert.strictEqual(ctx.querystring, 'page=2&color=blue') + }) + + it('should update ctx.search and ctx.query', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.querystring = 'page=2&color=blue' + assert.strictEqual(ctx.url, '/store/shoes?page=2&color=blue') + assert.strictEqual(ctx.search, '?page=2&color=blue') + assert.strictEqual(ctx.query.page, '2') + assert.strictEqual(ctx.query.color, 'blue') + }) + + it('should change .url but not .originalUrl', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.querystring = 'page=2&color=blue' + assert.strictEqual(ctx.url, '/store/shoes?page=2&color=blue') + assert.strictEqual(ctx.originalUrl, '/store/shoes') + assert.strictEqual(ctx.request.originalUrl, '/store/shoes') + }) + + it('should not affect parseurl', () => { + const ctx = context({ url: '/login?foo=bar' }) + ctx.querystring = 'foo=bar' + const url = parseurl(ctx.req) + assert.strictEqual(url.path, '/login?foo=bar') + }) +}) diff --git a/__tests__/request/search.js b/__tests__/request/search.js new file mode 100644 index 000000000..112a5bd25 --- /dev/null +++ b/__tests__/request/search.js @@ -0,0 +1,37 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.search=', () => { + it('should replace the search', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.search = '?page=2&color=blue' + assert.strictEqual(ctx.url, '/store/shoes?page=2&color=blue') + assert.strictEqual(ctx.search, '?page=2&color=blue') + }) + + it('should update ctx.querystring and ctx.query', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.search = '?page=2&color=blue' + assert.strictEqual(ctx.url, '/store/shoes?page=2&color=blue') + assert.strictEqual(ctx.querystring, 'page=2&color=blue') + assert.strictEqual(ctx.query.page, '2') + assert.strictEqual(ctx.query.color, 'blue') + }) + + it('should change .url but not .originalUrl', () => { + const ctx = context({ url: '/store/shoes' }) + ctx.search = '?page=2&color=blue' + assert.strictEqual(ctx.url, '/store/shoes?page=2&color=blue') + assert.strictEqual(ctx.originalUrl, '/store/shoes') + assert.strictEqual(ctx.request.originalUrl, '/store/shoes') + }) + + describe('when missing', () => { + it('should return ""', () => { + const ctx = context({ url: '/store/shoes' }) + assert.strictEqual(ctx.search, '') + }) + }) +}) diff --git a/__tests__/request/secure.js b/__tests__/request/secure.js new file mode 100644 index 000000000..c6e8a2ef3 --- /dev/null +++ b/__tests__/request/secure.js @@ -0,0 +1,12 @@ +'use strict' + +const assert = require('assert') +const request = require('../../test-helpers/context').request + +describe('req.secure', () => { + it('should return true when encrypted', () => { + const req = request() + req.req.socket = { encrypted: true } + assert.strictEqual(req.secure, true) + }) +}) diff --git a/__tests__/request/stale.js b/__tests__/request/stale.js new file mode 100644 index 000000000..07ff09898 --- /dev/null +++ b/__tests__/request/stale.js @@ -0,0 +1,16 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('req.stale', () => { + it('should be the inverse of req.fresh', () => { + const ctx = context() + ctx.status = 200 + ctx.method = 'GET' + ctx.req.headers['if-none-match'] = '"123"' + ctx.set('ETag', '"123"') + assert.strictEqual(ctx.fresh, true) + assert.strictEqual(ctx.stale, false) + }) +}) diff --git a/__tests__/request/subdomains.js b/__tests__/request/subdomains.js new file mode 100644 index 000000000..9711469bf --- /dev/null +++ b/__tests__/request/subdomains.js @@ -0,0 +1,27 @@ +'use strict' + +const assert = require('assert') +const request = require('../../test-helpers/context').request + +describe('req.subdomains', () => { + it('should return subdomain array', () => { + const req = request() + req.header.host = 'tobi.ferrets.example.com' + req.app.subdomainOffset = 2 + assert.deepStrictEqual(req.subdomains, ['ferrets', 'tobi']) + + req.app.subdomainOffset = 3 + assert.deepStrictEqual(req.subdomains, ['tobi']) + }) + + it('should work with no host present', () => { + const req = request() + assert.deepStrictEqual(req.subdomains, []) + }) + + it('should check if the host is an ip address, even with a port', () => { + const req = request() + req.header.host = '127.0.0.1:3000' + assert.deepStrictEqual(req.subdomains, []) + }) +}) diff --git a/__tests__/request/type.js b/__tests__/request/type.js new file mode 100644 index 000000000..747de714f --- /dev/null +++ b/__tests__/request/type.js @@ -0,0 +1,17 @@ +'use strict' + +const request = require('../../test-helpers/context').request +const assert = require('assert') + +describe('req.type', () => { + it('should return type void of parameters', () => { + const req = request() + req.header['content-type'] = 'text/html; charset=utf-8' + assert.strictEqual(req.type, 'text/html') + }) + + it('should return empty string with no host present', () => { + const req = request() + assert.strictEqual(req.type, '') + }) +}) diff --git a/__tests__/request/whatwg-url.js b/__tests__/request/whatwg-url.js new file mode 100644 index 000000000..bc96be0d5 --- /dev/null +++ b/__tests__/request/whatwg-url.js @@ -0,0 +1,24 @@ +'use strict' + +const request = require('../../test-helpers/context').request +const assert = require('assert') + +describe('req.URL', () => { + it('should not throw when host is void', () => { + // Accessing the URL should not throw. + request().URL // eslint-disable-line no-unused-expressions + }) + + it('should not throw when header.host is invalid', () => { + const req = request() + req.header.host = 'invalid host' + // Accessing the URL should not throw. + req.URL // eslint-disable-line no-unused-expressions + }) + + it('should return empty object when invalid', () => { + const req = request() + req.header.host = 'invalid host' + assert.deepStrictEqual(req.URL, Object.create(null)) + }) +}) diff --git a/__tests__/response/append.js b/__tests__/response/append.js new file mode 100644 index 000000000..4509875a3 --- /dev/null +++ b/__tests__/response/append.js @@ -0,0 +1,41 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.append(name, val)', () => { + it('should append multiple headers', () => { + const ctx = context() + ctx.append('x-foo', 'bar1') + ctx.append('x-foo', 'bar2') + assert.deepStrictEqual(ctx.response.header['x-foo'], ['bar1', 'bar2']) + }) + + it('should accept array of values', () => { + const ctx = context() + + ctx.append('Set-Cookie', ['foo=bar', 'fizz=buzz']) + ctx.append('Set-Cookie', 'hi=again') + assert.deepStrictEqual(ctx.response.header['set-cookie'], ['foo=bar', 'fizz=buzz', 'hi=again']) + }) + + it('should get reset by res.set(field, val)', () => { + const ctx = context() + + ctx.append('Link', '') + ctx.append('Link', '') + + ctx.set('Link', '') + + assert.strictEqual(ctx.response.header.link, '') + }) + + it('should work with res.set(field, val) first', () => { + const ctx = context() + + ctx.set('Link', '') + ctx.append('Link', '') + + assert.deepStrictEqual(ctx.response.header.link, ['', '']) + }) +}) diff --git a/__tests__/response/attachment.js b/__tests__/response/attachment.js new file mode 100644 index 000000000..35ebba968 --- /dev/null +++ b/__tests__/response/attachment.js @@ -0,0 +1,184 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') +const request = require('supertest') +const Koa = require('../..') + +describe('ctx.attachment([filename])', () => { + describe('when given a filename', () => { + it('should set the filename param', () => { + const ctx = context() + ctx.attachment('path/to/tobi.png') + const str = 'attachment; filename="tobi.png"' + assert.strictEqual(ctx.response.header['content-disposition'], str) + }) + }) + + describe('when omitting filename', () => { + it('should not set filename param', () => { + const ctx = context() + ctx.attachment() + assert.strictEqual(ctx.response.header['content-disposition'], 'attachment') + }) + }) + + describe('when given a non-ascii filename', () => { + it('should set the encodeURI filename param', () => { + const ctx = context() + ctx.attachment('path/to/include-no-ascii-char-中文名-ok.png') + const str = 'attachment; filename="include-no-ascii-char-???-ok.png"; filename*=UTF-8\'\'include-no-ascii-char-%E4%B8%AD%E6%96%87%E5%90%8D-ok.png' + assert.strictEqual(ctx.response.header['content-disposition'], str) + }) + + it('should work with http client', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.attachment('path/to/include-no-ascii-char-中文名-ok.json') + ctx.body = { foo: 'bar' } + }) + + return request(app.callback()) + .get('/') + .expect('content-disposition', 'attachment; filename="include-no-ascii-char-???-ok.json"; filename*=UTF-8\'\'include-no-ascii-char-%E4%B8%AD%E6%96%87%E5%90%8D-ok.json') + .expect({ foo: 'bar' }) + .expect(200) + }) + }) +}) + +// reference test case of content-disposition module +describe('contentDisposition(filename, options)', () => { + describe('with "fallback" option', () => { + it('should require a string or Boolean', () => { + const ctx = context() + assert.throws(() => { ctx.attachment('plans.pdf', { fallback: 42 }) }, + /fallback.*string/) + }) + + it('should default to true', () => { + const ctx = context() + ctx.attachment('€ rates.pdf') + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="? rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') + }) + + describe('when "false"', () => { + it('should not generate ISO-8859-1 fallback', () => { + const ctx = context() + ctx.attachment('£ and € rates.pdf', { fallback: false }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') + }) + + it('should keep ISO-8859-1 filename', () => { + const ctx = context() + ctx.attachment('£ rates.pdf', { fallback: false }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="£ rates.pdf"') + }) + }) + + describe('when "true"', () => { + it('should generate ISO-8859-1 fallback', () => { + const ctx = context() + ctx.attachment('£ and € rates.pdf', { fallback: true }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="£ and ? rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') + }) + + it('should pass through ISO-8859-1 filename', () => { + const ctx = context() + ctx.attachment('£ rates.pdf', { fallback: true }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="£ rates.pdf"') + }) + }) + + describe('when a string', () => { + it('should require an ISO-8859-1 string', () => { + const ctx = context() + assert.throws(() => { ctx.attachment('€ rates.pdf', { fallback: '€ rates.pdf' }) }, + /fallback.*iso-8859-1/i) + }) + + it('should use as ISO-8859-1 fallback', () => { + const ctx = context() + ctx.attachment('£ and € rates.pdf', { fallback: '£ and EURO rates.pdf' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="£ and EURO rates.pdf"; filename*=UTF-8\'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf') + }) + + it('should use as fallback even when filename is ISO-8859-1', () => { + const ctx = context() + ctx.attachment('"£ rates".pdf', { fallback: '£ rates.pdf' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="£ rates.pdf"; filename*=UTF-8\'\'%22%C2%A3%20rates%22.pdf') + }) + + it('should do nothing if equal to filename', () => { + const ctx = context() + ctx.attachment('plans.pdf', { fallback: 'plans.pdf' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="plans.pdf"') + }) + + it('should use the basename of the string', () => { + const ctx = context() + ctx.attachment('€ rates.pdf', { fallback: '/path/to/EURO rates.pdf' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment; filename="EURO rates.pdf"; filename*=UTF-8\'\'%E2%82%AC%20rates.pdf') + }) + + it('should do nothing without filename option', () => { + const ctx = context() + ctx.attachment(undefined, { fallback: 'plans.pdf' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment') + }) + }) + }) + + describe('with "type" option', () => { + it('should default to attachment', () => { + const ctx = context() + ctx.attachment() + assert.strictEqual(ctx.response.header['content-disposition'], + 'attachment') + }) + + it('should require a string', () => { + const ctx = context() + assert.throws(() => { ctx.attachment(undefined, { type: 42 }) }, + /invalid type/) + }) + + it('should require a valid type', () => { + const ctx = context() + assert.throws(() => { ctx.attachment(undefined, { type: 'invlaid;type' }) }, + /invalid type/) + }) + + it('should create a header with inline type', () => { + const ctx = context() + ctx.attachment(undefined, { type: 'inline' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'inline') + }) + + it('should create a header with inline type and filename', () => { + const ctx = context() + ctx.attachment('plans.pdf', { type: 'inline' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'inline; filename="plans.pdf"') + }) + + it('should normalize type', () => { + const ctx = context() + ctx.attachment(undefined, { type: 'INLINE' }) + assert.strictEqual(ctx.response.header['content-disposition'], + 'inline') + }) + }) +}) diff --git a/__tests__/response/body.js b/__tests__/response/body.js new file mode 100644 index 000000000..5af050786 --- /dev/null +++ b/__tests__/response/body.js @@ -0,0 +1,144 @@ +'use strict' + +const response = require('../../test-helpers/context').response +const assert = require('assert') +const fs = require('fs') +const Stream = require('stream') + +describe('res.body=', () => { + describe('when Content-Type is set', () => { + it('should not override', () => { + const res = response() + res.type = 'png' + res.body = Buffer.from('something') + assert.strictEqual('image/png', res.header['content-type']) + }) + + describe('when body is an object', () => { + it('should override as json', () => { + const res = response() + + res.body = 'hey' + assert.strictEqual('text/html; charset=utf-8', res.header['content-type']) + + res.body = { foo: 'bar' } + assert.strictEqual('application/json; charset=utf-8', res.header['content-type']) + }) + }) + + it('should override length', () => { + const res = response() + res.type = 'html' + res.body = 'something' + assert.strictEqual(res.length, 9) + }) + }) + + describe('when a string is given', () => { + it('should default to text', () => { + const res = response() + res.body = 'Tobi' + assert.strictEqual('text/plain; charset=utf-8', res.header['content-type']) + }) + + it('should set length', () => { + const res = response() + res.body = 'Tobi' + assert.strictEqual('4', res.header['content-length']) + }) + + describe('and contains a non-leading <', () => { + it('should default to text', () => { + const res = response() + res.body = 'aklsdjf < klajsdlfjasd' + assert.strictEqual('text/plain; charset=utf-8', res.header['content-type']) + }) + }) + }) + + describe('when an html string is given', () => { + it('should default to html', () => { + const res = response() + res.body = '

Tobi

' + assert.strictEqual('text/html; charset=utf-8', res.header['content-type']) + }) + + it('should set length', () => { + const string = '

Tobi

' + const res = response() + res.body = string + assert.strictEqual(res.length, Buffer.byteLength(string)) + }) + + it('should set length when body is overridden', () => { + const string = '

Tobi

' + const res = response() + res.body = string + res.body = string + string + assert.strictEqual(res.length, 2 * Buffer.byteLength(string)) + }) + + describe('when it contains leading whitespace', () => { + it('should default to html', () => { + const res = response() + res.body = '

Tobi

' + assert.strictEqual('text/html; charset=utf-8', res.header['content-type']) + }) + }) + }) + + describe('when an xml string is given', () => { + it('should default to html', () => { + /** + * ctx test is to show that we're not going + * to be stricter with the html sniff + * or that we will sniff other string types. + * You should `.type=` if ctx simple test fails. + */ + + const res = response() + res.body = '\n<俄语>данные' + assert.strictEqual('text/html; charset=utf-8', res.header['content-type']) + }) + }) + + describe('when a stream is given', () => { + it('should default to an octet stream', () => { + const res = response() + res.body = fs.createReadStream('LICENSE') + assert.strictEqual('application/octet-stream', res.header['content-type']) + }) + + it('should add error handler to the stream, but only once', () => { + const res = response() + const body = new Stream.PassThrough() + assert.strictEqual(body.listenerCount('error'), 0) + res.body = body + assert.strictEqual(body.listenerCount('error'), 1) + res.body = body + assert.strictEqual(body.listenerCount('error'), 1) + }) + }) + + describe('when a buffer is given', () => { + it('should default to an octet stream', () => { + const res = response() + res.body = Buffer.from('hey') + assert.strictEqual('application/octet-stream', res.header['content-type']) + }) + + it('should set length', () => { + const res = response() + res.body = Buffer.from('Tobi') + assert.strictEqual('4', res.header['content-length']) + }) + }) + + describe('when an object is given', () => { + it('should default to json', () => { + const res = response() + res.body = { foo: 'bar' } + assert.strictEqual('application/json; charset=utf-8', res.header['content-type']) + }) + }) +}) diff --git a/__tests__/response/etag.js b/__tests__/response/etag.js new file mode 100644 index 000000000..f76bb47ff --- /dev/null +++ b/__tests__/response/etag.js @@ -0,0 +1,32 @@ +'use strict' + +const assert = require('assert') +const response = require('../../test-helpers/context').response + +describe('res.etag=', () => { + it('should not modify an etag with quotes', () => { + const res = response() + res.etag = '"asdf"' + assert.strictEqual(res.header.etag, '"asdf"') + }) + + it('should not modify a weak etag', () => { + const res = response() + res.etag = 'W/"asdf"' + assert.strictEqual(res.header.etag, 'W/"asdf"') + }) + + it('should add quotes around an etag if necessary', () => { + const res = response() + res.etag = 'asdf' + assert.strictEqual(res.header.etag, '"asdf"') + }) +}) + +describe('res.etag', () => { + it('should return etag', () => { + const res = response() + res.etag = '"asdf"' + assert.strictEqual(res.etag, '"asdf"') + }) +}) diff --git a/__tests__/response/flushHeaders.js b/__tests__/response/flushHeaders.js new file mode 100644 index 000000000..89354c8d0 --- /dev/null +++ b/__tests__/response/flushHeaders.js @@ -0,0 +1,150 @@ +'use strict' + +const request = require('supertest') +const assert = require('assert') +const Koa = require('../..') +const http = require('http') + +describe('ctx.flushHeaders()', () => { + it('should set headersSent', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.body = 'Body' + ctx.status = 200 + ctx.flushHeaders() + assert.strictEqual(ctx.res.headersSent, true) + }) + + const server = app.listen() + + return request(server) + .get('/') + .expect(200) + .expect('Body') + }) + + it('should allow a response afterwards', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.status = 200 + ctx.res.setHeader('Content-Type', 'text/plain') + ctx.flushHeaders() + ctx.body = 'Body' + }) + + const server = app.listen() + return request(server) + .get('/') + .expect(200) + .expect('Content-Type', 'text/plain') + .expect('Body') + }) + + it('should send the correct status code', () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.status = 401 + ctx.res.setHeader('Content-Type', 'text/plain') + ctx.flushHeaders() + ctx.body = 'Body' + }) + + const server = app.listen() + return request(server) + .get('/') + .expect(401) + .expect('Content-Type', 'text/plain') + .expect('Body') + }) + + it('should ignore set header after flushHeaders', async () => { + const app = new Koa() + + app.use((ctx, next) => { + ctx.status = 401 + ctx.res.setHeader('Content-Type', 'text/plain') + ctx.flushHeaders() + ctx.body = 'foo' + ctx.set('X-Shouldnt-Work', 'Value') + ctx.remove('Content-Type') + ctx.vary('Content-Type') + }) + + const server = app.listen() + const res = await request(server) + .get('/') + .expect(401) + .expect('Content-Type', 'text/plain') + + assert.strictEqual(res.headers['x-shouldnt-work'], undefined, 'header set after flushHeaders') + assert.strictEqual(res.headers.vary, undefined, 'header set after flushHeaders') + }) + + it('should flush headers first and delay to send data', done => { + const PassThrough = require('stream').PassThrough + const app = new Koa() + + app.use(ctx => { + ctx.type = 'json' + ctx.status = 200 + ctx.headers.Link = '; as=style; rel=preload, ; rel=preconnect; crossorigin' + const stream = ctx.body = new PassThrough() + ctx.flushHeaders() + + setTimeout(() => { + stream.end(JSON.stringify({ message: 'hello!' })) + }, 10000) + }) + + app.listen(function (err) { + if (err) return done(err) + + const port = this.address().port + + http.request({ + port + }) + .on('response', res => { + const onData = () => done(new Error('boom')) + res.on('data', onData) + + // shouldn't receive any data for a while + setTimeout(() => { + res.removeListener('data', onData) + done() + }, 1000) + }) + .on('error', done) + .end() + }) + }) + + it('should catch stream error', done => { + const PassThrough = require('stream').PassThrough + const app = new Koa() + app.once('error', err => { + assert(err.message === 'mock error') + done() + }) + + app.use(ctx => { + ctx.type = 'json' + ctx.status = 200 + ctx.headers.Link = '; as=style; rel=preload, ; rel=preconnect; crossorigin' + ctx.length = 20 + ctx.flushHeaders() + const stream = ctx.body = new PassThrough() + + setTimeout(() => { + stream.emit('error', new Error('mock error')) + }, 100) + }) + + const server = app.listen() + + request(server).get('/').end() + }) +}) diff --git a/__tests__/response/get.js b/__tests__/response/get.js new file mode 100644 index 000000000..adac7311a --- /dev/null +++ b/__tests__/response/get.js @@ -0,0 +1,36 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.get(name)', () => { + it('should get a field value, case insensitive', () => { + const ctx = context() + ctx.set('X-Foo', 'bar') + assert.strictEqual(ctx.response.get('x-FOO'), 'bar') + }) + + it('should have the same behavior as ctx.res.getHeader on undefined and null values', () => { + const ctx = context() + ctx.res.setHeader('X-Foo', undefined) + ctx.response.header['x-boo'] = null + assert.strictEqual(ctx.response.get('x-FOO'), ctx.res.getHeader('X-FOO')) + assert.strictEqual(ctx.response.get('x-bOO'), ctx.res.getHeader('X-BOO')) + }) + + it('should not convert header value type', () => { + const ctx = context() + ctx.res.setHeader('Foo-date', new Date()) + ctx.response.header['foo-map'] = new Map() + ctx.res.setHeader('Foo-empty-string', '') + ctx.res.setHeader('Foo-number', 0) + ctx.res.setHeader('Foo-null', null) + ctx.res.setHeader('Foo-undefined', undefined) + assert.ok(ctx.response.get('foo-Date') instanceof Date) + assert.ok(ctx.response.get('foo-Map') instanceof Map) + assert.strictEqual(ctx.response.get('Foo-empty-String'), '') + assert.strictEqual(ctx.response.get('Foo-Number'), 0) + assert.ok(ctx.response.get('foo-NULL') === null) + assert.ok(typeof ctx.response.get('FOO-undefined') === 'undefined') + }) +}) diff --git a/__tests__/response/has.js b/__tests__/response/has.js new file mode 100644 index 000000000..db295f0b3 --- /dev/null +++ b/__tests__/response/has.js @@ -0,0 +1,20 @@ +'use strict' + +const assert = require('assert') +const context = require('../../test-helpers/context') + +describe('ctx.response.has(name)', () => { + it('should check a field value, case insensitive way', () => { + const ctx = context() + ctx.set('X-Foo', '') + assert.ok(ctx.response.has('x-Foo')) + assert.ok(ctx.has('x-foo')) + }) + + it('should return false for non-existent header', () => { + const ctx = context() + assert.strictEqual(ctx.response.has('boo'), false) + ctx.set('x-foo', 5) + assert.strictEqual(ctx.has('x-boo'), false) + }) +}) diff --git a/__tests__/response/header.js b/__tests__/response/header.js new file mode 100644 index 000000000..1b9a8d38a --- /dev/null +++ b/__tests__/response/header.js @@ -0,0 +1,45 @@ +'use strict' + +const assert = require('assert') +const request = require('supertest') +const response = require('../../test-helpers/context').response +const Koa = require('../..') + +describe('res.header', () => { + it('should return the response header object', () => { + const res = response() + res.set('X-Foo', 'bar') + res.set('X-Number', 200) + assert.deepStrictEqual(res.header, { 'x-foo': 'bar', 'x-number': '200' }) + }) + + it('should use res.getHeaders() accessor when available', () => { + const res = response() + res.res._headers = null + res.res.getHeaders = () => ({ 'x-foo': 'baz' }) + assert.deepStrictEqual(res.header, { 'x-foo': 'baz' }) + }) + + it('should return the response header object when no mocks are in use', async () => { + const app = new Koa() + let header + + app.use(ctx => { + ctx.set('x-foo', '42') + header = Object.assign({}, ctx.response.header) + }) + + await request(app.callback()) + .get('/') + + assert.deepStrictEqual(header, { 'x-foo': '42' }) + }) + + describe('when res._headers not present', () => { + it('should return empty object', () => { + const res = response() + res.res._headers = null + assert.deepStrictEqual(res.header, {}) + }) + }) +}) diff --git a/__tests__/response/headers.js b/__tests__/response/headers.js new file mode 100644 index 000000000..1b3b212da --- /dev/null +++ b/__tests__/response/headers.js @@ -0,0 +1,20 @@ +'use strict' + +const assert = require('assert') +const response = require('../../test-helpers/context').response + +describe('res.header', () => { + it('should return the response header object', () => { + const res = response() + res.set('X-Foo', 'bar') + assert.deepStrictEqual(res.headers, { 'x-foo': 'bar' }) + }) + + describe('when res._headers not present', () => { + it('should return empty object', () => { + const res = response() + res.res._headers = null + assert.deepStrictEqual(res.headers, {}) + }) + }) +}) diff --git a/__tests__/response/inspect.js b/__tests__/response/inspect.js new file mode 100644 index 000000000..07f4fd4e3 --- /dev/null +++ b/__tests__/response/inspect.js @@ -0,0 +1,35 @@ +'use strict' + +const response = require('../../test-helpers/context').response +const assert = require('assert') +const util = require('util') + +describe('res.inspect()', () => { + describe('with no response.res present', () => { + it('should return null', () => { + const res = response() + res.body = 'hello' + delete res.res + assert.strictEqual(res.inspect(), undefined) + assert.strictEqual(util.inspect(res), 'undefined') + }) + }) + + it('should return a json representation', () => { + const res = response() + res.body = 'hello' + + const expected = { + status: 200, + message: 'OK', + header: { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': '5' + }, + body: 'hello' + } + + assert.deepStrictEqual(res.inspect(), expected) + assert.deepStrictEqual(util.inspect(res), util.inspect(expected)) + }) +}) diff --git a/__tests__/response/is.js b/__tests__/response/is.js new file mode 100644 index 000000000..6f70cc321 --- /dev/null +++ b/__tests__/response/is.js @@ -0,0 +1,85 @@ +'use strict' + +const context = require('../../test-helpers/context') +const assert = require('assert') + +describe('response.is(type)', () => { + it('should ignore params', () => { + const res = context().response + res.type = 'text/html; charset=utf-8' + + assert.strictEqual(res.is('text/*'), 'text/html') + }) + + describe('when no type is set', () => { + it('should return false', () => { + const res = context().response + + assert.strictEqual(res.is(), false) + assert.strictEqual(res.is('html'), false) + }) + }) + + describe('when given no types', () => { + it('should return the type', () => { + const res = context().response + res.type = 'text/html; charset=utf-8' + + assert.strictEqual(res.is(), 'text/html') + }) + }) + + describe('given one type', () => { + it('should return the type or false', () => { + const res = context().response + res.type = 'image/png' + + assert.strictEqual(res.is('png'), 'png') + assert.strictEqual(res.is('.png'), '.png') + assert.strictEqual(res.is('image/png'), 'image/png') + assert.strictEqual(res.is('image/*'), 'image/png') + assert.strictEqual(res.is('*/png'), 'image/png') + + assert.strictEqual(res.is('jpeg'), false) + assert.strictEqual(res.is('.jpeg'), false) + assert.strictEqual(res.is('image/jpeg'), false) + assert.strictEqual(res.is('text/*'), false) + assert.strictEqual(res.is('*/jpeg'), false) + }) + }) + + describe('given multiple types', () => { + it('should return the first match or false', () => { + const res = context().response + res.type = 'image/png' + + assert.strictEqual(res.is('png'), 'png') + assert.strictEqual(res.is('.png'), '.png') + assert.strictEqual(res.is('text/*', 'image/*'), 'image/png') + assert.strictEqual(res.is('image/*', 'text/*'), 'image/png') + assert.strictEqual(res.is('image/*', 'image/png'), 'image/png') + assert.strictEqual(res.is('image/png', 'image/*'), 'image/png') + + assert.strictEqual(res.is(['text/*', 'image/*']), 'image/png') + assert.strictEqual(res.is(['image/*', 'text/*']), 'image/png') + assert.strictEqual(res.is(['image/*', 'image/png']), 'image/png') + assert.strictEqual(res.is(['image/png', 'image/*']), 'image/png') + + assert.strictEqual(res.is('jpeg'), false) + assert.strictEqual(res.is('.jpeg'), false) + assert.strictEqual(res.is('text/*', 'application/*'), false) + assert.strictEqual(res.is('text/html', 'text/plain', 'application/json; charset=utf-8'), false) + }) + }) + + describe('when Content-Type: application/x-www-form-urlencoded', () => { + it('should match "urlencoded"', () => { + const res = context().response + res.type = 'application/x-www-form-urlencoded' + + assert.strictEqual(res.is('urlencoded'), 'urlencoded') + assert.strictEqual(res.is('json', 'urlencoded'), 'urlencoded') + assert.strictEqual(res.is('urlencoded', 'json'), 'urlencoded') + }) + }) +}) diff --git a/__tests__/response/last-modified.js b/__tests__/response/last-modified.js new file mode 100644 index 000000000..3ca452b9c --- /dev/null +++ b/__tests__/response/last-modified.js @@ -0,0 +1,35 @@ +'use strict' + +const assert = require('assert') +const response = require('../../test-helpers/context').response + +describe('res.lastModified', () => { + it('should set the header as a UTCString', () => { + const res = response() + const date = new Date() + res.lastModified = date + assert.strictEqual(res.header['last-modified'], date.toUTCString()) + }) + + it('should work with date strings', () => { + const res = response() + const date = new Date() + res.lastModified = date.toString() + assert.strictEqual(res.header['last-modified'], date.toUTCString()) + }) + + it('should get the header as a Date', () => { + // Note: Date() removes milliseconds, but it's practically important. + const res = response() + const date = new Date() + res.lastModified = date + assert.strictEqual((res.lastModified.getTime() / 1000), Math.floor(date.getTime() / 1000)) + }) + + describe('when lastModified not set', () => { + it('should get undefined', () => { + const res = response() + assert.strictEqual(res.lastModified, undefined) + }) + }) +}) diff --git a/__tests__/response/length.js b/__tests__/response/length.js new file mode 100644 index 000000000..d1246c62d --- /dev/null +++ b/__tests__/response/length.js @@ -0,0 +1,80 @@ +'use strict' + +const response = require('../../test-helpers/context').response +const assert = require('assert') +const fs = require('fs') + +describe('res.length', () => { + describe('when Content-Length is defined', () => { + it('should return a number', () => { + const res = response() + res.set('Content-Length', '1024') + assert.strictEqual(res.length, 1024) + }) + + describe('but not number', () => { + it('should return 0', () => { + const res = response() + res.set('Content-Length', 'hey') + assert.strictEqual(res.length, 0) + }) + }) + }) + + describe('when Content-Length is not defined', () => { + describe('and a .body is set', () => { + it('should return a number', () => { + const res = response() + + res.body = null + assert.strictEqual(res.length, undefined) + + res.body = 'foo' + res.remove('Content-Length') + assert.strictEqual(res.length, 3) + + res.body = 'foo' + assert.strictEqual(res.length, 3) + + res.body = Buffer.from('foo bar') + res.remove('Content-Length') + assert.strictEqual(res.length, 7) + + res.body = Buffer.from('foo bar') + assert.strictEqual(res.length, 7) + + res.body = { hello: 'world' } + res.remove('Content-Length') + assert.strictEqual(res.length, 17) + + res.body = { hello: 'world' } + assert.strictEqual(res.length, 17) + + res.body = fs.createReadStream('package.json') + assert.strictEqual(res.length, undefined) + + res.body = null + assert.strictEqual(res.length, undefined) + }) + }) + + describe('and .body is not', () => { + it('should return undefined', () => { + const res = response() + assert.strictEqual(res.length, undefined) + }) + }) + }) + + describe('and a .type is set to json', () => { + describe('and a .body is set to null', () => { + it('should return a number', () => { + const res = response() + + res.type = 'json' + res.body = null + assert.strictEqual(res.length, 4) + }) + }) + }) +}) diff --git a/__tests__/response/message.js b/__tests__/response/message.js new file mode 100644 index 000000000..dc7fc83a9 --- /dev/null +++ b/__tests__/response/message.js @@ -0,0 +1,30 @@ +'use strict' + +const assert = require('assert') +const response = require('../../test-helpers/context').response + +describe('res.message', () => { + it('should return the response status message', () => { + const res = response() + res.status = 200 + assert.strictEqual(res.message, 'OK') + }) + + describe('when res.message not present', () => { + it('should look up in statuses', () => { + const res = response() + res.res.statusCode = 200 + assert.strictEqual(res.message, 'OK') + }) + }) +}) + +describe('res.message=', () => { + it('should set response status message', () => { + const res = response() + res.status = 200 + res.message = 'ok' + assert.strictEqual(res.res.statusMessage, 'ok') + assert.strictEqual(res.inspect().message, 'ok') + }) +}) diff --git a/__tests__/response/redirect.js b/__tests__/response/redirect.js new file mode 100644 index 000000000..ec7c43f94 --- /dev/null +++ b/__tests__/response/redirect.js @@ -0,0 +1,136 @@ +'use strict' + +const assert = require('assert') +const request = require('supertest') +const context = require('../../test-helpers/context') +const Koa = require('../..') + +describe('ctx.redirect(url)', () => { + it('should redirect to the given url', () => { + const ctx = context() + ctx.redirect('http://google.com') + assert.strictEqual(ctx.response.header.location, 'http://google.com') + assert.strictEqual(ctx.status, 302) + }) + + it('should auto fix not encode url', done => { + const app = new Koa() + + app.use(ctx => { + ctx.redirect('http://google.com/😓') + }) + + request(app.callback()) + .get('/') + .end((err, res) => { + if (err) return done(err) + assert.strictEqual(res.status, 302) + assert.strictEqual(res.headers.location, 'http://google.com/%F0%9F%98%93') + done() + }) + }) + + describe('with "back"', () => { + it('should redirect to Referrer', () => { + const ctx = context() + ctx.req.headers.referrer = '/login' + ctx.redirect('back') + assert.strictEqual(ctx.response.header.location, '/login') + }) + + it('should redirect to Referer', () => { + const ctx = context() + ctx.req.headers.referer = '/login' + ctx.redirect('back') + assert.strictEqual(ctx.response.header.location, '/login') + }) + + it('should default to alt', () => { + const ctx = context() + ctx.redirect('back', '/index.html') + assert.strictEqual(ctx.response.header.location, '/index.html') + }) + + it('should default redirect to /', () => { + const ctx = context() + ctx.redirect('back') + assert.strictEqual(ctx.response.header.location, '/') + }) + }) + + describe('when html is accepted', () => { + it('should respond with html', () => { + const ctx = context() + const url = 'http://google.com' + ctx.header.accept = 'text/html' + ctx.redirect(url) + assert.strictEqual(ctx.response.header['content-type'], 'text/html; charset=utf-8') + assert.strictEqual(ctx.body, `Redirecting to ${url}.`) + }) + + it('should escape the url', () => { + const ctx = context() + let url = '