From c1ce321d6cac434809215fe05b8b6a667b730406 Mon Sep 17 00:00:00 2001 From: KaKa <23028015+climba03003@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:26:02 +0800 Subject: [PATCH] feat: implement abstract send (#199) * feat: implement abstract send * fixup --- .github/workflows/ci-abstract-send.yml | 100 + packages/abstract-send/LICENSE | 24 + packages/abstract-send/README.md | 159 ++ packages/abstract-send/eslint.config.js | 10 + .../fast-decode-uri-component.d.ts | 3 + packages/abstract-send/lib/header-if.ts | 153 + packages/abstract-send/lib/header-ranges.ts | 138 + packages/abstract-send/lib/html.ts | 14 + packages/abstract-send/lib/index.ts | 49 + packages/abstract-send/lib/mime.ts | 69 + packages/abstract-send/lib/options.ts | 206 ++ packages/abstract-send/lib/path-slash.ts | 18 + packages/abstract-send/lib/send-error.ts | 57 + .../abstract-send/lib/send-file-directly.ts | 138 + packages/abstract-send/lib/send-file.ts | 38 + packages/abstract-send/lib/send-index.ts | 29 + .../abstract-send/lib/send-not-modified.ts | 20 + packages/abstract-send/lib/send-redirect.ts | 33 + packages/abstract-send/lib/send-stat-error.ts | 15 + packages/abstract-send/lib/try-stat.ts | 21 + packages/abstract-send/lib/types.ts | 45 + packages/abstract-send/package.json | 62 + .../abstract-send/test/fixtures/.hidden.txt | 1 + .../abstract-send/test/fixtures/.mine/.hidden | 1 + .../test/fixtures/.mine/name.txt | 1 + .../abstract-send/test/fixtures/do..ts.txt | 1 + .../abstract-send/test/fixtures/empty.txt | 0 .../test/fixtures/images/node-js.png | Bin 0 -> 522 bytes .../test/fixtures/name.d/name.txt | 1 + .../test/fixtures/name.dir/name.txt | 1 + .../abstract-send/test/fixtures/name.html | 1 + packages/abstract-send/test/fixtures/name.txt | 1 + packages/abstract-send/test/fixtures/no_ext | 1 + packages/abstract-send/test/fixtures/nums.txt | 1 + .../abstract-send/test/fixtures/pets/.hidden | 1 + .../test/fixtures/pets/index.html | 3 + .../fixtures/snow \342\230\203/index.html" | 0 .../test/fixtures/some thing.txt | 1 + .../test/fixtures/thing.html.html | 1 + .../abstract-send/test/fixtures/tobi.html | 1 + packages/abstract-send/test/send.test.ts | 2532 +++++++++++++++++ packages/abstract-send/test/utils.ts | 39 + packages/abstract-send/tsconfig.cjs.json | 7 + packages/abstract-send/tsconfig.json | 29 + packages/abstract-send/tsconfig.mjs.json | 11 + pnpm-lock.yaml | 630 +++- 46 files changed, 4665 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci-abstract-send.yml create mode 100644 packages/abstract-send/LICENSE create mode 100644 packages/abstract-send/README.md create mode 100644 packages/abstract-send/eslint.config.js create mode 100644 packages/abstract-send/fast-decode-uri-component.d.ts create mode 100644 packages/abstract-send/lib/header-if.ts create mode 100644 packages/abstract-send/lib/header-ranges.ts create mode 100644 packages/abstract-send/lib/html.ts create mode 100644 packages/abstract-send/lib/index.ts create mode 100644 packages/abstract-send/lib/mime.ts create mode 100644 packages/abstract-send/lib/options.ts create mode 100644 packages/abstract-send/lib/path-slash.ts create mode 100644 packages/abstract-send/lib/send-error.ts create mode 100644 packages/abstract-send/lib/send-file-directly.ts create mode 100644 packages/abstract-send/lib/send-file.ts create mode 100644 packages/abstract-send/lib/send-index.ts create mode 100644 packages/abstract-send/lib/send-not-modified.ts create mode 100644 packages/abstract-send/lib/send-redirect.ts create mode 100644 packages/abstract-send/lib/send-stat-error.ts create mode 100644 packages/abstract-send/lib/try-stat.ts create mode 100644 packages/abstract-send/lib/types.ts create mode 100644 packages/abstract-send/package.json create mode 100644 packages/abstract-send/test/fixtures/.hidden.txt create mode 100644 packages/abstract-send/test/fixtures/.mine/.hidden create mode 100644 packages/abstract-send/test/fixtures/.mine/name.txt create mode 100644 packages/abstract-send/test/fixtures/do..ts.txt create mode 100644 packages/abstract-send/test/fixtures/empty.txt create mode 100644 packages/abstract-send/test/fixtures/images/node-js.png create mode 100644 packages/abstract-send/test/fixtures/name.d/name.txt create mode 100644 packages/abstract-send/test/fixtures/name.dir/name.txt create mode 100644 packages/abstract-send/test/fixtures/name.html create mode 100644 packages/abstract-send/test/fixtures/name.txt create mode 100644 packages/abstract-send/test/fixtures/no_ext create mode 100644 packages/abstract-send/test/fixtures/nums.txt create mode 100644 packages/abstract-send/test/fixtures/pets/.hidden create mode 100644 packages/abstract-send/test/fixtures/pets/index.html create mode 100644 "packages/abstract-send/test/fixtures/snow \342\230\203/index.html" create mode 100644 packages/abstract-send/test/fixtures/some thing.txt create mode 100644 packages/abstract-send/test/fixtures/thing.html.html create mode 100644 packages/abstract-send/test/fixtures/tobi.html create mode 100644 packages/abstract-send/test/send.test.ts create mode 100644 packages/abstract-send/test/utils.ts create mode 100644 packages/abstract-send/tsconfig.cjs.json create mode 100644 packages/abstract-send/tsconfig.json create mode 100644 packages/abstract-send/tsconfig.mjs.json diff --git a/.github/workflows/ci-abstract-send.yml b/.github/workflows/ci-abstract-send.yml new file mode 100644 index 00000000..29a48926 --- /dev/null +++ b/.github/workflows/ci-abstract-send.yml @@ -0,0 +1,100 @@ +name: Continuous Integration - Abstract Send + +on: + push: + paths: + - ".github/workflows/ci-abstract-send.yml" + - "packages/abstract-send/**" + pull_request: + paths: + - ".github/workflows/ci-abstract-send.yml" + - "packages/abstract-send/**" + +jobs: + linter: + name: Lint Code + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Lint code + run: pnpm --filter "./packages/abstract-send" run lint + + test: + name: Test + needs: linter + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + matrix: + node-version: [20, 22] + os: [macos-latest, ubuntu-latest, windows-latest] + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-${{ matrix.node-version }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.node-version }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Build @kakang/unit + run: pnpm --filter "./packages/unit" run build + + - name: Run tests + run: pnpm --filter "./packages/abstract-send" run test diff --git a/packages/abstract-send/LICENSE b/packages/abstract-send/LICENSE new file mode 100644 index 00000000..564eed14 --- /dev/null +++ b/packages/abstract-send/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2012 TJ Holowaychuk +Copyright (c) 2014-2022 Douglas Christopher Wilson +Copyright (c) 2023-2024 The Fastify Team +Copyright (c) 2024 KaKa Ng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/abstract-send/README.md b/packages/abstract-send/README.md new file mode 100644 index 00000000..d79f479d --- /dev/null +++ b/packages/abstract-send/README.md @@ -0,0 +1,159 @@ +# @kakang/abstract-send + +The code on this package is based on [`@fastify/send`](https://github.com/fastify/send) +and ported to TypeScript with some additional features. + +Send is a library for streaming files from the file system as an HTTP response +supporting partial responses (Ranges), conditional-GET negotiation (If-Match, +If-Unmodified-Since, If-None-Match, If-Modified-Since), high test coverage, +and granular events which may be leveraged to take appropriate actions in your +application or framework. + +## Installation + +```bash +$ npm install @kakang/abstract-send +``` + +## API + +```js +var send = require('@fastify/send') +``` + +### send(req, path, [options]) + +Provide `statusCode`, `headers` and `stream` for the given path to send to a +`res`. The `req` is the Node.js HTTP request and the `path `is a urlencoded path +to send (urlencoded, not the actual file-system path). + +#### Options + +##### acceptRanges + +Enable or disable accepting ranged requests, defaults to true. +Disabling this will not send `Accept-Ranges` and ignore the contents +of the `Range` request header. + +##### cacheControl + +Enable or disable setting `Cache-Control` response header, defaults to +true. Disabling this will ignore the `immutable` and `maxAge` options. + +##### dotfiles + +Set how "dotfiles" are treated when encountered. A dotfile is a file +or directory that begins with a dot ("."). Note this check is done on +the path itself without checking if the path exists on the +disk. If `root` is specified, only the dotfiles above the root are +checked (i.e. the root itself can be within a dotfile when set +to "deny"). + + - `'allow'` No special treatment for dotfiles. + - `'deny'` Send a 403 for any request for a dotfile. + - `'ignore'` Pretend like the dotfile does not exist and 404. + +The default value is _similar_ to `'ignore'`, with the exception that +this default will not ignore the files within a directory that begins +with a dot, for backward-compatibility. + +##### end + +Byte offset at which the stream ends, defaults to the length of the file +minus 1. The end is inclusive in the stream, meaning `end: 3` will include +the 4th byte in the stream. + +##### etag + +Enable or disable etag generation, defaults to true. + +##### extensions + +If a given file doesn't exist, try appending one of the given extensions, +in the given order. By default, this is disabled (set to `false`). An +example value that will serve extension-less HTML files: `['html', 'htm']`. +This is skipped if the requested file already has an extension. + +##### immutable + +Enable or disable the `immutable` directive in the `Cache-Control` response +header, defaults to `false`. If set to `true`, the `maxAge` option should +also be specified to enable caching. The `immutable` directive will prevent +supported clients from making conditional requests during the life of the +`maxAge` option to check if the file has changed. + +##### index + +By default send supports "index.html" files, to disable this +set `false` or to supply a new index pass a string or an array +in preferred order. + +##### lastModified + +Enable or disable `Last-Modified` header, defaults to true. Uses the file +system's last modified value. + +##### maxAge + +Provide a max-age in milliseconds for HTTP caching, defaults to 0. +This can also be a string accepted by the +[ms](https://www.npmjs.org/package/ms#readme) module. + +##### root + +Serve files relative to `path`. + +##### start + +Byte offset at which the stream starts, defaults to 0. The start is inclusive, +meaning `start: 2` will include the 3rd byte in the stream. + +### .mime + +The `mime` export is a proxy of global instance +of the [`mime` npm module](https://www.npmjs.com/package/mime). +The methods is transformed to async, to resolve the ESM import +on CommonJS environment. + +This is used to configure the MIME types that are associated with file extensions +as well as other options for how to resolve the MIME type of a file (like the +default type to use for an unknown file extension). + +#### .mime.getType(path) + +```js +import { mime } from '@kakang/abstract-send' +await mime.getType('package.json') +``` + +#### .mime.getExtension(type) + +```js +import { mime } from '@kakang/abstract-send' +await mime.getExtension('application/json') +``` + +#### .mime.getAllExtensions(type) + +```js +import { mime } from '@kakang/abstract-send' +await mime.getAllExtensions('application/json') +``` + +#### .mime.define(typeMap, force) + +```js +import { mime } from '@kakang/abstract-send' +await mime.define({ + 'app/new-extension': ['.app'] +}, false) +``` + +#### .mime.default_type + +It is the default type used by this package when +`mime` cannot resolve a proper type. + +## License + +[MIT](LICENSE) diff --git a/packages/abstract-send/eslint.config.js b/packages/abstract-send/eslint.config.js new file mode 100644 index 00000000..ea013b92 --- /dev/null +++ b/packages/abstract-send/eslint.config.js @@ -0,0 +1,10 @@ +'use strict' + +const neostandard = require('neostandard') + +module.exports = [ + { + ignores: ['**/*.d.ts'], + }, + ...neostandard({ ts: true }), +] diff --git a/packages/abstract-send/fast-decode-uri-component.d.ts b/packages/abstract-send/fast-decode-uri-component.d.ts new file mode 100644 index 00000000..ce0fae73 --- /dev/null +++ b/packages/abstract-send/fast-decode-uri-component.d.ts @@ -0,0 +1,3 @@ +declare module 'fast-decode-uri-component' { + export default (path: string): string | undefined +} diff --git a/packages/abstract-send/lib/header-if.ts b/packages/abstract-send/lib/header-if.ts new file mode 100644 index 00000000..4a5fa47c --- /dev/null +++ b/packages/abstract-send/lib/header-if.ts @@ -0,0 +1,153 @@ +import { IncomingMessage } from 'node:http' +import { Headers } from './types' + +const slice = String.prototype.slice + +function parseTokenList (str: string, cb: (match: string) => boolean | undefined): boolean | undefined { + let end = 0 + let start = 0 + let result + + // gather tokens + for (let i = 0, len = str.length; i < len; i++) { + switch (str.charCodeAt(i)) { + case 0x20: /* */ + if (start === end) { + start = end = i + 1 + } + break + case 0x2c: /* , */ + if (start !== end) { + result = cb(slice.call(str, start, end)) + if (result !== undefined) { + return result + } + } + start = end = i + 1 + break + default: + end = i + 1 + break + } + } + + // final token + if (start !== end) { + return cb(slice.call(str, start, end)) + } +} + +export function isConditionalGET (request: IncomingMessage) { + return request.headers['if-match'] || + request.headers['if-unmodified-since'] || + request.headers['if-none-match'] || + request.headers['if-modified-since'] +} + +export function isPreconditionFailure (request: IncomingMessage, headers: Headers) { + // if-match + const ifMatch = request.headers['if-match'] + if (ifMatch) { + const etag = headers.ETag + + if (ifMatch !== '*') { + const isMatching = parseTokenList(ifMatch, function (match) { + if ( + match === etag || + 'W/' + match === etag + ) { + return true + } + }) || false + + if (isMatching !== true) { + return true + } + } + } + + // if-unmodified-since + if ('if-unmodified-since' in request.headers) { + const ifUnmodifiedSince = request.headers['if-unmodified-since'] + const unmodifiedSince = Date.parse(ifUnmodifiedSince as string) + // eslint-disable-next-line no-self-compare + if (unmodifiedSince === unmodifiedSince) { // fast path of isNaN(number) + const lastModified = Date.parse(headers['Last-Modified'] as string) + if ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified ||// fast path of isNaN(number) + lastModified > unmodifiedSince + ) { + return true + } + } + } + + return false +} + +export function isNotModifiedFailure (request: IncomingMessage, headers: Headers) { + // Always return stale when Cache-Control: no-cache + // to support end-to-end reload requests + // https://tools.ietf.org/html/rfc2616#section-14.9.4 + if ( + 'cache-control' in request.headers && + (request.headers as any)['cache-control'].indexOf('no-cache') !== -1 + ) { + return false + } + + // if-none-match + if ('if-none-match' in request.headers) { + const ifNoneMatch = request.headers['if-none-match'] as string + + if (ifNoneMatch === '*') { + return true + } + + const etag = headers.ETag + + if (typeof etag !== 'string') { + return false + } + + const etagL = etag.length + const isMatching = parseTokenList(ifNoneMatch, function (match) { + const mL = match.length + + if ( + (etagL === mL && match === etag) || + (etagL > mL && 'W/' + match === etag) + ) { + return true + } + }) + + if (isMatching) { + return true + } + + /** + * A recipient MUST ignore If-Modified-Since if the request contains an + * If-None-Match header field; the condition in If-None-Match is considered + * to be a more accurate replacement for the condition in If-Modified-Since, + * and the two are only combined for the sake of interoperating with older + * intermediaries that might not implement If-None-Match. + * + * @see RFC 9110 section 13.1.3 + */ + return false + } + + // if-modified-since + if ('if-modified-since' in request.headers) { + const ifModifiedSince = request.headers['if-modified-since'] as string + const lastModified = headers['Last-Modified'] as string + + if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { + return true + } + } + + return false +} diff --git a/packages/abstract-send/lib/header-ranges.ts b/packages/abstract-send/lib/header-ranges.ts new file mode 100644 index 00000000..fa063096 --- /dev/null +++ b/packages/abstract-send/lib/header-ranges.ts @@ -0,0 +1,138 @@ +import { IncomingMessage } from 'node:http' +import { Headers } from './types' + +export function isRangeFresh (request: IncomingMessage, headers: Headers): boolean { + if (!('if-range' in request.headers)) { + return true + } + + const ifRange = request.headers['if-range'] as string + + // if-range as etag + if (ifRange.indexOf('"') !== -1) { + const etag = headers.ETag as string + return (etag && ifRange.indexOf(etag) !== -1) || false + } + + const ifRangeTimestamp = Date.parse(ifRange) + // eslint-disable-next-line no-self-compare + if (ifRangeTimestamp !== ifRangeTimestamp) { // fast path of isNaN(number) + return false + } + + // if-range as modified date + const lastModified = Date.parse(headers['Last-Modified'] as string) + + return ( + // eslint-disable-next-line no-self-compare + lastModified !== lastModified || // fast path of isNaN(number) + lastModified <= ifRangeTimestamp + ) +} + +export function parseBytesRange (size: number, str: string) { + // split the range string + const values = str.slice(str.indexOf('=') + 1) + const ranges: Array<{ start: number, end: number, index: number }> = [] + + const len = values.length + let i = 0 + let il = 0 + let j = 0 + let start + let end + let commaIdx = values.indexOf(',') + let dashIdx = values.indexOf('-') + let prevIdx = -1 + + // parse all ranges + while (true) { + commaIdx === -1 && (commaIdx = len) + start = parseInt(values.slice(prevIdx + 1, dashIdx), 10) + end = parseInt(values.slice(dashIdx + 1, commaIdx), 10) + + // -nnn + // eslint-disable-next-line no-self-compare + if (start !== start) { // fast path of isNaN(number) + start = size - end + end = size - 1 + // nnn- + // eslint-disable-next-line no-self-compare + } else if (end !== end) { // fast path of isNaN(number) + end = size - 1 + // limit last-byte-pos to current length + } else if (end > size - 1) { + end = size - 1 + } + + // add range only on valid ranges + if ( + // eslint-disable-next-line no-self-compare + start === start && // fast path of isNaN(number) + // eslint-disable-next-line no-self-compare + end === end && // fast path of isNaN(number) + start > -1 && + start <= end + ) { + // add range + ranges.push({ + start, + end, + index: j++ + }) + } + + if (commaIdx === len) { + break + } + prevIdx = commaIdx++ + dashIdx = values.indexOf('-', commaIdx) + commaIdx = values.indexOf(',', commaIdx) + } + + // unsatisfiable + if ( + j < 2 + ) { + return ranges + } + + ranges.sort(sortByRangeStart) + + il = j + j = 0 + i = 1 + while (i < il) { + const range = ranges[i++] + const current = ranges[j] + + if (range.start > current.end + 1) { + // next range + ranges[++j] = range + } else if (range.end > current.end) { + // extend range + current.end = range.end + current.index > range.index && (current.index = range.index) + } + } + + // trim ordered array + ranges.length = j + 1 + + // generate combined range + ranges.sort(sortByRangeIndex) + + return ranges +} + +function sortByRangeIndex (a: { start: number, end: number, index: number }, b: { start: number, end: number, index: number }) { + return a.index - b.index +} + +function sortByRangeStart (a: { start: number, end: number, index: number }, b: { start: number, end: number, index: number }) { + return a.start - b.start +} + +export function contentRange (type: string, size: number, range?: { start: number, end: number, index: number }): string { + return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size +} diff --git a/packages/abstract-send/lib/html.ts b/packages/abstract-send/lib/html.ts new file mode 100644 index 00000000..bbf507bf --- /dev/null +++ b/packages/abstract-send/lib/html.ts @@ -0,0 +1,14 @@ +export function createHtmlDocument (title: string, body: string): [string, number] { + const html = '\n' + + '\n' + + '
\n' + + '\n' + + '' + body + '\n' + + '\n' + + '\n' + + return [html, Buffer.byteLength(html)] +} diff --git a/packages/abstract-send/lib/index.ts b/packages/abstract-send/lib/index.ts new file mode 100644 index 00000000..1bee5194 --- /dev/null +++ b/packages/abstract-send/lib/index.ts @@ -0,0 +1,49 @@ +import { IncomingMessage } from 'http' +import { containDotFile, normalizeOptions, normalizePath, SendOptions } from './options' +import { hasTrailingSlash } from './path-slash' +import { sendError } from './send-error' +import { sendFile } from './send-file' +import { sendIndex } from './send-index' +import { SendResult } from './types' + +export async function send (request: IncomingMessage, requestPath: string, options?: SendOptions): Promise
tobi
\ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/name.txt b/packages/abstract-send/test/fixtures/name.txt new file mode 100644 index 00000000..fa66f37f --- /dev/null +++ b/packages/abstract-send/test/fixtures/name.txt @@ -0,0 +1 @@ +tobi \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/no_ext b/packages/abstract-send/test/fixtures/no_ext new file mode 100644 index 00000000..f6ea0495 --- /dev/null +++ b/packages/abstract-send/test/fixtures/no_ext @@ -0,0 +1 @@ +foobar \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/nums.txt b/packages/abstract-send/test/fixtures/nums.txt new file mode 100644 index 00000000..e2e107ac --- /dev/null +++ b/packages/abstract-send/test/fixtures/nums.txt @@ -0,0 +1 @@ +123456789 \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/pets/.hidden b/packages/abstract-send/test/fixtures/pets/.hidden new file mode 100644 index 00000000..d97c5ead --- /dev/null +++ b/packages/abstract-send/test/fixtures/pets/.hidden @@ -0,0 +1 @@ +secret diff --git a/packages/abstract-send/test/fixtures/pets/index.html b/packages/abstract-send/test/fixtures/pets/index.html new file mode 100644 index 00000000..5106b6f5 --- /dev/null +++ b/packages/abstract-send/test/fixtures/pets/index.html @@ -0,0 +1,3 @@ +tobi +loki +jane \ No newline at end of file diff --git "a/packages/abstract-send/test/fixtures/snow \342\230\203/index.html" "b/packages/abstract-send/test/fixtures/snow \342\230\203/index.html" new file mode 100644 index 00000000..e69de29b diff --git a/packages/abstract-send/test/fixtures/some thing.txt b/packages/abstract-send/test/fixtures/some thing.txt new file mode 100644 index 00000000..2b31011c --- /dev/null +++ b/packages/abstract-send/test/fixtures/some thing.txt @@ -0,0 +1 @@ +hey \ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/thing.html.html b/packages/abstract-send/test/fixtures/thing.html.html new file mode 100644 index 00000000..d5644325 --- /dev/null +++ b/packages/abstract-send/test/fixtures/thing.html.html @@ -0,0 +1 @@ +trap!
\ No newline at end of file diff --git a/packages/abstract-send/test/fixtures/tobi.html b/packages/abstract-send/test/fixtures/tobi.html new file mode 100644 index 00000000..52b19230 --- /dev/null +++ b/packages/abstract-send/test/fixtures/tobi.html @@ -0,0 +1 @@ +tobi
\ No newline at end of file diff --git a/packages/abstract-send/test/send.test.ts b/packages/abstract-send/test/send.test.ts new file mode 100644 index 00000000..58024f03 --- /dev/null +++ b/packages/abstract-send/test/send.test.ts @@ -0,0 +1,2532 @@ +'use strict' + +import fs from 'node:fs' +import { readdir } from 'node:fs/promises' +import http from 'node:http' +import path from 'node:path' +import { test, TestContext } from 'node:test' +import request from 'supertest' +import { send } from '../lib' +import { createServer, shouldNotHaveBody, shouldNotHaveHeader, withResolvers } from './utils' + +// test server + +const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ +const fixtures = path.join(__dirname, 'fixtures') + +test('send(file, options)', async function (t: TestContext) { + await t.test('acceptRanges', async function (t: TestContext) { + await t.test('should support disabling accept-ranges', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ acceptRanges: false, root: fixtures })) + .get('/nums.txt') + .expect(shouldNotHaveHeader('Accept-Ranges', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await await t.test('should ignore requested range', async function (t: TestContext) { + t.plan(3) + + const { promise, resolve } = withResolvers() + request(createServer({ acceptRanges: false, root: fixtures })) + .get('/nums.txt') + .set('Range', 'bytes=0-2') + .expect(shouldNotHaveHeader('Accept-Ranges', t)) + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('cacheControl', async function (t: TestContext) { + await t.test('should support disabling cache-control', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ cacheControl: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Cache-Control', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should ignore maxAge option', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Cache-Control', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('etag', async function (t: TestContext) { + await t.test('should support disabling etags', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ etag: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('ETag', t)) + .expect(200, (err) => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('extensions', async function (t: TestContext) { + await t.test('should reject numbers', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + // @ts-expect-error - check invalid value + request(createServer({ extensions: 42, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should reject true', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: true, root: fixtures })) + .get('/pets/') + .expect(500, /TypeError: extensions option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be not be enabled by default', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/tobi') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be configurable', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: 'txt', root: fixtures })) + .get('/name') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support disabling extensions', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: false, root: fixtures })) + .get('/name') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support fallbacks', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/name') + .expect(200, 'tobi
', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if nothing found', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) + .get('/bob') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should skip directories', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: ['file', 'dir'], root: fixtures })) + .get('/name') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not search if file has extension', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ extensions: 'html', root: fixtures })) + .get('/thing.html') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('lastModified', async function (t: TestContext) { + await t.test('should support disabling last-modified', async function (t: TestContext) { + t.plan(2) + + const { promise, resolve } = withResolvers() + request(createServer({ lastModified: false, root: fixtures })) + .get('/name.txt') + .expect(shouldNotHaveHeader('Last-Modified', t)) + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('dotfiles', async function (t: TestContext) { + const { promise, resolve } = withResolvers() + await t.test('should default to "ignore"', async function (t: TestContext) { + t.plan(1) + + request(createServer({ root: fixtures })) + .get('/.hidden.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should reject bad value', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + // @ts-expect-error - check invalid value + request(createServer({ dotfiles: 'bogus' })) + .get('/name.txt') + .expect(500, /dotfiles/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('when "allow"', async function (t: TestContext) { + await t.test('should send dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.hidden.txt') + .expect(200, 'secret', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should send within dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.mine/name.txt') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for non-existent dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'allow', root: fixtures })) + .get('/.nothere') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when "deny"', async function (t: TestContext) { + await t.test('should 403 for dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.hidden.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile directory with trailing slash', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for file within dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for non-existent dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.nothere') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for non-existent dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.what/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile in directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/pets/.hidden') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile in dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: fixtures })) + .get('/.mine/.hidden') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should send files in root dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 403 for dotfile without root', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url as string, { dotfiles: 'deny' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when "ignore"', async function (t: TestContext) { + await t.test('should 404 for dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.hidden.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for dotfile directory with trailing slash', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for file within dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.mine/name.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for non-existent dotfile', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.nothere') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for non-existent dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: fixtures })) + .get('/.what/name.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should send files in root dotfile directory', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) + .get('/name.txt') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 for dotfile without root', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url as string, { dotfiles: 'ignore' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('immutable', async function (t: TestContext) { + await t.test('should default to false', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set immutable directive in Cache-Control', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=3600, immutable', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('maxAge', async function (t: TestContext) { + await t.test('should default to 0', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=0', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should floor to integer', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ maxAge: 123956, root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=123', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should accept string', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ maxAge: '30d', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=2592000', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should max at 1 year', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ maxAge: '2y', root: fixtures })) + .get('/name.txt') + .expect('Cache-Control', 'public, max-age=31536000', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('index', async function (t: TestContext) { + await t.test('should reject numbers', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + // @ts-expect-error - check invalid value + request(createServer({ root: fixtures, index: 42 })) + .get('/pets/') + .expect(500, /TypeError: index option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should reject true', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: true })) + .get('/pets/') + .expect(500, /TypeError: index option/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should default to index.html', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/') + .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be configurable', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: 'tobi.html' })) + .get('/') + .expect(200, 'tobi
', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support disabling', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: false })) + .get('/pets/') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support fallbacks', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) + .get('/pets/') + .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8'), err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if no index file found (file)', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: 'default.htm' })) + .get('/pets/') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if no index file found (dir)', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: 'pets' })) + .get('/') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not follow directories', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) + .get('/') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should work without root', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function (req, res) { + const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' + const { statusCode, headers, stream } = await send(req, p, { index: ['index.html'] }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/') + .expect(200, /tobi/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('root', async function (t: TestContext) { + await t.test('when given', async function (t: TestContext) { + await t.test('should join root', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/../name.txt') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should work with trailing slash', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures + '/' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should work with empty path', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(301, /Redirecting to/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + // + // NOTE: This is not a real part of the API, but + // over time this has become something users + // are doing, so this will prevent unseen + // regressions around this use-case. + // + await t.test('should try as file with empty path', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, '', { root: path.join(fixtures, 'name.txt') }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should restrict paths to within root', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/../../send.js') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should allow .. in root', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures + '/../fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/pets/../../send.js') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not allow root transversal', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: path.join(fixtures, 'name.d') })) + .get('/../name.dir/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not allow root path disclosure', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets/../../fixtures/name.txt') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when missing', async function (t: TestContext) { + await t.test('should consider .. malicious', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/../send.js') + .expect(403, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should still serve files with dots in name', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, fixtures + req.url) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/do..ts.txt') + .expect(200, '...', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('should stream the file contents', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should stream a zero-length file', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/empty.txt') + .expect('Content-Length', '0') + .expect(200, '', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should decode the given path as a URI', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/some%20thing.txt') + .expect(200, 'hey', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should serve files with dots in name', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/do..ts.txt') + .expect(200, '...', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should treat a malformed URI as a bad request', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/some%99thing.txt') + .expect(400, /Bad Request/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 400 on NULL bytes', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/some%00thing.txt') + .expect(400, /Bad Request/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should treat an ENAMETOOLONG as a 404', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support HEAD', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .head('/name.txt') + .expect(200) + .expect('Content-Length', '4') + .expect(shouldNotHaveBody(t)) + .end(err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add an ETag header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('etag', /^W\/"[^"]+"$/) + .end(err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add a Date header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('date', dateRegExp, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add a Last-Modified header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('last-modified', dateRegExp, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should add a Accept-Ranges header field', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Accept-Ranges', 'bytes', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if the file does not exist', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/meow') + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if the filename is too long', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const longFilename = new Array(512).fill('a').join('') + + const { promise, resolve } = withResolvers() + request(app) + .get('/' + longFilename) + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should 404 if the requested resource is not a directory', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt/invalid') + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not override content-type', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'Content-Type': 'application/x-custom' + }) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Content-Type', 'application/x-custom', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set Content-Type via mime map', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, function (err) { + request(app) + .get('/tobi.html') + .expect('Content-Type', 'text/html; charset=UTF-8') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('send directory', async function (t: TestContext) { + await t.test('should redirect directories to trailing slash', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with an HTML redirect', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/pets\/<\/a>, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with default Content-Security-Policy', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/pets') + .expect('Location', '/pets/') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not redirect to protocol-relative locations', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('//pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with an HTML redirect', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, (req.url as string).replace('/snow', '/snow ☃'), { root: 'test/fixtures' }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/snow') + .expect('Location', '/snow%20%E2%98%83/') + .expect('Content-Type', /html/) + .expect(301, />Redirecting to \/snow%20%E2%98%83\/<\/a>, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('send error', async function (t: TestContext) { + await t.test('should respond to errors directly', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/foobar') + .expect(404, />Not Found, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with default Content-Security-Policy', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures })) + .get('/foobar') + .expect('Content-Security-Policy', "default-src 'none'") + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('with conditional-GET', async function (t: TestContext) { + await t.test('should remove Content headers with 304', async function (t: TestContext) { + t.plan(2) + + const server = createServer({ root: fixtures }, function (req, res) { + res.setHeader('Content-Language', 'en-US') + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Contents', 'foo') + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Contents', 'foo') + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should not remove all Content-* headers', async function (t: TestContext) { + t.plan(2) + + const server = createServer({ root: fixtures }, function (req, res) { + res.setHeader('Content-Location', 'http://localhost/name.txt') + res.setHeader('Content-Security-Policy', 'default-src \'self\'') + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/name.txt') + .expect(200, function (err, res) { + request(server) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect('Content-Location', 'http://localhost/name.txt') + .expect('Content-Security-Policy', 'default-src \'self\'') + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('where "If-Match" is set', async function (t: TestContext) { + await t.test('should respond with 200 when "*"', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .set('If-Match', '*') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 412 when ETag unmatched', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .set('If-Match', ' "foo",, "bar" ,') + .expect(412, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag matched /1', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Match', '"foo", "bar", ' + res.headers.etag) + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag matched /2', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Match', '"foo", ' + res.headers.etag + ', "bar"') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('where "If-Modified-Since" is set', async function (t: TestContext) { + await t.test('should respond with 304 when unmodified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when modified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + const lmod = new Date(res.headers['last-modified']) as never as number + const date = new Date(lmod - 60000) + request(app) + .get('/name.txt') + .set('If-Modified-Since', date.toUTCString()) + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when modified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Modified-Since', res.headers['last-modified']) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('where "If-None-Match" is set', async function (t: TestContext) { + await t.test('should respond with 304 when ETag matched', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .expect(304, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag unmatched', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when ETag is not generated', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', '"123"') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 306 Not Modified when using wildcard * on existing file', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', '*') + .expect(304, '', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 404 Not Found when using wildcard * on non-existing file', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { etag: false, root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/asdf.txt') + .set('If-None-Match', '*') + .expect(404, /Not Found/, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 cache-control is set to no-cache', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-None-Match', res.headers.etag) + .set('cache-control', 'no-cache') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('where "If-Unmodified-Since" is set', async function (t: TestContext) { + await t.test('should respond with 200 when unmodified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', res.headers['last-modified']) + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 412 when modified', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .expect(200, function (err, res) { + const lmod = new Date(res.headers['last-modified']) as never as number + const date = new Date(lmod - 60000).toUTCString() + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', date) + .expect(412, err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when invalid date', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/name.txt') + .set('If-Unmodified-Since', 'foo') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('with Range request', async function (t: TestContext) { + await t.test('should support byte ranges', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, '12345', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should ignore non-byte ranges', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'items=0-4') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should be inclusive', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-0') + .expect(206, '1', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set Content-Range', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-5') + .expect('Content-Range', 'bytes 2-5/9') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support -n', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=-3') + .expect(206, '789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support n-', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=3-') + .expect(206, '456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 206 "Partial Content"', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=0-4') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should set Content-Length to the # of octets transferred', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-3') + .expect('Content-Length', '2') + .expect(206, '34', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('when last-byte-pos of the range is greater the length', async function (t: TestContext) { + await t.test('is taken to be equal to one less than the length', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Range', 'bytes 2-8/9') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should adapt the Content-Length accordingly', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=2-50') + .expect('Content-Length', '7') + .expect(206, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when the first- byte-pos of the range is greater length', async function (t: TestContext) { + await t.test('should respond with 416', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('Content-Range', 'bytes */9') + .expect(416, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should emit error 416 with content-range header', async function (t: TestContext) { + t.plan(1) + + const server = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, { + ...headers, + 'X-Content-Range': headers['Content-Range'] + }) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(server) + .get('/nums.txt') + .set('Range', 'bytes=9-50') + .expect('X-Content-Range', 'bytes */9') + .expect(416, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when syntactically invalid', async function (t: TestContext) { + await t.test('should respond with 200 and the entire contents', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'asdf') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when multiple ranges', async function (t: TestContext) { + await t.test('should respond with 200 and the entire contents', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-1,3-') + .expect(shouldNotHaveHeader('Content-Range', t)) + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 206 is all ranges can be combined', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('Range', 'bytes=1-2,3-5') + .expect('Content-Range', 'bytes 1-5/9') + .expect(206, '23456', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('when if-range present', async function (t: TestContext) { + await t.test('should respond with parts when etag unchanged', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const etag = res.headers.etag + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when etag changed', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const etag = res.headers.etag.replace(/"(.)/, '"0$1') + + request(app) + .get('/nums.txt') + .set('If-Range', etag) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with parts when modified unchanged', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const modified = res.headers['last-modified'] + + request(app) + .get('/nums.txt') + .set('If-Range', modified) + .set('Range', 'bytes=0-0') + .expect(206, '1', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when modified changed', async function (t: TestContext) { + t.plan(2) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .expect(200, function (err, res) { + const modified = Date.parse(res.headers['last-modified']) - 20000 + + request(app) + .get('/nums.txt') + .set('If-Range', new Date(modified).toUTCString()) + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should respond with 200 when invalid value', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream } = await send(req, req.url as string, { root: fixtures }) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/nums.txt') + .set('If-Range', 'foo') + .set('Range', 'bytes=0-0') + .expect(200, '123456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + }) + + await t.test('when "options" is specified', async function (t: TestContext) { + await t.test('should support start/end', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 3, end: 5 })) + .get('/nums.txt') + .expect(200, '456', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should adjust too large end', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 3, end: 90 })) + .get('/nums.txt') + .expect(200, '456789', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support start/end with Range request', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=-2') + .expect(206, '23', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('should support start/end with unsatisfiable Range request', async function (t: TestContext) { + t.plan(1) + + const { promise, resolve } = withResolvers() + request(createServer({ root: fixtures, start: 0, end: 2 })) + .get('/nums.txt') + .set('Range', 'bytes=5-9') + .expect('Content-Range', 'bytes */3') + .expect(416, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + }) + + await t.test('file type', async function (t: TestContext) { + t.plan(6) + + const { promise, resolve } = withResolvers() + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + t.assert.strictEqual(type, 'file') + t.assert.ok((metadata as any).path) + t.assert.ok((metadata as any).stat) + t.assert.ok(!(metadata as any).error) + t.assert.ok(!(metadata as any).requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('directory type', async function (t: TestContext) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + t.assert.strictEqual(type, 'directory') + t.assert.ok((metadata as any).path) + t.assert.ok(!(metadata as any).stat) + t.assert.ok(!(metadata as any).error) + t.assert.ok((metadata as any).requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('error type', async function (t: TestContext) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + t.assert.strictEqual(type, 'error') + t.assert.ok(!(metadata as any).path) + t.assert.ok(!(metadata as any).stat) + t.assert.ok((metadata as any).error) + t.assert.ok(!(metadata as any).requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const { promise, resolve } = withResolvers() + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('custom directory index view', async function (t: TestContext) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + if (type === 'directory') { + const list = await readdir(metadata.path) + res.writeHead(200, { 'Content-Type': 'text/plain; charset=UTF-8' }) + res.end(list.join('\n') + '\n') + } else { + res.writeHead(statusCode, headers) + stream.pipe(res) + } + }) + + const { promise, resolve } = withResolvers() + request(app) + .get('/pets') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, '.hidden\nindex.html\n', err => { + resolve() + return t.assert.ifError(err) + }) + await promise + }) + + await t.test('serving from a root directory with custom error-handling', async function (t: TestContext) { + t.plan(3) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url as string, { root: fixtures }) + switch (type) { + case 'directory': { + res.writeHead(301, { + Location: metadata.requestPath + '/' + }) + res.end('Redirecting to ' + metadata.requestPath + '/') + break + } + case 'error': { + res.writeHead((metadata as any).error.status ?? 500, {}) + res.end(metadata.error.message) + break + } + default: { + // serve all files for download + res.setHeader('Content-Disposition', 'attachment') + res.writeHead(statusCode, headers) + stream.pipe(res) + } + } + }) + + const promises = [] + { + const { promise, resolve } = withResolvers() + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => { + resolve() + return t.assert.ifError(err) + }) + promises.push(promise) + } + + { + const { promise, resolve } = withResolvers() + request(app) + .get('/not-exists') + .expect(404, err => { + resolve() + return t.assert.ifError(err) + }) + promises.push(promise) + } + + { + const { promise, resolve } = withResolvers() + request(app) + .get('/pets/index.html') + .expect('Content-Disposition', 'attachment') + .expect(200, err => { + resolve() + return t.assert.ifError(err) + }) + promises.push(promise) + } + + await Promise.all(promises) + }) +}) diff --git a/packages/abstract-send/test/utils.ts b/packages/abstract-send/test/utils.ts new file mode 100644 index 00000000..136ccca3 --- /dev/null +++ b/packages/abstract-send/test/utils.ts @@ -0,0 +1,39 @@ +import http, { IncomingMessage, ServerResponse } from 'node:http' +import { TestContext } from 'node:test' +import { send } from '../lib' +import { SendOptions } from '../lib/options' + +export function createServer (options: SendOptions, fn?: (request: IncomingMessage, response : ServerResponse) => void) { + return http.createServer(async function (request, response) { + try { + fn && fn(request, response) + const { statusCode, headers, stream } = await send(request, request.url as string, options) + response.writeHead(statusCode, headers) + stream.pipe(response) + } catch (err) { + response.statusCode = 500 + response.end(String(err)) + } + }) +} + +export function shouldNotHaveHeader (header: string, t: TestContext) { + return function (response: any) { + t.assert.ok(!(header.toLowerCase() in response.headers), 'should not have header ' + header) + } +} + +export function shouldNotHaveBody (t: TestContext) { + return function (response: any) { + t.assert.ok(response.text === '' || response.text === undefined) + } +} + +export function withResolvers () { + const promise: { promise: Promise