diff options
Diffstat (limited to '')
39 files changed, 5296 insertions, 0 deletions
diff --git a/test/fetch/407-statuscode-window-null.js b/test/fetch/407-statuscode-window-null.js new file mode 100644 index 0000000..e22554f --- /dev/null +++ b/test/fetch/407-statuscode-window-null.js @@ -0,0 +1,20 @@ +'use strict' + +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') +const { test } = require('tap') + +test('Receiving a 407 status code w/ a window option present should reject', async (t) => { + const server = createServer((req, res) => { + res.statusCode = 407 + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + // if init.window exists, the spec tells us to set request.window to 'no-window', + // which later causes the request to be rejected if the status code is 407 + await t.rejects(fetch(`http://localhost:${server.address().port}`, { window: null })) +}) diff --git a/test/fetch/abort.js b/test/fetch/abort.js new file mode 100644 index 0000000..e1ca1eb --- /dev/null +++ b/test/fetch/abort.js @@ -0,0 +1,82 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') +const { DOMException } = require('../../lib/fetch/constants') +const { nodeMajor } = require('../../lib/core/util') + +const { AbortController: NPMAbortController } = require('abort-controller') + +test('Allow the usage of custom implementation of AbortController', async (t) => { + const body = { + fixes: 1605 + } + + const server = createServer((req, res) => { + res.statusCode = 200 + res.end(JSON.stringify(body)) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0) + await once(server, 'listening') + + const controller = new NPMAbortController() + const signal = controller.signal + controller.abort() + + try { + await fetch(`http://localhost:${server.address().port}`, { + signal + }) + } catch (e) { + t.equal(e.code, DOMException.ABORT_ERR) + } +}) + +test('allows aborting with custom errors', { skip: nodeMajor === 16 }, async (t) => { + const server = createServer().listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + t.test('Using AbortSignal.timeout with cause', async (t) => { + t.plan(2) + + try { + await fetch(`http://localhost:${server.address().port}`, { + signal: AbortSignal.timeout(50) + }) + t.fail('should throw') + } catch (err) { + if (err.name === 'TypeError') { + const cause = err.cause + t.equal(cause.name, 'HeadersTimeoutError') + t.equal(cause.code, 'UND_ERR_HEADERS_TIMEOUT') + } else if (err.name === 'TimeoutError') { + t.equal(err.code, DOMException.TIMEOUT_ERR) + t.equal(err.cause, undefined) + } else { + t.error(err) + } + } + }) + + t.test('Error defaults to an AbortError DOMException', async (t) => { + const ac = new AbortController() + ac.abort() // no reason + + await t.rejects( + fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal + }), + { + name: 'AbortError', + code: DOMException.ABORT_ERR + } + ) + }) +}) diff --git a/test/fetch/abort2.js b/test/fetch/abort2.js new file mode 100644 index 0000000..5f3853b --- /dev/null +++ b/test/fetch/abort2.js @@ -0,0 +1,60 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') +const { DOMException } = require('../../lib/fetch/constants') + +/* global AbortController */ + +test('parallel fetch with the same AbortController works as expected', async (t) => { + const body = { + fixes: 1389, + bug: 'Ensure request is not aborted before enqueueing bytes into stream.' + } + + const server = createServer((req, res) => { + res.statusCode = 200 + res.end(JSON.stringify(body)) + }) + + t.teardown(server.close.bind(server)) + + const abortController = new AbortController() + + async function makeRequest () { + const result = await fetch(`http://localhost:${server.address().port}`, { + signal: abortController.signal + }).then(response => response.json()) + + abortController.abort() + return result + } + + server.listen(0) + await once(server, 'listening') + + const requests = Array.from({ length: 10 }, makeRequest) + const result = await Promise.allSettled(requests) + + // since the requests are running parallel, any of them could resolve first. + // therefore we cannot rely on the order of the requests sent. + const { resolved, rejected } = result.reduce((a, b) => { + if (b.status === 'rejected') { + a.rejected.push(b) + } else { + a.resolved.push(b) + } + + return a + }, { resolved: [], rejected: [] }) + + t.equal(rejected.length, 9) // out of 10 requests, only 1 should succeed + t.equal(resolved.length, 1) + + t.ok(rejected.every(rej => rej.reason?.code === DOMException.ABORT_ERR)) + t.same(resolved[0].value, body) + + t.end() +}) diff --git a/test/fetch/about-uri.js b/test/fetch/about-uri.js new file mode 100644 index 0000000..ac9cbf2 --- /dev/null +++ b/test/fetch/about-uri.js @@ -0,0 +1,21 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') + +test('fetching about: uris', async (t) => { + t.test('about:blank', async (t) => { + await t.rejects(fetch('about:blank')) + }) + + t.test('All other about: urls should return an error', async (t) => { + try { + await fetch('about:config') + t.fail('fetching about:config should fail') + } catch (e) { + t.ok(e, 'this error was expected') + } finally { + t.end() + } + }) +}) diff --git a/test/fetch/blob-uri.js b/test/fetch/blob-uri.js new file mode 100644 index 0000000..f9db96c --- /dev/null +++ b/test/fetch/blob-uri.js @@ -0,0 +1,100 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { Blob } = require('buffer') + +test('fetching blob: uris', async (t) => { + const blobContents = 'hello world' + /** @type {import('buffer').Blob} */ + let blob + /** @type {string} */ + let objectURL + + t.beforeEach(() => { + blob = new Blob([blobContents]) + objectURL = URL.createObjectURL(blob) + }) + + t.test('a normal fetch request works', async (t) => { + const res = await fetch(objectURL) + + t.equal(blobContents, await res.text()) + t.equal(blob.type, res.headers.get('Content-Type')) + t.equal(`${blob.size}`, res.headers.get('Content-Length')) + t.end() + }) + + t.test('non-GET method to blob: fails', async (t) => { + try { + await fetch(objectURL, { + method: 'POST' + }) + t.fail('expected POST to blob: uri to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L36-L41 + t.test('fetching revoked URL should fail', async (t) => { + URL.revokeObjectURL(objectURL) + + try { + await fetch(objectURL) + t.fail('expected revoked blob: url to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L28-L34 + t.test('works with a fragment', async (t) => { + const res = await fetch(objectURL + '#fragment') + + t.equal(blobContents, await res.text()) + t.end() + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L52-L56 + t.test('Appending a query string to blob: url should cause fetch to fail', async (t) => { + try { + await fetch(objectURL + '?querystring') + t.fail('expected ?querystring blob: url to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L58-L62 + t.test('Appending a path should cause fetch to fail', async (t) => { + try { + await fetch(objectURL + '/path') + t.fail('expected /path blob: url to fail') + } catch (e) { + t.ok(e, 'Got the expected error') + } finally { + t.end() + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L64-L70 + t.test('these http methods should fail', async (t) => { + for (const method of ['HEAD', 'POST', 'DELETE', 'OPTIONS', 'PUT', 'CUSTOM']) { + try { + await fetch(objectURL, { method }) + t.fail(`${method} fetch should have failed`) + } catch (e) { + t.ok(e, `${method} blob url - test succeeded`) + } + } + + t.end() + }) +}) diff --git a/test/fetch/bundle.js b/test/fetch/bundle.js new file mode 100644 index 0000000..aa1257a --- /dev/null +++ b/test/fetch/bundle.js @@ -0,0 +1,41 @@ +'use strict'
+
+const { test, skip } = require('tap')
+const { nodeMajor } = require('../../lib/core/util')
+
+if (nodeMajor === 16) {
+ skip('esbuild uses static blocks with --keep-names which node 16.8 does not have')
+ process.exit()
+}
+
+const { Response, Request, FormData, Headers } = require('../../undici-fetch')
+
+test('bundle sets constructor.name and .name properly', (t) => {
+ t.equal(new Response().constructor.name, 'Response')
+ t.equal(Response.name, 'Response')
+
+ t.equal(new Request('http://a').constructor.name, 'Request')
+ t.equal(Request.name, 'Request')
+
+ t.equal(new Headers().constructor.name, 'Headers')
+ t.equal(Headers.name, 'Headers')
+
+ t.equal(new FormData().constructor.name, 'FormData')
+ t.equal(FormData.name, 'FormData')
+
+ t.end()
+})
+
+test('regression test for https://github.com/nodejs/node/issues/50263', (t) => {
+ const request = new Request('https://a', {
+ headers: {
+ test: 'abc'
+ },
+ method: 'POST'
+ })
+
+ const request1 = new Request(request, { body: 'does not matter' })
+
+ t.equal(request1.headers.get('test'), 'abc')
+ t.end()
+})
diff --git a/test/fetch/client-error-stack-trace.js b/test/fetch/client-error-stack-trace.js new file mode 100644 index 0000000..7d94aa8 --- /dev/null +++ b/test/fetch/client-error-stack-trace.js @@ -0,0 +1,21 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { fetch: fetchIndex } = require('../../index-fetch') + +test('FETCH: request errors and prints trimmed stack trace', async (t) => { + try { + await fetch('http://a.com') + } catch (error) { + t.match(error.stack, `at Test.<anonymous> (${__filename}`) + } +}) + +test('FETCH-index: request errors and prints trimmed stack trace', async (t) => { + try { + await fetchIndex('http://a.com') + } catch (error) { + t.match(error.stack, `at Test.<anonymous> (${__filename}`) + } +}) diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js new file mode 100644 index 0000000..9009d54 --- /dev/null +++ b/test/fetch/client-fetch.js @@ -0,0 +1,688 @@ +/* globals AbortController */ + +'use strict' + +const { test, teardown } = require('tap') +const { createServer } = require('http') +const { ReadableStream } = require('stream/web') +const { Blob } = require('buffer') +const { fetch, Response, Request, FormData, File } = require('../..') +const { Client, setGlobalDispatcher, Agent } = require('../..') +const { nodeMajor, nodeMinor } = require('../../lib/core/util') +const nodeFetch = require('../../index-fetch') +const { once } = require('events') +const { gzipSync } = require('zlib') +const { promisify } = require('util') +const { randomFillSync, createHash } = require('crypto') + +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 +})) + +test('function signature', (t) => { + t.plan(2) + + t.equal(fetch.name, 'fetch') + t.equal(fetch.length, 1) +}) + +test('args validation', async (t) => { + t.plan(2) + + await t.rejects(fetch(), TypeError) + await t.rejects(fetch('ftp://unsupported'), TypeError) +}) + +test('request json', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame(obj, await body.json()) + }) +}) + +test('request text', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('request arrayBuffer', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame(Buffer.from(JSON.stringify(obj)), Buffer.from(await body.arrayBuffer())) + }) +}) + +test('should set type of blob object to the value of the `Content-Type` header from response', (t) => { + t.plan(1) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`) + t.equal('application/json', (await response.blob()).type) + }) +}) + +test('pre aborted with readable request body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const ac = new AbortController() + ac.abort() + await fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal, + method: 'POST', + body: new ReadableStream({ + async cancel (reason) { + t.equal(reason.name, 'AbortError') + } + }), + duplex: 'half' + }).catch(err => { + t.equal(err.name, 'AbortError') + }) + }) +}) + +test('pre aborted with closed readable request body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const ac = new AbortController() + ac.abort() + const body = new ReadableStream({ + async start (c) { + t.pass() + c.close() + }, + async cancel (reason) { + t.fail() + } + }) + queueMicrotask(() => { + fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal, + method: 'POST', + body, + duplex: 'half' + }).catch(err => { + t.equal(err.name, 'AbortError') + }) + }) + }) +}) + +test('unsupported formData 1', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'asdasdsad') + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .catch(err => { + t.equal(err.name, 'TypeError') + }) + }) +}) + +test('multipart formdata not base64', async (t) => { + t.plan(2) + // Construct example form data, with text and blob fields + const formData = new FormData() + formData.append('field1', 'value1') + const blob = new Blob(['example\ntext file'], { type: 'text/plain' }) + formData.append('field2', blob, 'file.txt') + + const tempRes = new Response(formData) + const boundary = tempRes.headers.get('content-type').split('boundary=')[1] + const formRaw = await tempRes.text() + + const server = createServer((req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=' + boundary) + res.write(formRaw) + res.end() + }) + t.teardown(server.close.bind(server)) + + const listen = promisify(server.listen.bind(server)) + await listen(0) + + const res = await fetch(`http://localhost:${server.address().port}`) + const form = await res.formData() + t.equal(form.get('field1'), 'value1') + + const text = await form.get('field2').text() + t.equal(text, 'example\ntext file') +}) + +// TODO(@KhafraDev): re-enable this test once the issue is fixed +// See https://github.com/nodejs/node/issues/47301 +test('multipart formdata base64', { skip: nodeMajor >= 19 && nodeMinor >= 8 }, (t) => { + t.plan(1) + + // Example form data with base64 encoding + const data = randomFillSync(Buffer.alloc(256)) + const formRaw = `------formdata-undici-0.5786922755719377\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nContent-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\n\r\n${data.toString('base64')}\r\n------formdata-undici-0.5786922755719377--` + const server = createServer(async (req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=----formdata-undici-0.5786922755719377') + + for (let offset = 0; offset < formRaw.length;) { + res.write(formRaw.slice(offset, offset += 2)) + await new Promise(resolve => setTimeout(resolve)) + } + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(form => form.get('file').arrayBuffer()) + .then(buffer => createHash('sha256').update(Buffer.from(buffer)).digest('base64')) + .then(digest => { + t.equal(createHash('sha256').update(data).digest('base64'), digest) + }) + }) +}) + +test('multipart fromdata non-ascii filed names', async (t) => { + t.plan(1) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=----formdata-undici-0.6204674738279623' + }, + body: + '------formdata-undici-0.6204674738279623\r\n' + + 'Content-Disposition: form-data; name="fiŝo"\r\n' + + '\r\n' + + 'value1\r\n' + + '------formdata-undici-0.6204674738279623--' + }) + + const form = await request.formData() + t.equal(form.get('fiŝo'), 'value1') +}) + +test('busboy emit error', async (t) => { + t.plan(1) + const formData = new FormData() + formData.append('field1', 'value1') + + const tempRes = new Response(formData) + const formRaw = await tempRes.text() + + const server = createServer((req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=wrongboundary') + res.write(formRaw) + res.end() + }) + t.teardown(server.close.bind(server)) + + const listen = promisify(server.listen.bind(server)) + await listen(0) + + const res = await fetch(`http://localhost:${server.address().port}`) + await t.rejects(res.formData(), 'Unexpected end of multipart data') +}) + +// https://github.com/nodejs/undici/issues/2244 +test('parsing formData preserve full path on files', async (t) => { + t.plan(1) + const formData = new FormData() + formData.append('field1', new File(['foo'], 'a/b/c/foo.txt')) + + const tempRes = new Response(formData) + const form = await tempRes.formData() + + t.equal(form.get('field1').name, 'a/b/c/foo.txt') +}) + +test('urlencoded formData', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('field1=value1&field2=value2') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(formData => { + t.equal(formData.get('field1'), 'value1') + t.equal(formData.get('field2'), 'value2') + }) + }) +}) + +test('text with BOM', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('\uFEFFtest=\uFEFF') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.text()) + .then(text => { + t.equal(text, 'test=\uFEFF') + }) + }) +}) + +test('formData with BOM', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('\uFEFFtest=\uFEFF') + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(formData => { + t.equal(formData.get('\uFEFFtest'), '\uFEFF') + }) + }) +}) + +test('locked blob body', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`) + const reader = res.body.getReader() + res.blob().catch(err => { + t.equal(err.message, 'Body is unusable') + reader.cancel() + }) + }) +}) + +test('disturbed blob body', (t) => { + t.plan(2) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`) + res.blob().then(() => { + t.pass(2) + }) + res.blob().catch(err => { + t.equal(err.message, 'Body is unusable') + }) + }) +}) + +test('redirect with body', (t) => { + t.plan(3) + + let count = 0 + const server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) { + body += chunk + } + t.equal(body, 'asd') + if (count++ === 0) { + res.setHeader('location', 'asd') + res.statusCode = 302 + res.end() + } else { + res.end(String(count)) + } + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body: 'asd' + }) + t.equal(await res.text(), '2') + }) +}) + +test('redirect with stream', (t) => { + t.plan(3) + + const location = '/asd' + const body = 'hello!' + const server = createServer(async (req, res) => { + res.writeHead(302, { location }) + let count = 0 + const l = setInterval(() => { + res.write(body[count++]) + if (count === body.length) { + res.end() + clearInterval(l) + } + }, 50) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + redirect: 'manual' + }) + t.equal(res.status, 302) + t.equal(res.headers.get('location'), location) + t.equal(await res.text(), body) + }) +}) + +test('fail to extract locked body', (t) => { + t.plan(1) + + const stream = new ReadableStream({}) + const reader = stream.getReader() + try { + // eslint-disable-next-line + new Response(stream) + } catch (err) { + t.equal(err.name, 'TypeError') + } + reader.cancel() +}) + +test('fail to extract locked body', (t) => { + t.plan(1) + + const stream = new ReadableStream({}) + const reader = stream.getReader() + try { + // eslint-disable-next-line + new Request('http://asd', { + method: 'PUT', + body: stream, + keepalive: true + }) + } catch (err) { + t.equal(err.message, 'keepalive') + } + reader.cancel() +}) + +test('post FormData with Blob', (t) => { + t.plan(1) + + const body = new FormData() + body.append('field1', new Blob(['asd1'])) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body + }) + t.ok(/asd1/.test(await res.text())) + }) +}) + +test('post FormData with File', (t) => { + t.plan(2) + + const body = new FormData() + body.append('field1', new File(['asd1'], 'filename123')) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body + }) + const result = await res.text() + t.ok(/asd1/.test(result)) + t.ok(/filename123/.test(result)) + }) +}) + +test('invalid url', async (t) => { + t.plan(1) + + try { + await fetch('http://invalid') + } catch (e) { + t.match(e.cause.message, 'invalid') + } +}) + +test('custom agent', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const dispatcher = new Client('http://localhost:' + server.address().port, { + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 + }) + const oldDispatch = dispatcher.dispatch + dispatcher.dispatch = function (options, handler) { + t.pass('custom dispatcher') + return oldDispatch.call(this, options, handler) + } + t.teardown(server.close.bind(server)) + const body = await fetch(`http://localhost:${server.address().port}`, { + dispatcher + }) + t.strictSame(obj, await body.json()) + }) +}) + +test('custom agent node fetch', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const dispatcher = new Client('http://localhost:' + server.address().port, { + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 + }) + const oldDispatch = dispatcher.dispatch + dispatcher.dispatch = function (options, handler) { + t.pass('custom dispatcher') + return oldDispatch.call(this, options, handler) + } + t.teardown(server.close.bind(server)) + const body = await nodeFetch.fetch(`http://localhost:${server.address().port}`, { + dispatcher + }) + t.strictSame(obj, await body.json()) + }) +}) + +test('error on redirect', async (t) => { + const server = createServer((req, res) => { + res.statusCode = 302 + res.end() + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const errorCause = await fetch(`http://localhost:${server.address().port}`, { + redirect: 'error' + }).catch((e) => e.cause) + + t.equal(errorCause.message, 'unexpected redirect') + }) +}) + +// https://github.com/nodejs/undici/issues/1527 +test('fetching with Request object - issue #1527', async (t) => { + const server = createServer((req, res) => { + t.pass() + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const body = JSON.stringify({ foo: 'bar' }) + const request = new Request(`http://localhost:${server.address().port}`, { + method: 'POST', + body + }) + + await t.resolves(fetch(request)) + t.end() +}) + +test('do not decode redirect body', (t) => { + t.plan(3) + + const obj = { asd: true } + const server = createServer((req, res) => { + if (req.url === '/resource') { + t.pass('we redirect') + res.statusCode = 301 + res.setHeader('location', '/resource/') + // Some dumb http servers set the content-encoding gzip + // even if there is no response + res.setHeader('content-encoding', 'gzip') + res.end() + return + } + t.pass('actual response') + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(JSON.stringify(obj))) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/resource`) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('decode non-redirect body with location header', (t) => { + t.plan(2) + + const obj = { asd: true } + const server = createServer((req, res) => { + t.pass('response') + res.statusCode = 201 + res.setHeader('location', '/resource/') + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(JSON.stringify(obj))) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/resource`) + t.strictSame(JSON.stringify(obj), await body.text()) + }) +}) + +test('Receiving non-Latin1 headers', async (t) => { + const ContentDisposition = [ + 'inline; filename=rock&roll.png', + 'inline; filename="rock\'n\'roll.png"', + 'inline; filename="image â\x80\x94 copy (1).png"; filename*=UTF-8\'\'image%20%E2%80%94%20copy%20(1).png', + 'inline; filename="_å\x9C\x96ç\x89\x87_ð\x9F\x96¼_image_.png"; filename*=UTF-8\'\'_%E5%9C%96%E7%89%87_%F0%9F%96%BC_image_.png', + 'inline; filename="100 % loading&perf.png"; filename*=UTF-8\'\'100%20%25%20loading%26perf.png' + ] + + const server = createServer((req, res) => { + for (let i = 0; i < ContentDisposition.length; i++) { + res.setHeader(`Content-Disposition-${i + 1}`, ContentDisposition[i]) + } + + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const url = `http://localhost:${server.address().port}` + const response = await fetch(url, { method: 'HEAD' }) + const cdHeaders = [...response.headers] + .filter(([k]) => k.startsWith('content-disposition')) + .map(([, v]) => v) + const lengths = cdHeaders.map(h => h.length) + + t.same(cdHeaders, ContentDisposition) + t.same(lengths, [30, 34, 94, 104, 90]) + t.end() +}) + +teardown(() => process.exit()) diff --git a/test/fetch/client-node-max-header-size.js b/test/fetch/client-node-max-header-size.js new file mode 100644 index 0000000..737bae8 --- /dev/null +++ b/test/fetch/client-node-max-header-size.js @@ -0,0 +1,29 @@ +'use strict' + +const { execSync } = require('node:child_process') +const { test, skip } = require('tap') +const { nodeMajor } = require('../../lib/core/util') + +if (nodeMajor === 16) { + skip('esbuild uses static blocks with --keep-names which node 16.8 does not have') + process.exit() +} + +const command = 'node -e "require(\'./undici-fetch.js\').fetch(\'https://httpbin.org/get\')"' + +test("respect Node.js' --max-http-header-size", async (t) => { + t.throws( + // TODO: Drop the `--unhandled-rejections=throw` once we drop Node.js 14 + () => execSync(`${command} --max-http-header-size=1 --unhandled-rejections=throw`), + /UND_ERR_HEADERS_OVERFLOW/, + 'max-http-header-size=1 should throw' + ) + + t.doesNotThrow( + () => execSync(command), + /UND_ERR_HEADERS_OVERFLOW/, + 'default max-http-header-size should not throw' + ) + + t.end() +}) diff --git a/test/fetch/content-length.js b/test/fetch/content-length.js new file mode 100644 index 0000000..9264091 --- /dev/null +++ b/test/fetch/content-length.js @@ -0,0 +1,29 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { Blob } = require('buffer') +const { fetch, FormData } = require('../..') + +// https://github.com/nodejs/undici/issues/1783 +test('Content-Length is set when using a FormData body with fetch', async (t) => { + const server = createServer((req, res) => { + // TODO: check the length's value once the boundary has a fixed length + t.ok('content-length' in req.headers) // request has content-length header + t.ok(!Number.isNaN(Number(req.headers['content-length']))) + res.end() + }).listen(0) + + await once(server, 'listening') + t.teardown(server.close.bind(server)) + + const fd = new FormData() + fd.set('file', new Blob(['hello world 👋'], { type: 'text/plain' }), 'readme.md') + fd.set('string', 'some string value') + + await fetch(`http://localhost:${server.address().port}`, { + method: 'POST', + body: fd + }) +}) diff --git a/test/fetch/cookies.js b/test/fetch/cookies.js new file mode 100644 index 0000000..18b001d --- /dev/null +++ b/test/fetch/cookies.js @@ -0,0 +1,69 @@ +'use strict' + +const { once } = require('events') +const { createServer } = require('http') +const { test } = require('tap') +const { fetch, Headers } = require('../..') + +test('Can receive set-cookie headers from a server using fetch - issue #1262', async (t) => { + const server = createServer((req, res) => { + res.setHeader('set-cookie', 'name=value; Domain=example.com') + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + t.equal(response.headers.get('set-cookie'), 'name=value; Domain=example.com') + + const response2 = await fetch(`http://localhost:${server.address().port}`, { + credentials: 'include' + }) + + t.equal(response2.headers.get('set-cookie'), 'name=value; Domain=example.com') + + t.end() +}) + +test('Can send cookies to a server with fetch - issue #1463', async (t) => { + const server = createServer((req, res) => { + t.equal(req.headers.cookie, 'value') + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const headersInit = [ + new Headers([['cookie', 'value']]), + { cookie: 'value' }, + [['cookie', 'value']] + ] + + for (const headers of headersInit) { + await fetch(`http://localhost:${server.address().port}`, { headers }) + } + + t.end() +}) + +test('Cookie header is delimited with a semicolon rather than a comma - issue #1905', async (t) => { + t.plan(1) + + const server = createServer((req, res) => { + t.equal(req.headers.cookie, 'FOO=lorem-ipsum-dolor-sit-amet; BAR=the-quick-brown-fox') + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await fetch(`http://localhost:${server.address().port}`, { + headers: [ + ['cookie', 'FOO=lorem-ipsum-dolor-sit-amet'], + ['cookie', 'BAR=the-quick-brown-fox'] + ] + }) +}) diff --git a/test/fetch/data-uri.js b/test/fetch/data-uri.js new file mode 100644 index 0000000..6191bfe --- /dev/null +++ b/test/fetch/data-uri.js @@ -0,0 +1,214 @@ +'use strict' + +const { test } = require('tap') +const { + URLSerializer, + collectASequenceOfCodePoints, + stringPercentDecode, + parseMIMEType, + collectAnHTTPQuotedString +} = require('../../lib/fetch/dataURL') +const { fetch } = require('../..') + +test('https://url.spec.whatwg.org/#concept-url-serializer', (t) => { + t.test('url scheme gets appended', (t) => { + const url = new URL('https://www.google.com/') + const serialized = URLSerializer(url) + + t.ok(serialized.startsWith(url.protocol)) + t.end() + }) + + t.test('non-null url host with authentication', (t) => { + const url = new URL('https://username:password@google.com') + const serialized = URLSerializer(url) + + t.ok(serialized.includes(`//${url.username}:${url.password}`)) + t.ok(serialized.endsWith('@google.com/')) + t.end() + }) + + t.test('null url host', (t) => { + for (const url of ['web+demo:/.//not-a-host/', 'web+demo:/path/..//not-a-host/']) { + t.equal( + URLSerializer(new URL(url)), + 'web+demo:/.//not-a-host/' + ) + } + + t.end() + }) + + t.test('url with query works', (t) => { + t.equal( + URLSerializer(new URL('https://www.google.com/?fetch=undici')), + 'https://www.google.com/?fetch=undici' + ) + + t.end() + }) + + t.test('exclude fragment', (t) => { + t.equal( + URLSerializer(new URL('https://www.google.com/#frag')), + 'https://www.google.com/#frag' + ) + + t.equal( + URLSerializer(new URL('https://www.google.com/#frag'), true), + 'https://www.google.com/' + ) + + t.end() + }) + + t.end() +}) + +test('https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points', (t) => { + const input = 'text/plain;base64,' + const position = { position: 0 } + const result = collectASequenceOfCodePoints( + (char) => char !== ';', + input, + position + ) + + t.strictSame(result, 'text/plain') + t.strictSame(position.position, input.indexOf(';')) + t.end() +}) + +test('https://url.spec.whatwg.org/#string-percent-decode', (t) => { + t.test('encodes %{2} in range properly', (t) => { + const input = '%FF' + const percentDecoded = stringPercentDecode(input) + + t.same(percentDecoded, new Uint8Array([255])) + t.end() + }) + + t.test('encodes %{2} not in range properly', (t) => { + const input = 'Hello %XD World' + const percentDecoded = stringPercentDecode(input) + const expected = [...input].map(c => c.charCodeAt(0)) + + t.same(percentDecoded, expected) + t.end() + }) + + t.test('normal string works', (t) => { + const input = 'Hello world' + const percentDecoded = stringPercentDecode(input) + const expected = [...input].map(c => c.charCodeAt(0)) + + t.same(percentDecoded, Uint8Array.from(expected)) + t.end() + }) + + t.end() +}) + +test('https://mimesniff.spec.whatwg.org/#parse-a-mime-type', (t) => { + t.same(parseMIMEType('text/plain'), { + type: 'text', + subtype: 'plain', + parameters: new Map(), + essence: 'text/plain' + }) + + t.same(parseMIMEType('text/html;charset="shift_jis"iso-2022-jp'), { + type: 'text', + subtype: 'html', + parameters: new Map([['charset', 'shift_jis']]), + essence: 'text/html' + }) + + t.same(parseMIMEType('application/javascript'), { + type: 'application', + subtype: 'javascript', + parameters: new Map(), + essence: 'application/javascript' + }) + + t.end() +}) + +test('https://fetch.spec.whatwg.org/#collect-an-http-quoted-string', (t) => { + // https://fetch.spec.whatwg.org/#example-http-quoted-string + t.test('first', (t) => { + const position = { position: 0 } + + t.strictSame(collectAnHTTPQuotedString('"\\', { + position: 0 + }), '"\\') + t.strictSame(collectAnHTTPQuotedString('"\\', position, true), '\\') + t.strictSame(position.position, 2) + t.end() + }) + + t.test('second', (t) => { + const position = { position: 0 } + const input = '"Hello" World' + + t.strictSame(collectAnHTTPQuotedString(input, { + position: 0 + }), '"Hello"') + t.strictSame(collectAnHTTPQuotedString(input, position, true), 'Hello') + t.strictSame(position.position, 7) + t.end() + }) + + t.end() +}) + +// https://github.com/nodejs/undici/issues/1574 +test('too long base64 url', async (t) => { + const inputStr = 'a'.repeat(1 << 20) + const base64 = Buffer.from(inputStr).toString('base64') + const dataURIPrefix = 'data:application/octet-stream;base64,' + const dataURL = dataURIPrefix + base64 + try { + const res = await fetch(dataURL) + const buf = await res.arrayBuffer() + const outputStr = Buffer.from(buf).toString('ascii') + t.same(outputStr, inputStr) + } catch (e) { + t.fail(`failed to fetch ${dataURL}`) + } +}) + +test('https://domain.com/#', (t) => { + t.plan(1) + const domain = 'https://domain.com/#a' + const serialized = URLSerializer(new URL(domain)) + t.equal(serialized, domain) +}) + +test('https://domain.com/?', (t) => { + t.plan(1) + const domain = 'https://domain.com/?a=b' + const serialized = URLSerializer(new URL(domain)) + t.equal(serialized, domain) +}) + +// https://github.com/nodejs/undici/issues/2474 +test('hash url', (t) => { + t.plan(1) + const domain = 'https://domain.com/#a#b' + const url = new URL(domain) + const serialized = URLSerializer(url, true) + t.equal(serialized, url.href.substring(0, url.href.length - url.hash.length)) +}) + +// https://github.com/nodejs/undici/issues/2474 +test('data url that includes the hash', async (t) => { + t.plan(1) + const dataURL = 'data:,node#js#' + try { + const res = await fetch(dataURL) + t.equal(await res.text(), 'node') + } catch (error) { + t.fail(`failed to fetch ${dataURL}`) + } +}) diff --git a/test/fetch/encoding.js b/test/fetch/encoding.js new file mode 100644 index 0000000..75d8fc3 --- /dev/null +++ b/test/fetch/encoding.js @@ -0,0 +1,58 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { fetch } = require('../..') +const { createBrotliCompress, createGzip, createDeflate } = require('zlib') + +test('content-encoding header is case-iNsENsITIve', async (t) => { + const contentCodings = 'GZiP, bR' + const text = 'Hello, World!' + + const server = createServer((req, res) => { + const gzip = createGzip() + const brotli = createBrotliCompress() + + res.setHeader('Content-Encoding', contentCodings) + res.setHeader('Content-Type', 'text/plain') + + brotli.pipe(gzip).pipe(res) + + brotli.write(text) + brotli.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + t.equal(await response.text(), text) + t.equal(response.headers.get('content-encoding'), contentCodings) +}) + +test('response decompression according to content-encoding should be handled in a correct order', async (t) => { + const contentCodings = 'deflate, gzip' + const text = 'Hello, World!' + + const server = createServer((req, res) => { + const gzip = createGzip() + const deflate = createDeflate() + + res.setHeader('Content-Encoding', contentCodings) + res.setHeader('Content-Type', 'text/plain') + + gzip.pipe(deflate).pipe(res) + + gzip.write(text) + gzip.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + t.equal(await response.text(), text) +}) diff --git a/test/fetch/fetch-leak.js b/test/fetch/fetch-leak.js new file mode 100644 index 0000000..b8e6b16 --- /dev/null +++ b/test/fetch/fetch-leak.js @@ -0,0 +1,44 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') + +test('do not leak', (t) => { + t.plan(1) + + const server = createServer((req, res) => { + res.end() + }) + t.teardown(server.close.bind(server)) + + let url + let done = false + server.listen(0, function attack () { + if (done) { + return + } + url ??= new URL(`http://127.0.0.1:${server.address().port}`) + const controller = new AbortController() + fetch(url, { signal: controller.signal }) + .then(res => res.arrayBuffer()) + .catch(() => {}) + .then(attack) + }) + + let prev = Infinity + let count = 0 + const interval = setInterval(() => { + done = true + global.gc() + const next = process.memoryUsage().heapUsed + if (next <= prev) { + t.pass() + } else if (count++ > 20) { + t.fail() + } else { + prev = next + } + }, 1e3) + t.teardown(() => clearInterval(interval)) +}) diff --git a/test/fetch/fetch-timeouts.js b/test/fetch/fetch-timeouts.js new file mode 100644 index 0000000..b659aaa --- /dev/null +++ b/test/fetch/fetch-timeouts.js @@ -0,0 +1,56 @@ +'use strict' + +const { test } = require('tap') + +const { fetch, Agent } = require('../..') +const timers = require('../../lib/timers') +const { createServer } = require('http') +const FakeTimers = require('@sinonjs/fake-timers') + +test('Fetch very long request, timeout overridden so no error', (t) => { + const minutes = 6 + const msToDelay = 1000 * 60 * minutes + + t.setTimeout(undefined) + t.plan(1) + + const clock = FakeTimers.install() + t.teardown(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.teardown(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, msToDelay) + clock.tick(msToDelay + 1) + }) + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`, { + path: '/', + method: 'GET', + dispatcher: new Agent({ + headersTimeout: 0, + connectTimeout: 0, + bodyTimeout: 0 + }) + }) + .then((response) => response.text()) + .then((response) => { + t.equal('hello', response) + t.end() + }) + .catch((err) => { + // This should not happen, a timeout error should not occur + t.error(err) + }) + + clock.tick(msToDelay - 1) + }) +}) diff --git a/test/fetch/file.js b/test/fetch/file.js new file mode 100644 index 0000000..5901541 --- /dev/null +++ b/test/fetch/file.js @@ -0,0 +1,190 @@ +'use strict' + +const { Blob } = require('buffer') +const { test } = require('tap') +const { File, FileLike } = require('../../lib/fetch/file') + +test('args validation', (t) => { + t.plan(14) + + t.throws(() => { + File.prototype.name.toString() + }, TypeError) + t.throws(() => { + File.prototype.lastModified.toString() + }, TypeError) + t.doesNotThrow(() => { + File.prototype[Symbol.toStringTag].charAt(0) + }, TypeError) + + t.throws(() => { + FileLike.prototype.stream.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.arrayBuffer.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.slice.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.text.call(null) + }, TypeError) + t.throws(() => { + FileLike.prototype.size.toString() + }, TypeError) + t.throws(() => { + FileLike.prototype.type.toString() + }, TypeError) + t.throws(() => { + FileLike.prototype.name.toString() + }, TypeError) + t.throws(() => { + FileLike.prototype.lastModified.toString() + }, TypeError) + t.doesNotThrow(() => { + FileLike.prototype[Symbol.toStringTag].charAt(0) + }, TypeError) + + t.equal(File.prototype[Symbol.toStringTag], 'File') + t.equal(FileLike.prototype[Symbol.toStringTag], 'File') +}) + +test('return value of File.lastModified', (t) => { + t.plan(2) + + const f = new File(['asd1'], 'filename123') + const lastModified = f.lastModified + t.ok(typeof lastModified === typeof Date.now()) + t.ok(lastModified >= 0 && lastModified <= Date.now()) +}) + +test('Symbol.toStringTag', (t) => { + t.plan(2) + t.equal(new File([], '')[Symbol.toStringTag], 'File') + t.equal(new FileLike()[Symbol.toStringTag], 'File') +}) + +test('arguments', (t) => { + t.throws(() => { + new File() // eslint-disable-line no-new + }, TypeError) + + t.throws(() => { + new File([]) // eslint-disable-line no-new + }, TypeError) + + t.end() +}) + +test('lastModified', (t) => { + const file = new File([], '') + const lastModified = Date.now() - 69_000 + + t.notOk(file === 0) + + const file1 = new File([], '', { lastModified }) + t.equal(file1.lastModified, lastModified) + + t.equal(new File([], '', { lastModified: 0 }).lastModified, 0) + + t.equal( + new File([], '', { + lastModified: true + }).lastModified, + 1 + ) + + t.end() +}) + +test('File.prototype.text', async (t) => { + t.test('With Blob', async (t) => { + const blob1 = new Blob(['hello']) + const blob2 = new Blob([' ']) + const blob3 = new Blob(['world']) + + const file = new File([blob1, blob2, blob3], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + /* eslint-disable camelcase */ + t.test('With TypedArray', async (t) => { + const uint8_1 = new Uint8Array(Buffer.from('hello')) + const uint8_2 = new Uint8Array(Buffer.from(' ')) + const uint8_3 = new Uint8Array(Buffer.from('world')) + + const file = new File([uint8_1, uint8_2, uint8_3], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + t.test('With TypedArray range', async (t) => { + const uint8_1 = new Uint8Array(Buffer.from('hello world')) + const uint8_2 = new Uint8Array(uint8_1.buffer, 1, 4) + + const file = new File([uint8_2], 'hello_world.txt') + + t.equal(await file.text(), 'ello') + t.end() + }) + /* eslint-enable camelcase */ + + t.test('With ArrayBuffer', async (t) => { + const uint8 = new Uint8Array([65, 66, 67]) + const ab = uint8.buffer + + const file = new File([ab], 'file.txt') + + t.equal(await file.text(), 'ABC') + t.end() + }) + + t.test('With string', async (t) => { + const string = 'hello world' + const file = new File([string], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + t.test('With Buffer', async (t) => { + const buffer = Buffer.from('hello world') + + const file = new File([buffer], 'hello_world.txt') + + t.equal(await file.text(), 'hello world') + t.end() + }) + + t.test('Mixed', async (t) => { + const blob = new Blob(['Hello, ']) + const uint8 = new Uint8Array(Buffer.from('world! This')) + const string = ' is a test! Hope it passes!' + + const file = new File([blob, uint8, string], 'mixed-messages.txt') + + t.equal( + await file.text(), + 'Hello, world! This is a test! Hope it passes!' + ) + t.end() + }) + + t.end() +}) + +test('endings=native', async (t) => { + const file = new File(['Hello\nWorld'], 'text.txt', { endings: 'native' }) + const text = await file.text() + + if (process.platform === 'win32') { + t.equal(text, 'Hello\r\nWorld', 'on windows, LF is replace with CRLF') + } else { + t.equal(text, 'Hello\nWorld', `on ${process.platform} LF stays LF`) + } + + t.end() +}) diff --git a/test/fetch/formdata.js b/test/fetch/formdata.js new file mode 100644 index 0000000..fed95bf --- /dev/null +++ b/test/fetch/formdata.js @@ -0,0 +1,401 @@ +'use strict' + +const { test } = require('tap') +const { FormData, File, Response } = require('../../') +const { Blob: ThirdPartyBlob } = require('formdata-node') +const { Blob } = require('buffer') +const { isFormDataLike } = require('../../lib/core/util') +const ThirdPartyFormDataInvalid = require('form-data') + +test('arg validation', (t) => { + const form = new FormData() + + // constructor + t.throws(() => { + // eslint-disable-next-line + new FormData('asd') + }, TypeError) + + // append + t.throws(() => { + FormData.prototype.append.call(null) + }, TypeError) + t.throws(() => { + form.append() + }, TypeError) + t.throws(() => { + form.append('k', 'not usv', '') + }, TypeError) + + // delete + t.throws(() => { + FormData.prototype.delete.call(null) + }, TypeError) + t.throws(() => { + form.delete() + }, TypeError) + + // get + t.throws(() => { + FormData.prototype.get.call(null) + }, TypeError) + t.throws(() => { + form.get() + }, TypeError) + + // getAll + t.throws(() => { + FormData.prototype.getAll.call(null) + }, TypeError) + t.throws(() => { + form.getAll() + }, TypeError) + + // has + t.throws(() => { + FormData.prototype.has.call(null) + }, TypeError) + t.throws(() => { + form.has() + }, TypeError) + + // set + t.throws(() => { + FormData.prototype.set.call(null) + }, TypeError) + t.throws(() => { + form.set('k') + }, TypeError) + t.throws(() => { + form.set('k', 'not usv', '') + }, TypeError) + + // iterator + t.throws(() => { + Reflect.apply(FormData.prototype[Symbol.iterator], null) + }, TypeError) + + // toStringTag + t.doesNotThrow(() => { + FormData.prototype[Symbol.toStringTag].charAt(0) + }) + + t.end() +}) + +test('append file', (t) => { + const form = new FormData() + form.set('asd', new File([], 'asd1', { type: 'text/plain' }), 'asd2') + form.append('asd2', new File([], 'asd1'), 'asd2') + + t.equal(form.has('asd'), true) + t.equal(form.has('asd2'), true) + t.equal(form.get('asd').name, 'asd2') + t.equal(form.get('asd2').name, 'asd2') + t.equal(form.get('asd').type, 'text/plain') + form.delete('asd') + t.equal(form.get('asd'), null) + t.equal(form.has('asd2'), true) + t.equal(form.has('asd'), false) + + t.end() +}) + +test('append blob', async (t) => { + const form = new FormData() + form.set('asd', new Blob(['asd1'], { type: 'text/plain' })) + + t.equal(form.has('asd'), true) + t.equal(form.get('asd').type, 'text/plain') + t.equal(await form.get('asd').text(), 'asd1') + form.delete('asd') + t.equal(form.get('asd'), null) + + t.end() +}) + +test('append third-party blob', async (t) => { + const form = new FormData() + form.set('asd', new ThirdPartyBlob(['asd1'], { type: 'text/plain' })) + + t.equal(form.has('asd'), true) + t.equal(form.get('asd').type, 'text/plain') + t.equal(await form.get('asd').text(), 'asd1') + form.delete('asd') + t.equal(form.get('asd'), null) + + t.end() +}) + +test('append string', (t) => { + const form = new FormData() + form.set('k1', 'v1') + form.set('k2', 'v2') + t.same([...form], [['k1', 'v1'], ['k2', 'v2']]) + t.equal(form.has('k1'), true) + t.equal(form.get('k1'), 'v1') + form.append('k1', 'v1+') + t.same(form.getAll('k1'), ['v1', 'v1+']) + form.set('k2', 'v1++') + t.equal(form.get('k2'), 'v1++') + form.delete('asd') + t.equal(form.get('asd'), null) + t.end() +}) + +test('formData.entries', (t) => { + t.plan(2) + const form = new FormData() + + t.test('with 0 entries', (t) => { + t.plan(1) + + const entries = [...form.entries()] + t.same(entries, []) + }) + + t.test('with 1+ entries', (t) => { + t.plan(2) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const entries = [...form.entries()] + const entries2 = [...form.entries()] + t.same(entries, [['k1', 'v1'], ['k2', 'v2']]) + t.same(entries, entries2) + }) +}) + +test('formData.keys', (t) => { + t.plan(2) + const form = new FormData() + + t.test('with 0 keys', (t) => { + t.plan(1) + + const keys = [...form.entries()] + t.same(keys, []) + }) + + t.test('with 1+ keys', (t) => { + t.plan(2) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const keys = [...form.keys()] + const keys2 = [...form.keys()] + t.same(keys, ['k1', 'k2']) + t.same(keys, keys2) + }) +}) + +test('formData.values', (t) => { + t.plan(2) + const form = new FormData() + + t.test('with 0 values', (t) => { + t.plan(1) + + const values = [...form.values()] + t.same(values, []) + }) + + t.test('with 1+ values', (t) => { + t.plan(2) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const values = [...form.values()] + const values2 = [...form.values()] + t.same(values, ['v1', 'v2']) + t.same(values, values2) + }) +}) + +test('formData forEach', (t) => { + t.test('invalid arguments', (t) => { + t.throws(() => { + FormData.prototype.forEach.call({}) + }, TypeError('Illegal invocation')) + + t.throws(() => { + const fd = new FormData() + + fd.forEach({}) + }, TypeError) + + t.end() + }) + + t.test('with a callback', (t) => { + const fd = new FormData() + + fd.set('a', 'b') + fd.set('c', 'd') + + let i = 0 + fd.forEach((value, key, self) => { + if (i++ === 0) { + t.equal(value, 'b') + t.equal(key, 'a') + } else { + t.equal(value, 'd') + t.equal(key, 'c') + } + + t.equal(fd, self) + }) + + t.end() + }) + + t.test('with a thisArg', (t) => { + const fd = new FormData() + fd.set('b', 'a') + + fd.forEach(function (value, key, self) { + t.equal(this, globalThis) + t.equal(fd, self) + t.equal(key, 'b') + t.equal(value, 'a') + }) + + const thisArg = Symbol('thisArg') + fd.forEach(function () { + t.equal(this, thisArg) + }, thisArg) + + t.end() + }) + + t.end() +}) + +test('formData toStringTag', (t) => { + const form = new FormData() + t.equal(form[Symbol.toStringTag], 'FormData') + t.equal(FormData.prototype[Symbol.toStringTag], 'FormData') + t.end() +}) + +test('formData.constructor.name', (t) => { + const form = new FormData() + t.equal(form.constructor.name, 'FormData') + t.end() +}) + +test('formData should be an instance of FormData', (t) => { + t.plan(3) + + t.test('Invalid class FormData', (t) => { + class FormData { + constructor () { + this.data = [] + } + + append (key, value) { + this.data.push([key, value]) + } + + get (key) { + return this.data.find(([k]) => k === key) + } + } + + const form = new FormData() + t.equal(isFormDataLike(form), false) + t.end() + }) + + t.test('Invalid function FormData', (t) => { + function FormData () { + const data = [] + return { + append (key, value) { + data.push([key, value]) + }, + get (key) { + return data.find(([k]) => k === key) + } + } + } + + const form = new FormData() + t.equal(isFormDataLike(form), false) + t.end() + }) + + test('Invalid third-party FormData', (t) => { + const form = new ThirdPartyFormDataInvalid() + t.equal(isFormDataLike(form), false) + t.end() + }) + + t.test('Valid FormData', (t) => { + const form = new FormData() + t.equal(isFormDataLike(form), true) + t.end() + }) +}) + +test('FormData should be compatible with third-party libraries', (t) => { + t.plan(1) + + class FormData { + constructor () { + this.data = [] + } + + get [Symbol.toStringTag] () { + return 'FormData' + } + + append () {} + delete () {} + get () {} + getAll () {} + has () {} + set () {} + entries () {} + keys () {} + values () {} + forEach () {} + } + + const form = new FormData() + t.equal(isFormDataLike(form), true) +}) + +test('arguments', (t) => { + t.equal(FormData.constructor.length, 1) + t.equal(FormData.prototype.append.length, 2) + t.equal(FormData.prototype.delete.length, 1) + t.equal(FormData.prototype.get.length, 1) + t.equal(FormData.prototype.getAll.length, 1) + t.equal(FormData.prototype.has.length, 1) + t.equal(FormData.prototype.set.length, 2) + + t.end() +}) + +// https://github.com/nodejs/undici/pull/1814 +test('FormData returned from bodyMixin.formData is not a clone', async (t) => { + const fd = new FormData() + fd.set('foo', 'bar') + + const res = new Response(fd) + fd.set('foo', 'foo') + + const fd2 = await res.formData() + + t.equal(fd2.get('foo'), 'bar') + t.equal(fd.get('foo'), 'foo') + + fd2.set('foo', 'baz') + + t.equal(fd2.get('foo'), 'baz') + t.equal(fd.get('foo'), 'foo') +}) diff --git a/test/fetch/general.js b/test/fetch/general.js new file mode 100644 index 0000000..0469875 --- /dev/null +++ b/test/fetch/general.js @@ -0,0 +1,30 @@ +'use strict' + +const { test } = require('tap') +const { + File, + FormData, + Headers, + Request, + Response +} = require('../../index') + +test('Symbol.toStringTag descriptor', (t) => { + for (const cls of [ + File, + FormData, + Headers, + Request, + Response + ]) { + const desc = Object.getOwnPropertyDescriptor(cls.prototype, Symbol.toStringTag) + t.same(desc, { + value: cls.name, + writable: false, + enumerable: false, + configurable: true + }) + } + + t.end() +}) diff --git a/test/fetch/headers.js b/test/fetch/headers.js new file mode 100644 index 0000000..4846110 --- /dev/null +++ b/test/fetch/headers.js @@ -0,0 +1,743 @@ +'use strict' + +const tap = require('tap') +const { Headers, fill } = require('../../lib/fetch/headers') +const { kGuard } = require('../../lib/fetch/symbols') +const { once } = require('events') +const { fetch } = require('../..') +const { createServer } = require('http') + +tap.test('Headers initialization', t => { + t.plan(8) + + t.test('allows undefined', t => { + t.plan(1) + + t.doesNotThrow(() => new Headers()) + }) + + t.test('with array of header entries', t => { + t.plan(3) + + t.test('fails on invalid array-based init', t => { + t.plan(3) + t.throws( + () => new Headers([['undici', 'fetch'], ['fetch']]), + TypeError('Headers constructor: expected name/value pair to be length 2, found 1.') + ) + t.throws(() => new Headers(['undici', 'fetch', 'fetch']), TypeError) + t.throws( + () => new Headers([0, 1, 2]), + TypeError('Sequence: Value of type Number is not an Object.') + ) + }) + + t.test('allows even length init', t => { + t.plan(1) + const init = [['undici', 'fetch'], ['fetch', 'undici']] + t.doesNotThrow(() => new Headers(init)) + }) + + t.test('fails for event flattened init', t => { + t.plan(1) + const init = ['undici', 'fetch', 'fetch', 'undici'] + t.throws( + () => new Headers(init), + TypeError('Sequence: Value of type String is not an Object.') + ) + }) + }) + + t.test('with object of header entries', t => { + t.plan(1) + const init = { + undici: 'fetch', + fetch: 'undici' + } + t.doesNotThrow(() => new Headers(init)) + }) + + t.test('fails silently if a boxed primitive object is passed', t => { + t.plan(3) + /* eslint-disable no-new-wrappers */ + t.doesNotThrow(() => new Headers(new Number())) + t.doesNotThrow(() => new Headers(new Boolean())) + t.doesNotThrow(() => new Headers(new String())) + /* eslint-enable no-new-wrappers */ + }) + + t.test('fails if primitive is passed', t => { + t.plan(2) + const expectedTypeError = TypeError + t.throws(() => new Headers(1), expectedTypeError) + t.throws(() => new Headers('1'), expectedTypeError) + }) + + t.test('allows some weird stuff (because of webidl)', t => { + t.doesNotThrow(() => { + new Headers(function () {}) // eslint-disable-line no-new + }) + + t.doesNotThrow(() => { + new Headers(Function) // eslint-disable-line no-new + }) + + t.end() + }) + + t.test('allows a myriad of header values to be passed', t => { + t.plan(4) + + // Headers constructor uses Headers.append + + t.doesNotThrow(() => new Headers([ + ['a', ['b', 'c']], + ['d', ['e', 'f']] + ]), 'allows any array values') + t.doesNotThrow(() => new Headers([ + ['key', null] + ]), 'allows null values') + t.throws(() => new Headers([ + ['key'] + ]), 'throws when 2 arguments are not passed') + t.throws(() => new Headers([ + ['key', 'value', 'value2'] + ]), 'throws when too many arguments are passed') + }) + + t.test('accepts headers as objects with array values', t => { + t.plan(1) + const headers = new Headers({ + c: '5', + b: ['3', '4'], + a: ['1', '2'] + }) + + t.same([...headers.entries()], [ + ['a', '1,2'], + ['b', '3,4'], + ['c', '5'] + ]) + }) +}) + +tap.test('Headers append', t => { + t.plan(3) + + t.test('adds valid header entry to instance', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + t.doesNotThrow(() => headers.append(name, value)) + t.equal(headers.get(name), value) + }) + + t.test('adds valid header to existing entry', t => { + t.plan(4) + const headers = new Headers() + + const name = 'undici' + const value1 = 'fetch1' + const value2 = 'fetch2' + const value3 = 'fetch3' + headers.append(name, value1) + t.equal(headers.get(name), value1) + t.doesNotThrow(() => headers.append(name, value2)) + t.doesNotThrow(() => headers.append(name, value3)) + t.equal(headers.get(name), [value1, value2, value3].join(', ')) + }) + + t.test('throws on invalid entry', t => { + t.plan(3) + const headers = new Headers() + + t.throws(() => headers.append(), 'throws on missing name and value') + t.throws(() => headers.append('undici'), 'throws on missing value') + t.throws(() => headers.append('invalid @ header ? name', 'valid value'), 'throws on invalid name') + }) +}) + +tap.test('Headers delete', t => { + t.plan(4) + + t.test('deletes valid header entry from instance', t => { + t.plan(3) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + headers.append(name, value) + t.equal(headers.get(name), value) + t.doesNotThrow(() => headers.delete(name)) + t.equal(headers.get(name), null) + }) + + t.test('does not mutate internal list when no match is found', t => { + t.plan(3) + + const headers = new Headers() + const name = 'undici' + const value = 'fetch' + headers.append(name, value) + t.equal(headers.get(name), value) + t.doesNotThrow(() => headers.delete('not-undici')) + t.equal(headers.get(name), value) + }) + + t.test('throws on invalid entry', t => { + t.plan(2) + const headers = new Headers() + + t.throws(() => headers.delete(), 'throws on missing namee') + t.throws(() => headers.delete('invalid @ header ? name'), 'throws on invalid name') + }) + + // https://github.com/nodejs/undici/issues/2429 + t.test('`Headers#delete` returns undefined', t => { + t.plan(2) + const headers = new Headers({ test: 'test' }) + + t.same(headers.delete('test'), undefined) + t.same(headers.delete('test2'), undefined) + }) +}) + +tap.test('Headers get', t => { + t.plan(3) + + t.test('returns null if not found in instance', t => { + t.plan(1) + const headers = new Headers() + headers.append('undici', 'fetch') + + t.equal(headers.get('not-undici'), null) + }) + + t.test('returns header values from valid header name', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici'; const value1 = 'fetch1'; const value2 = 'fetch2' + headers.append(name, value1) + t.equal(headers.get(name), value1) + headers.append(name, value2) + t.equal(headers.get(name), [value1, value2].join(', ')) + }) + + t.test('throws on invalid entry', t => { + t.plan(2) + const headers = new Headers() + + t.throws(() => headers.get(), 'throws on missing name') + t.throws(() => headers.get('invalid @ header ? name'), 'throws on invalid name') + }) +}) + +tap.test('Headers has', t => { + t.plan(2) + + t.test('returns boolean existence for a header name', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici' + headers.append('not-undici', 'fetch') + t.equal(headers.has(name), false) + headers.append(name, 'fetch') + t.equal(headers.has(name), true) + }) + + t.test('throws on invalid entry', t => { + t.plan(2) + const headers = new Headers() + + t.throws(() => headers.has(), 'throws on missing name') + t.throws(() => headers.has('invalid @ header ? name'), 'throws on invalid name') + }) +}) + +tap.test('Headers set', t => { + t.plan(5) + + t.test('sets valid header entry to instance', t => { + t.plan(2) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + headers.append('not-undici', 'fetch') + t.doesNotThrow(() => headers.set(name, value)) + t.equal(headers.get(name), value) + }) + + t.test('overwrites existing entry', t => { + t.plan(4) + const headers = new Headers() + + const name = 'undici' + const value1 = 'fetch1' + const value2 = 'fetch2' + t.doesNotThrow(() => headers.set(name, value1)) + t.equal(headers.get(name), value1) + t.doesNotThrow(() => headers.set(name, value2)) + t.equal(headers.get(name), value2) + }) + + t.test('allows setting a myriad of values', t => { + t.plan(4) + const headers = new Headers() + + t.doesNotThrow(() => headers.set('a', ['b', 'c']), 'sets array values properly') + t.doesNotThrow(() => headers.set('b', null), 'allows setting null values') + t.throws(() => headers.set('c'), 'throws when 2 arguments are not passed') + t.doesNotThrow(() => headers.set('c', 'd', 'e'), 'ignores extra arguments') + }) + + t.test('throws on invalid entry', t => { + t.plan(3) + const headers = new Headers() + + t.throws(() => headers.set(), 'throws on missing name and value') + t.throws(() => headers.set('undici'), 'throws on missing value') + t.throws(() => headers.set('invalid @ header ? name', 'valid value'), 'throws on invalid name') + }) + + // https://github.com/nodejs/undici/issues/2431 + t.test('`Headers#set` returns undefined', t => { + t.plan(2) + const headers = new Headers() + + t.same(headers.set('a', 'b'), undefined) + + t.notOk(headers.set('c', 'd') instanceof Map) + }) +}) + +tap.test('Headers forEach', t => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + + t.test('standard', t => { + t.equal(typeof headers.forEach, 'function') + + headers.forEach((value, key, headerInstance) => { + t.ok(value === 'b' || value === 'd') + t.ok(key === 'a' || key === 'c') + t.equal(headers, headerInstance) + }) + + t.end() + }) + + t.test('when no thisArg is set, it is globalThis', (t) => { + headers.forEach(function () { + t.equal(this, globalThis) + }) + + t.end() + }) + + t.test('with thisArg', t => { + const thisArg = { a: Math.random() } + headers.forEach(function () { + t.equal(this, thisArg) + }, thisArg) + + t.end() + }) + + t.end() +}) + +tap.test('Headers as Iterable', t => { + t.plan(7) + + t.test('should freeze values while iterating', t => { + t.plan(1) + const init = [ + ['foo', '123'], + ['bar', '456'] + ] + const expected = [ + ['foo', '123'], + ['x-x-bar', '456'] + ] + const headers = new Headers(init) + for (const [key, val] of headers) { + headers.delete(key) + headers.set(`x-${key}`, val) + } + t.strictSame([...headers], expected) + }) + + t.test('returns combined and sorted entries using .forEach()', t => { + t.plan(8) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'] + ] + const headers = new Headers(init) + const that = {} + let i = 0 + headers.forEach(function (value, key, _headers) { + t.strictSame(expected[i++], [key, value]) + t.equal(this, that) + }, that) + }) + + t.test('returns combined and sorted entries using .entries()', t => { + t.plan(4) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'] + ] + const headers = new Headers(init) + let i = 0 + for (const header of headers.entries()) { + t.strictSame(header, expected[i++]) + } + }) + + t.test('returns combined and sorted keys using .keys()', t => { + t.plan(4) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = ['a', 'abc', 'b', 'c'] + const headers = new Headers(init) + let i = 0 + for (const key of headers.keys()) { + t.strictSame(key, expected[i++]) + } + }) + + t.test('returns combined and sorted values using .values()', t => { + t.plan(4) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = ['1', '4', '2, 5', '3'] + const headers = new Headers(init) + let i = 0 + for (const value of headers.values()) { + t.strictSame(value, expected[i++]) + } + }) + + t.test('returns combined and sorted entries using for...of loop', t => { + t.plan(5) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'], + ['d', ['6', '7']] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'], + ['d', '6,7'] + ] + let i = 0 + for (const header of new Headers(init)) { + t.strictSame(header, expected[i++]) + } + }) + + t.test('validate append ordering', t => { + t.plan(1) + const headers = new Headers([['b', '2'], ['c', '3'], ['e', '5']]) + headers.append('d', '4') + headers.append('a', '1') + headers.append('f', '6') + headers.append('c', '7') + headers.append('abc', '8') + + const expected = [...new Map([ + ['a', '1'], + ['abc', '8'], + ['b', '2'], + ['c', '3, 7'], + ['d', '4'], + ['e', '5'], + ['f', '6'] + ])] + + t.same([...headers], expected) + }) +}) + +tap.test('arg validation', (t) => { + // fill + t.throws(() => { + fill({}, 0) + }, TypeError) + + const headers = new Headers() + + // constructor + t.throws(() => { + // eslint-disable-next-line + new Headers(0) + }, TypeError) + + // get [Symbol.toStringTag] + t.doesNotThrow(() => { + Object.prototype.toString.call(Headers.prototype) + }) + + // toString + t.doesNotThrow(() => { + Headers.prototype.toString.call(null) + }) + + // append + t.throws(() => { + Headers.prototype.append.call(null) + }, TypeError) + t.throws(() => { + headers.append() + }, TypeError) + + // delete + t.throws(() => { + Headers.prototype.delete.call(null) + }, TypeError) + t.throws(() => { + headers.delete() + }, TypeError) + + // get + t.throws(() => { + Headers.prototype.get.call(null) + }, TypeError) + t.throws(() => { + headers.get() + }, TypeError) + + // has + t.throws(() => { + Headers.prototype.has.call(null) + }, TypeError) + t.throws(() => { + headers.has() + }, TypeError) + + // set + t.throws(() => { + Headers.prototype.set.call(null) + }, TypeError) + t.throws(() => { + headers.set() + }, TypeError) + + // forEach + t.throws(() => { + Headers.prototype.forEach.call(null) + }, TypeError) + t.throws(() => { + headers.forEach() + }, TypeError) + t.throws(() => { + headers.forEach(1) + }, TypeError) + + // inspect + t.throws(() => { + Headers.prototype[Symbol.for('nodejs.util.inspect.custom')].call(null) + }, TypeError) + + t.end() +}) + +tap.test('function signature verification', (t) => { + t.test('function length', (t) => { + t.equal(Headers.prototype.append.length, 2) + t.equal(Headers.prototype.constructor.length, 0) + t.equal(Headers.prototype.delete.length, 1) + t.equal(Headers.prototype.entries.length, 0) + t.equal(Headers.prototype.forEach.length, 1) + t.equal(Headers.prototype.get.length, 1) + t.equal(Headers.prototype.has.length, 1) + t.equal(Headers.prototype.keys.length, 0) + t.equal(Headers.prototype.set.length, 2) + t.equal(Headers.prototype.values.length, 0) + t.equal(Headers.prototype[Symbol.iterator].length, 0) + t.equal(Headers.prototype.toString.length, 0) + + t.end() + }) + + t.test('function equality', (t) => { + t.equal(Headers.prototype.entries, Headers.prototype[Symbol.iterator]) + t.equal(Headers.prototype.toString, Object.prototype.toString) + + t.end() + }) + + t.test('toString and Symbol.toStringTag', (t) => { + t.equal(Object.prototype.toString.call(Headers.prototype), '[object Headers]') + t.equal(Headers.prototype[Symbol.toStringTag], 'Headers') + t.equal(Headers.prototype.toString.call(null), '[object Null]') + + t.end() + }) + + t.end() +}) + +tap.test('various init paths of Headers', (t) => { + const h1 = new Headers() + const h2 = new Headers({}) + const h3 = new Headers(undefined) + t.equal([...h1.entries()].length, 0) + t.equal([...h2.entries()].length, 0) + t.equal([...h3.entries()].length, 0) + + t.end() +}) + +tap.test('immutable guard', (t) => { + const headers = new Headers() + headers.set('key', 'val') + headers[kGuard] = 'immutable' + + t.throws(() => { + headers.set('asd', 'asd') + }) + t.throws(() => { + headers.append('asd', 'asd') + }) + t.throws(() => { + headers.delete('asd') + }) + t.equal(headers.get('key'), 'val') + t.equal(headers.has('key'), true) + + t.end() +}) + +tap.test('request-no-cors guard', (t) => { + const headers = new Headers() + headers[kGuard] = 'request-no-cors' + t.doesNotThrow(() => { headers.set('key', 'val') }) + t.doesNotThrow(() => { headers.append('key', 'val') }) + t.doesNotThrow(() => { headers.delete('key') }) + t.end() +}) + +tap.test('invalid headers', (t) => { + t.doesNotThrow(() => new Headers({ "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~": 'test' })) + + const chars = '"(),/:;<=>?@[\\]{}'.split('') + + for (const char of chars) { + t.throws(() => new Headers({ [char]: 'test' }), TypeError, `The string "${char}" should throw an error.`) + } + + for (const byte of ['\r', '\n', '\t', ' ', String.fromCharCode(128), '']) { + t.throws(() => { + new Headers().set(byte, 'test') + }, TypeError, 'invalid header name') + } + + for (const byte of [ + '\0', + '\r', + '\n' + ]) { + t.throws(() => { + new Headers().set('a', `a${byte}b`) + }, TypeError, 'not allowed at all in header value') + } + + t.doesNotThrow(() => { + new Headers().set('a', '\r') + }) + + t.doesNotThrow(() => { + new Headers().set('a', '\n') + }) + + t.throws(() => { + new Headers().set('a', Symbol('symbol')) + }, TypeError, 'symbols should throw') + + t.end() +}) + +tap.test('headers that might cause a ReDoS', (t) => { + t.doesNotThrow(() => { + // This test will time out if the ReDoS attack is successful. + const headers = new Headers() + const attack = 'a' + '\t'.repeat(500_000) + '\ta' + headers.append('fhqwhgads', attack) + }) + + t.end() +}) + +tap.test('Headers.prototype.getSetCookie', (t) => { + t.test('Mutating the returned list does not affect the set-cookie list', (t) => { + const h = new Headers([ + ['set-cookie', 'a=b'], + ['set-cookie', 'c=d'] + ]) + + const old = h.getSetCookie() + h.getSetCookie().push('oh=no') + const now = h.getSetCookie() + + t.same(old, now) + t.end() + }) + + // https://github.com/nodejs/undici/issues/1935 + t.test('When Headers are cloned, so are the cookies', async (t) => { + const server = createServer((req, res) => { + res.setHeader('Set-Cookie', 'test=onetwo') + res.end('Hello World!') + }).listen(0) + + await once(server, 'listening') + t.teardown(server.close.bind(server)) + + const res = await fetch(`http://localhost:${server.address().port}`) + const entries = Object.fromEntries(res.headers.entries()) + + t.same(res.headers.getSetCookie(), ['test=onetwo']) + t.ok('set-cookie' in entries) + }) + + t.end() +}) diff --git a/test/fetch/http2.js b/test/fetch/http2.js new file mode 100644 index 0000000..9f6997f --- /dev/null +++ b/test/fetch/http2.js @@ -0,0 +1,415 @@ +'use strict' + +const { createSecureServer } = require('node:http2') +const { createReadStream, readFileSync } = require('node:fs') +const { once } = require('node:events') +const { Blob } = require('node:buffer') +const { Readable } = require('node:stream') + +const { test, plan } = require('tap') +const pem = require('https-pem') + +const { Client, fetch, Headers } = require('../..') + +const nodeVersion = Number(process.version.split('v')[1].split('.')[0]) + +plan(7) + +test('[Fetch] Issue#2311', async t => { + const expectedBody = 'hello from client!' + + const server = createSecureServer(pem, async (req, res) => { + let body = '' + + req.setEncoding('utf8') + + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': req.headers['x-my-header'] + }) + + for await (const chunk of req) { + body += chunk + } + + res.end(body) + }) + + t.plan(1) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: expectedBody + } + ) + + const responseBody = await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.equal(responseBody, expectedBody) +}) + +test('[Fetch] Simple GET with h2', async t => { + const server = createSecureServer(pem) + const expectedRequestBody = 'hello h2!' + + server.on('stream', async (stream, headers) => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + 'x-method': headers[':method'], + ':status': 200 + }) + + stream.end(expectedRequestBody) + }) + + t.plan(5) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.equal(responseBody, expectedRequestBody) + t.equal(response.headers.get('x-method'), 'GET') + t.equal(response.headers.get('x-custom-h2'), 'foo') + // https://github.com/nodejs/undici/issues/2415 + t.throws(() => { + response.headers.get(':status') + }, TypeError) + + // See https://fetch.spec.whatwg.org/#concept-response-status-message + t.equal(response.statusText, '') +}) + +test('[Fetch] Should handle h2 request with body (string or buffer)', async t => { + const server = createSecureServer(pem) + const expectedBody = 'hello from client!' + const expectedRequestBody = 'hello h2!' + const requestBody = [] + + server.on('stream', async (stream, headers) => { + stream.on('data', chunk => requestBody.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end(expectedRequestBody) + }) + + t.plan(2) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: expectedBody + } + ) + + const responseBody = await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.equal(Buffer.concat(requestBody).toString('utf-8'), expectedBody) + t.equal(responseBody, expectedRequestBody) +}) + +// Skipping for now, there is something odd in the way the body is handled +test( + '[Fetch] Should handle h2 request with body (stream)', + { skip: nodeVersion === 16 }, + async t => { + const server = createSecureServer(pem) + const expectedBody = readFileSync(__filename, 'utf-8') + const stream = createReadStream(__filename) + const requestChunks = [] + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'PUT') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + for await (const chunk of stream) { + requestChunks.push(chunk) + } + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'PUT', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: Readable.toWeb(stream), + duplex: 'half' + } + ) + + const responseBody = await response.text() + + t.equal(response.status, 200) + t.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8') + t.equal(response.headers.get('x-custom-h2'), 'foo') + t.equal(responseBody, 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + } +) +test('Should handle h2 request with body (Blob)', { skip: !Blob }, async t => { + const server = createSecureServer(pem) + const expectedBody = 'asd' + const requestChunks = [] + const body = new Blob(['asd'], { + type: 'text/plain' + }) + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'POST') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end('hello h2!') + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + body, + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.arrayBuffer() + + t.equal(response.status, 200) + t.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8') + t.equal(response.headers.get('x-custom-h2'), 'foo') + t.same(new TextDecoder().decode(responseBody).toString(), 'hello h2!') + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) +}) + +test( + 'Should handle h2 request with body (Blob:ArrayBuffer)', + { skip: !Blob }, + async t => { + const server = createSecureServer(pem) + const expectedBody = 'hello' + const requestChunks = [] + const expectedResponseBody = { hello: 'h2' } + const buf = Buffer.from(expectedBody) + const body = new ArrayBuffer(buf.byteLength) + + buf.copy(new Uint8Array(body)) + + server.on('stream', async (stream, headers) => { + t.equal(headers[':method'], 'PUT') + t.equal(headers[':path'], '/') + t.equal(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'application/json', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end(JSON.stringify(expectedResponseBody)) + }) + + t.plan(8) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + body, + method: 'PUT', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.json() + + t.equal(response.status, 200) + t.equal(response.headers.get('content-type'), 'application/json') + t.equal(response.headers.get('x-custom-h2'), 'foo') + t.same(responseBody, expectedResponseBody) + t.equal(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + } +) + +test('Issue#2415', async (t) => { + t.plan(1) + const server = createSecureServer(pem) + + server.on('stream', async (stream, headers) => { + stream.respond({ + ':status': 200 + }) + stream.end('test') + }) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client + } + ) + + await response.text() + + t.teardown(server.close.bind(server)) + t.teardown(client.close.bind(client)) + + t.doesNotThrow(() => new Headers(response.headers)) +}) diff --git a/test/fetch/integrity.js b/test/fetch/integrity.js new file mode 100644 index 0000000..f91f693 --- /dev/null +++ b/test/fetch/integrity.js @@ -0,0 +1,150 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { createHash, getHashes } = require('crypto') +const { gzipSync } = require('zlib') +const { fetch, setGlobalDispatcher, Agent } = require('../..') +const { once } = require('events') + +const supportedHashes = getHashes() + +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 +})) + +test('request with correct integrity checksum', (t) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + t.strictSame(body, await response.text()) + t.end() + }) +}) + +test('request with wrong integrity checksum', (t) => { + const body = 'Hello world!' + const hash = 'c0535e4be2b79ffd93291305436bf889314e4a3faec05ecffcbb7df31ad9e51b' + + const server = createServer((req, res) => { + res.end(body) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }).then(response => { + t.pass('request did not fail') + }).catch((err) => { + t.equal(err.cause.message, 'integrity mismatch') + }).finally(() => { + t.end() + }) + }) +}) + +test('request with integrity checksum on encoded body', (t) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(body)) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + t.strictSame(body, await response.text()) + t.end() + }) +}) + +test('request with a totally incorrect integrity', async (t) => { + const server = createServer((req, res) => { + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: 'what-integrityisthis' + })) +}) + +test('request with mixed in/valid integrities', async (t) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: `invalid-integrity sha256-${hash}` + })) +}) + +test('request with sha384 hash', { skip: !supportedHashes.includes('sha384') }, async (t) => { + const body = 'Hello world!' + const hash = createHash('sha384').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + // request should succeed + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${hash}` + })) + + // request should fail + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha384-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + })) +}) + +test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') }, async (t) => { + const body = 'Hello world!' + const hash = createHash('sha512').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + // request should succeed + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${hash}` + })) + + // request should fail + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha512-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + })) +}) diff --git a/test/fetch/issue-1447.js b/test/fetch/issue-1447.js new file mode 100644 index 0000000..503b344 --- /dev/null +++ b/test/fetch/issue-1447.js @@ -0,0 +1,46 @@ +'use strict' + +const { test, skip } = require('tap') +const { nodeMajor } = require('../../lib/core/util') + +if (nodeMajor === 16) { + skip('esbuild uses static blocks with --keep-names which node 16.8 does not have') + process.exit() +} + +const undici = require('../..') +const { fetch: theoreticalGlobalFetch } = require('../../undici-fetch') + +test('Mocking works with both fetches', async (t) => { + const mockAgent = new undici.MockAgent() + const body = JSON.stringify({ foo: 'bar' }) + + mockAgent.disableNetConnect() + undici.setGlobalDispatcher(mockAgent) + const pool = mockAgent.get('https://example.com') + + pool.intercept({ + path: '/path', + method: 'POST', + body (bodyString) { + t.equal(bodyString, body) + return true + } + }).reply(200, { ok: 1 }).times(2) + + const url = new URL('https://example.com/path').href + + // undici fetch from node_modules + await undici.fetch(url, { + method: 'POST', + body + }) + + // the global fetch bundled with esbuild + await theoreticalGlobalFetch(url, { + method: 'POST', + body + }) + + t.end() +}) diff --git a/test/fetch/issue-2009.js b/test/fetch/issue-2009.js new file mode 100644 index 0000000..0b7b3e9 --- /dev/null +++ b/test/fetch/issue-2009.js @@ -0,0 +1,28 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') +const { createServer } = require('http') +const { once } = require('events') + +test('issue 2009', async (t) => { + const server = createServer((req, res) => { + res.setHeader('a', 'b') + res.flushHeaders() + + res.socket.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + for (let i = 0; i < 10; i++) { + await t.resolves( + fetch(`http://localhost:${server.address().port}`).then( + async (resp) => { + await resp.body.cancel('Some message') + } + ) + ) + } +}) diff --git a/test/fetch/issue-2021.js b/test/fetch/issue-2021.js new file mode 100644 index 0000000..cd28a71 --- /dev/null +++ b/test/fetch/issue-2021.js @@ -0,0 +1,32 @@ +'use strict' + +const { test } = require('tap') +const { once } = require('events') +const { createServer } = require('http') +const { fetch } = require('../..') + +// https://github.com/nodejs/undici/issues/2021 +test('content-length header is removed on redirect', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.writeHead(302, { Location: '/redirect2' }) + res.end() + return + } + + res.end() + }).listen(0).unref() + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const body = 'a+b+c' + + await t.resolves(fetch(`http://localhost:${server.address().port}/redirect`, { + method: 'POST', + body, + headers: { + 'content-length': Buffer.byteLength(body) + } + })) +}) diff --git a/test/fetch/issue-2171.js b/test/fetch/issue-2171.js new file mode 100644 index 0000000..b04ae0e --- /dev/null +++ b/test/fetch/issue-2171.js @@ -0,0 +1,25 @@ +'use strict' + +const { fetch } = require('../..') +const { DOMException } = require('../../lib/fetch/constants') +const { once } = require('events') +const { createServer } = require('http') +const { test } = require('tap') + +test('error reason is forwarded - issue #2171', { skip: !AbortSignal.timeout }, async (t) => { + const server = createServer(() => {}).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const timeout = AbortSignal.timeout(100) + await t.rejects( + fetch(`http://localhost:${server.address().port}`, { + signal: timeout + }), + { + name: 'TimeoutError', + code: DOMException.TIMEOUT_ERR + } + ) +}) diff --git a/test/fetch/issue-2242.js b/test/fetch/issue-2242.js new file mode 100644 index 0000000..fe70412 --- /dev/null +++ b/test/fetch/issue-2242.js @@ -0,0 +1,8 @@ +'use strict' + +const { test } = require('tap') +const { fetch } = require('../..') + +test('fetch with signal already aborted', async (t) => { + await t.rejects(fetch('http://localhost', { signal: AbortSignal.abort('Already aborted') }), 'Already aborted') +}) diff --git a/test/fetch/issue-2318.js b/test/fetch/issue-2318.js new file mode 100644 index 0000000..e4f610d --- /dev/null +++ b/test/fetch/issue-2318.js @@ -0,0 +1,25 @@ +'use strict'
+
+const { test } = require('tap')
+const { once } = require('events')
+const { createServer } = require('http')
+const { fetch } = require('../..')
+
+test('Undici overrides user-provided `Host` header', async (t) => {
+ t.plan(1)
+
+ const server = createServer((req, res) => {
+ t.equal(req.headers.host, `localhost:${server.address().port}`)
+
+ res.end()
+ }).listen(0)
+
+ t.teardown(server.close.bind(server))
+ await once(server, 'listening')
+
+ await fetch(`http://localhost:${server.address().port}`, {
+ headers: {
+ host: 'www.idk.org'
+ }
+ })
+})
diff --git a/test/fetch/issue-node-46525.js b/test/fetch/issue-node-46525.js new file mode 100644 index 0000000..6fd9810 --- /dev/null +++ b/test/fetch/issue-node-46525.js @@ -0,0 +1,28 @@ +'use strict' + +const { once } = require('events') +const { createServer } = require('http') +const { test } = require('tap') +const { fetch } = require('../..') + +// https://github.com/nodejs/node/issues/46525 +test('No warning when reusing AbortController', async (t) => { + function onWarning (error) { + t.error(error, 'Got warning') + } + + const server = createServer((req, res) => res.end()).listen(0) + + await once(server, 'listening') + + process.on('warning', onWarning) + t.teardown(() => { + process.off('warning', onWarning) + return server.close() + }) + + const controller = new AbortController() + for (let i = 0; i < 15; i++) { + await fetch(`http://localhost:${server.address().port}`, { signal: controller.signal }) + } +}) diff --git a/test/fetch/iterators.js b/test/fetch/iterators.js new file mode 100644 index 0000000..6c6761d --- /dev/null +++ b/test/fetch/iterators.js @@ -0,0 +1,140 @@ +'use strict' + +const { test } = require('tap') +const { Headers, FormData } = require('../..') + +test('Implements " Iterator" properly', (t) => { + t.test('all Headers iterators implement Headers Iterator', (t) => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + + for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) { + const gen = headers[iterable]() + // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object + const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + const iteratorProto = Object.getPrototypeOf(gen) + + t.ok(gen.constructor === Object) + t.ok(gen.prototype === undefined) + // eslint-disable-next-line no-proto + t.equal(gen.__proto__[Symbol.toStringTag], 'Headers Iterator') + // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049 + t.notOk(Headers.prototype[iterable] instanceof function * () {}.constructor) + // eslint-disable-next-line no-proto + t.ok(gen.__proto__.next.__proto__ === Function.prototype) + // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%." + t.equal(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator]) + t.equal(Object.getPrototypeOf(iteratorProto), IteratorPrototype) + } + + t.end() + }) + + t.test('all FormData iterators implement FormData Iterator', (t) => { + const fd = new FormData() + + for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) { + const gen = fd[iterable]() + // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object + const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + const iteratorProto = Object.getPrototypeOf(gen) + + t.ok(gen.constructor === Object) + t.ok(gen.prototype === undefined) + // eslint-disable-next-line no-proto + t.equal(gen.__proto__[Symbol.toStringTag], 'FormData Iterator') + // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049 + t.notOk(Headers.prototype[iterable] instanceof function * () {}.constructor) + // eslint-disable-next-line no-proto + t.ok(gen.__proto__.next.__proto__ === Function.prototype) + // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%." + t.equal(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator]) + t.equal(Object.getPrototypeOf(iteratorProto), IteratorPrototype) + } + + t.end() + }) + + t.test('Iterator symbols are properly set', (t) => { + t.test('Headers', (t) => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + const gen = headers.entries() + + t.equal(typeof gen[Symbol.toStringTag], 'string') + t.equal(typeof gen[Symbol.iterator], 'function') + t.end() + }) + + t.test('FormData', (t) => { + const fd = new FormData() + const gen = fd.entries() + + t.equal(typeof gen[Symbol.toStringTag], 'string') + t.equal(typeof gen[Symbol.iterator], 'function') + t.end() + }) + + t.end() + }) + + t.test('Iterator does not inherit Generator prototype methods', (t) => { + t.test('Headers', (t) => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + const gen = headers.entries() + + t.equal(gen.return, undefined) + t.equal(gen.throw, undefined) + t.equal(typeof gen.next, 'function') + + t.end() + }) + + t.test('FormData', (t) => { + const fd = new FormData() + const gen = fd.entries() + + t.equal(gen.return, undefined) + t.equal(gen.throw, undefined) + t.equal(typeof gen.next, 'function') + + t.end() + }) + + t.end() + }) + + t.test('Symbol.iterator', (t) => { + // Headers + const headerValues = new Headers([['a', 'b']]).entries()[Symbol.iterator]() + t.same(Array.from(headerValues), [['a', 'b']]) + + // FormData + const formdata = new FormData() + formdata.set('a', 'b') + const formdataValues = formdata.entries()[Symbol.iterator]() + t.same(Array.from(formdataValues), [['a', 'b']]) + + t.end() + }) + + t.test('brand check', (t) => { + // Headers + t.throws(() => { + const gen = new Headers().entries() + // eslint-disable-next-line no-proto + gen.__proto__.next() + }, TypeError) + + // FormData + t.throws(() => { + const gen = new FormData().entries() + // eslint-disable-next-line no-proto + gen.__proto__.next() + }, TypeError) + + t.end() + }) + + t.end() +}) diff --git a/test/fetch/jsdom-abortcontroller-1910-1464495619.js b/test/fetch/jsdom-abortcontroller-1910-1464495619.js new file mode 100644 index 0000000..e5a86ab --- /dev/null +++ b/test/fetch/jsdom-abortcontroller-1910-1464495619.js @@ -0,0 +1,26 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { fetch } = require('../..') +const { JSDOM } = require('jsdom') + +// https://github.com/nodejs/undici/pull/1910#issuecomment-1464495619 +test('third party AbortControllers', async (t) => { + const server = createServer((_, res) => res.end()).listen(0) + + const { AbortController } = new JSDOM().window + let controller = new AbortController() + + t.teardown(() => { + controller.abort() + controller = null + return server.close() + }) + await once(server, 'listening') + + await t.resolves(fetch(`http://localhost:${server.address().port}`, { + signal: controller.signal + })) +}) diff --git a/test/fetch/redirect-cross-origin-header.js b/test/fetch/redirect-cross-origin-header.js new file mode 100644 index 0000000..fca48c4 --- /dev/null +++ b/test/fetch/redirect-cross-origin-header.js @@ -0,0 +1,48 @@ +'use strict'
+
+const { test } = require('tap')
+const { createServer } = require('http')
+const { once } = require('events')
+const { fetch } = require('../..')
+
+test('Cross-origin redirects clear forbidden headers', async (t) => {
+ t.plan(5)
+
+ const server1 = createServer((req, res) => {
+ t.equal(req.headers.cookie, undefined)
+ t.equal(req.headers.authorization, undefined)
+
+ res.end('redirected')
+ }).listen(0)
+
+ const server2 = createServer((req, res) => {
+ t.equal(req.headers.authorization, 'test')
+ t.equal(req.headers.cookie, 'ddd=dddd')
+
+ res.writeHead(302, {
+ ...req.headers,
+ Location: `http://localhost:${server1.address().port}`
+ })
+ res.end()
+ }).listen(0)
+
+ t.teardown(() => {
+ server1.close()
+ server2.close()
+ })
+
+ await Promise.all([
+ once(server1, 'listening'),
+ once(server2, 'listening')
+ ])
+
+ const res = await fetch(`http://localhost:${server2.address().port}`, {
+ headers: {
+ Authorization: 'test',
+ Cookie: 'ddd=dddd'
+ }
+ })
+
+ const text = await res.text()
+ t.equal(text, 'redirected')
+})
diff --git a/test/fetch/redirect.js b/test/fetch/redirect.js new file mode 100644 index 0000000..7e3681b --- /dev/null +++ b/test/fetch/redirect.js @@ -0,0 +1,50 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { fetch } = require('../..') + +// https://github.com/nodejs/undici/issues/1776 +test('Redirecting with a body does not cancel the current request - #1776', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 301 + res.setHeader('location', '/redirect/') + res.write('<a href="/redirect/">Moved Permanently</a>') + setTimeout(() => res.end(), 500) + return + } + + res.write(req.url) + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const resp = await fetch(`http://localhost:${server.address().port}/redirect`) + t.equal(await resp.text(), '/redirect/') + t.ok(resp.redirected) +}) + +test('Redirecting with an empty body does not throw an error - #2027', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 307 + res.setHeader('location', '/redirect/') + res.write('<a href="/redirect/">Moved Permanently</a>') + res.end() + return + } + res.write(req.url) + res.end() + }).listen(0) + + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const resp = await fetch(`http://localhost:${server.address().port}/redirect`, { method: 'PUT', body: '' }) + t.equal(await resp.text(), '/redirect/') + t.ok(resp.redirected) +}) diff --git a/test/fetch/relative-url.js b/test/fetch/relative-url.js new file mode 100644 index 0000000..1a4f819 --- /dev/null +++ b/test/fetch/relative-url.js @@ -0,0 +1,110 @@ +'use strict' + +const { test, afterEach } = require('tap') +const { createServer } = require('http') +const { once } = require('events') +const { + getGlobalOrigin, + setGlobalOrigin, + Response, + Request, + fetch +} = require('../..') + +afterEach(() => setGlobalOrigin(undefined)) + +test('setGlobalOrigin & getGlobalOrigin', (t) => { + t.equal(getGlobalOrigin(), undefined) + + setGlobalOrigin('http://localhost:3000') + t.same(getGlobalOrigin(), new URL('http://localhost:3000')) + + setGlobalOrigin(undefined) + t.equal(getGlobalOrigin(), undefined) + + setGlobalOrigin(new URL('http://localhost:3000')) + t.same(getGlobalOrigin(), new URL('http://localhost:3000')) + + t.throws(() => { + setGlobalOrigin('invalid.url') + }, TypeError) + + t.throws(() => { + setGlobalOrigin('wss://invalid.protocol') + }, TypeError) + + t.throws(() => setGlobalOrigin(true)) + + t.end() +}) + +test('Response.redirect', (t) => { + t.throws(() => { + Response.redirect('/relative/path', 302) + }, TypeError('Failed to parse URL from /relative/path')) + + t.doesNotThrow(() => { + setGlobalOrigin('http://localhost:3000') + Response.redirect('/relative/path', 302) + }) + + setGlobalOrigin('http://localhost:3000') + const response = Response.redirect('/relative/path', 302) + // See step #7 of https://fetch.spec.whatwg.org/#dom-response-redirect + t.equal(response.headers.get('location'), 'http://localhost:3000/relative/path') + + t.end() +}) + +test('new Request', (t) => { + t.throws( + () => new Request('/relative/path'), + TypeError('Failed to parse URL from /relative/path') + ) + + t.doesNotThrow(() => { + setGlobalOrigin('http://localhost:3000') + // eslint-disable-next-line no-new + new Request('/relative/path') + }) + + setGlobalOrigin('http://localhost:3000') + const request = new Request('/relative/path') + t.equal(request.url, 'http://localhost:3000/relative/path') + + t.end() +}) + +test('fetch', async (t) => { + await t.rejects(async () => { + await fetch('/relative/path') + }, TypeError('Failed to parse URL from /relative/path')) + + t.test('Basic fetch', async (t) => { + const server = createServer((req, res) => { + t.equal(req.url, '/relative/path') + res.end() + }).listen(0) + + setGlobalOrigin(`http://localhost:${server.address().port}`) + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + await t.resolves(fetch('/relative/path')) + }) + + t.test('fetch return', async (t) => { + const server = createServer((req, res) => { + t.equal(req.url, '/relative/path') + res.end() + }).listen(0) + + setGlobalOrigin(`http://localhost:${server.address().port}`) + t.teardown(server.close.bind(server)) + await once(server, 'listening') + + const response = await fetch('/relative/path') + + t.equal(response.url, `http://localhost:${server.address().port}/relative/path`) + }) +}) diff --git a/test/fetch/request.js b/test/fetch/request.js new file mode 100644 index 0000000..db2c8e8 --- /dev/null +++ b/test/fetch/request.js @@ -0,0 +1,514 @@ +/* globals AbortController */ + +'use strict' + +const { test, teardown } = require('tap') +const { + Request, + Headers, + fetch +} = require('../../') +const { + Blob: ThirdPartyBlob, + FormData: ThirdPartyFormData +} = require('formdata-node') + +const hasSignalReason = 'reason' in AbortSignal.prototype + +test('arg validation', async (t) => { + // constructor + t.throws(() => { + // eslint-disable-next-line + new Request() + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', 0) + }, TypeError) + t.throws(() => { + const url = new URL('http://asd') + url.password = 'asd' + // eslint-disable-next-line + new Request(url) + }, TypeError) + t.throws(() => { + const url = new URL('http://asd') + url.username = 'asd' + // eslint-disable-next-line + new Request(url) + }, TypeError) + t.doesNotThrow(() => { + // eslint-disable-next-line + new Request('http://asd', undefined) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + window: {} + }) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + window: 1 + }) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + mode: 'navigate' + }) + }) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + referrerPolicy: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + mode: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + credentials: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + cache: 'agjhagna' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + method: 'agjhagnaöööö' + }) + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + method: 'TRACE' + }) + }, TypeError) + + t.throws(() => { + Request.prototype.destination.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.referrer.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.referrerPolicy.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.mode.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.credentials.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.cache.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.redirect.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.integrity.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.keepalive.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.isReloadNavigation.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.isHistoryNavigation.toString() + }, TypeError) + + t.throws(() => { + Request.prototype.signal.toString() + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Request.prototype.body + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Request.prototype.bodyUsed + }, TypeError) + + t.throws(() => { + Request.prototype.clone.call(null) + }, TypeError) + + t.doesNotThrow(() => { + Request.prototype[Symbol.toStringTag].charAt(0) + }) + + for (const method of [ + 'text', + 'json', + 'arrayBuffer', + 'blob', + 'formData' + ]) { + await t.rejects(async () => { + await new Request('http://localhost')[method].call({ + blob () { + return { + text () { + return Promise.resolve('emulating this') + } + } + } + }) + }, TypeError) + } + + t.end() +}) + +test('undefined window', t => { + t.doesNotThrow(() => new Request('http://asd', { window: undefined })) + t.end() +}) + +test('undefined body', t => { + const req = new Request('http://asd', { body: undefined }) + t.equal(req.body, null) + t.end() +}) + +test('undefined method', t => { + const req = new Request('http://asd', { method: undefined }) + t.equal(req.method, 'GET') + t.end() +}) + +test('undefined headers', t => { + const req = new Request('http://asd', { headers: undefined }) + t.equal([...req.headers.entries()].length, 0) + t.end() +}) + +test('undefined referrer', t => { + const req = new Request('http://asd', { referrer: undefined }) + t.equal(req.referrer, 'about:client') + t.end() +}) + +test('undefined referrerPolicy', t => { + const req = new Request('http://asd', { referrerPolicy: undefined }) + t.equal(req.referrerPolicy, '') + t.end() +}) + +test('undefined mode', t => { + const req = new Request('http://asd', { mode: undefined }) + t.equal(req.mode, 'cors') + t.end() +}) + +test('undefined credentials', t => { + const req = new Request('http://asd', { credentials: undefined }) + t.equal(req.credentials, 'same-origin') + t.end() +}) + +test('undefined cache', t => { + const req = new Request('http://asd', { cache: undefined }) + t.equal(req.cache, 'default') + t.end() +}) + +test('undefined redirect', t => { + const req = new Request('http://asd', { redirect: undefined }) + t.equal(req.redirect, 'follow') + t.end() +}) + +test('undefined keepalive', t => { + const req = new Request('http://asd', { keepalive: undefined }) + t.equal(req.keepalive, false) + t.end() +}) + +test('undefined integrity', t => { + const req = new Request('http://asd', { integrity: undefined }) + t.equal(req.integrity, '') + t.end() +}) + +test('null integrity', t => { + const req = new Request('http://asd', { integrity: null }) + t.equal(req.integrity, 'null') + t.end() +}) + +test('undefined signal', t => { + const req = new Request('http://asd', { signal: undefined }) + t.equal(req.signal.aborted, false) + t.end() +}) + +test('pre aborted signal', t => { + const ac = new AbortController() + ac.abort('gwak') + const req = new Request('http://asd', { signal: ac.signal }) + t.equal(req.signal.aborted, true) + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } + t.end() +}) + +test('post aborted signal', t => { + t.plan(2) + + const ac = new AbortController() + const req = new Request('http://asd', { signal: ac.signal }) + t.equal(req.signal.aborted, false) + ac.signal.addEventListener('abort', () => { + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } else { + t.pass() + } + }, { once: true }) + ac.abort('gwak') +}) + +test('pre aborted signal cloned', t => { + const ac = new AbortController() + ac.abort('gwak') + const req = new Request('http://asd', { signal: ac.signal }).clone() + t.equal(req.signal.aborted, true) + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } + t.end() +}) + +test('URLSearchParams body with Headers object - issue #1407', async (t) => { + const body = new URLSearchParams({ + abc: 123 + }) + + const request = new Request( + 'http://localhost', + { + method: 'POST', + body, + headers: { + Authorization: 'test' + } + } + ) + + t.equal(request.headers.get('content-type'), 'application/x-www-form-urlencoded;charset=UTF-8') + t.equal(request.headers.get('authorization'), 'test') + t.equal(await request.text(), 'abc=123') +}) + +test('post aborted signal cloned', t => { + t.plan(2) + + const ac = new AbortController() + const req = new Request('http://asd', { signal: ac.signal }).clone() + t.equal(req.signal.aborted, false) + ac.signal.addEventListener('abort', () => { + if (hasSignalReason) { + t.equal(req.signal.reason, 'gwak') + } else { + t.pass() + } + }, { once: true }) + ac.abort('gwak') +}) + +test('Passing headers in init', (t) => { + // https://github.com/nodejs/undici/issues/1400 + t.test('Headers instance', (t) => { + const req = new Request('http://localhost', { + headers: new Headers({ key: 'value' }) + }) + + t.equal(req.headers.get('key'), 'value') + t.end() + }) + + t.test('key:value object', (t) => { + const req = new Request('http://localhost', { + headers: { key: 'value' } + }) + + t.equal(req.headers.get('key'), 'value') + t.end() + }) + + t.test('[key, value][]', (t) => { + const req = new Request('http://localhost', { + headers: [['key', 'value']] + }) + + t.equal(req.headers.get('key'), 'value') + t.end() + }) + + t.end() +}) + +test('Symbol.toStringTag', (t) => { + const req = new Request('http://localhost') + + t.equal(req[Symbol.toStringTag], 'Request') + t.equal(Request.prototype[Symbol.toStringTag], 'Request') + t.end() +}) + +test('invalid RequestInit values', (t) => { + /* eslint-disable no-new */ + t.throws(() => { + new Request('http://l', { mode: 'CoRs' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { mode: 'random' }) + }, TypeError) + + t.throws(() => { + new Request('http://l', { credentials: 'OMIt' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { credentials: 'random' }) + }, TypeError) + + t.throws(() => { + new Request('http://l', { cache: 'DeFaULt' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { cache: 'random' }) + }, TypeError) + + t.throws(() => { + new Request('http://l', { redirect: 'FOllOW' }) + }, TypeError, 'not exact case = error') + + t.throws(() => { + new Request('http://l', { redirect: 'random' }) + }, TypeError) + /* eslint-enable no-new */ + + t.end() +}) + +test('RequestInit.signal option', async (t) => { + t.throws(() => { + // eslint-disable-next-line no-new + new Request('http://asd', { + signal: true + }) + }, TypeError) + + await t.rejects(fetch('http://asd', { + signal: false + }), TypeError) +}) + +test('constructing Request with third party Blob body', async (t) => { + const blob = new ThirdPartyBlob(['text']) + const req = new Request('http://asd', { + method: 'POST', + body: blob + }) + t.equal(await req.text(), 'text') +}) +test('constructing Request with third party FormData body', async (t) => { + const form = new ThirdPartyFormData() + form.set('key', 'value') + const req = new Request('http://asd', { + method: 'POST', + body: form + }) + const contentType = req.headers.get('content-type').split('=') + t.equal(contentType[0], 'multipart/form-data; boundary') + t.ok((await req.text()).startsWith(`--${contentType[1]}`)) +}) + +// https://github.com/nodejs/undici/issues/2050 +test('set-cookie headers get cleared when passing a Request as first param', (t) => { + const req1 = new Request('http://localhost', { + headers: { + 'set-cookie': 'a=1' + } + }) + + t.same([...req1.headers], [['set-cookie', 'a=1']]) + const req2 = new Request(req1, { headers: {} }) + + t.same([...req2.headers], []) + t.same(req2.headers.getSetCookie(), []) + t.end() +}) + +// https://github.com/nodejs/undici/issues/2124 +test('request.referrer', (t) => { + for (const referrer of ['about://client', 'about://client:1234']) { + const request = new Request('http://a', { referrer }) + + t.equal(request.referrer, 'about:client') + } + + t.end() +}) + +// https://github.com/nodejs/undici/issues/2445 +test('Clone the set-cookie header when Request is passed as the first parameter and no header is passed.', (t) => { + t.plan(2) + const request = new Request('http://localhost', { headers: { 'set-cookie': 'A' } }) + const request2 = new Request(request) + request2.headers.append('set-cookie', 'B') + t.equal(request.headers.getSetCookie().join(', '), request.headers.get('set-cookie')) + t.equal(request2.headers.getSetCookie().join(', '), request2.headers.get('set-cookie')) +}) + +// Tests for optimization introduced in https://github.com/nodejs/undici/pull/2456 +test('keys to object prototypes method', (t) => { + t.plan(1) + const request = new Request('http://localhost', { method: 'hasOwnProperty' }) + t.ok(typeof request.method === 'string') +}) + +// https://github.com/nodejs/undici/issues/2465 +test('Issue#2465', async (t) => { + t.plan(1) + const request = new Request('http://localhost', { body: new SharedArrayBuffer(0), method: 'POST' }) + t.equal(await request.text(), '[object SharedArrayBuffer]') +}) + +teardown(() => process.exit()) diff --git a/test/fetch/resource-timing.js b/test/fetch/resource-timing.js new file mode 100644 index 0000000..d266f28 --- /dev/null +++ b/test/fetch/resource-timing.js @@ -0,0 +1,72 @@ +'use strict' + +const { test } = require('tap') +const { createServer } = require('http') +const { nodeMajor, nodeMinor } = require('../../lib/core/util') +const { fetch } = require('../..') + +const { + PerformanceObserver, + performance +} = require('perf_hooks') + +const skip = nodeMajor < 18 || (nodeMajor === 18 && nodeMinor < 2) + +test('should create a PerformanceResourceTiming after each fetch request', { skip }, (t) => { + t.plan(8) + + const obs = new PerformanceObserver(list => { + const expectedResourceEntryName = `http://localhost:${server.address().port}/` + + const entries = list.getEntries() + t.equal(entries.length, 1) + const [entry] = entries + t.same(entry.name, expectedResourceEntryName) + t.strictSame(entry.entryType, 'resource') + + t.ok(entry.duration >= 0) + t.ok(entry.startTime >= 0) + + const entriesByName = list.getEntriesByName(expectedResourceEntryName) + t.equal(entriesByName.length, 1) + t.strictSame(entriesByName[0], entry) + + obs.disconnect() + performance.clearResourceTimings() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame('ok', await body.text()) + }) + + t.teardown(server.close.bind(server)) +}) + +test('should include encodedBodySize in performance entry', { skip }, (t) => { + t.plan(4) + const obs = new PerformanceObserver(list => { + const [entry] = list.getEntries() + t.equal(entry.encodedBodySize, 2) + t.equal(entry.decodedBodySize, 2) + t.equal(entry.transferSize, 2 + 300) + + obs.disconnect() + performance.clearResourceTimings() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + t.strictSame('ok', await body.text()) + }) + + t.teardown(server.close.bind(server)) +}) diff --git a/test/fetch/response-json.js b/test/fetch/response-json.js new file mode 100644 index 0000000..6244fbf --- /dev/null +++ b/test/fetch/response-json.js @@ -0,0 +1,113 @@ +'use strict' + +const { test } = require('tap') +const { Response } = require('../../') + +// https://github.com/web-platform-tests/wpt/pull/32825/ + +const APPLICATION_JSON = 'application/json' +const FOO_BAR = 'foo/bar' + +const INIT_TESTS = [ + [undefined, 200, '', APPLICATION_JSON, {}], + [{ status: 400 }, 400, '', APPLICATION_JSON, {}], + [{ statusText: 'foo' }, 200, 'foo', APPLICATION_JSON, {}], + [{ headers: {} }, 200, '', APPLICATION_JSON, {}], + [{ headers: { 'content-type': FOO_BAR } }, 200, '', FOO_BAR, {}], + [{ headers: { 'x-foo': 'bar' } }, 200, '', APPLICATION_JSON, { 'x-foo': 'bar' }] +] + +test('Check response returned by static json() with init', async (t) => { + for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) { + const response = Response.json('hello world', init) + t.equal(response.type, 'default', "Response's type is default") + t.equal(response.status, expectedStatus, "Response's status is " + expectedStatus) + t.equal(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText)) + t.equal(response.headers.get('content-type'), expectedContentType, "Response's content-type is " + expectedContentType) + for (const key in expectedHeaders) { + t.equal(response.headers.get(key), expectedHeaders[key], "Response's header " + key + ' is ' + JSON.stringify(expectedHeaders[key])) + } + + const data = await response.json() + t.equal(data, 'hello world', "Response's body is 'hello world'") + } + + t.end() +}) + +test('Throws TypeError when calling static json() with an invalid status', (t) => { + const nullBodyStatus = [204, 205, 304] + + for (const status of nullBodyStatus) { + t.throws(() => { + Response.json('hello world', { status }) + }, TypeError, `Throws TypeError when calling static json() with a status of ${status}`) + } + + t.end() +}) + +test('Check static json() encodes JSON objects correctly', async (t) => { + const response = Response.json({ foo: 'bar' }) + const data = await response.json() + t.equal(typeof data, 'object', "Response's json body is an object") + t.equal(data.foo, 'bar', "Response's json body is { foo: 'bar' }") + + t.end() +}) + +test('Check static json() throws when data is not encodable', (t) => { + t.throws(() => { + Response.json(Symbol('foo')) + }, TypeError) + + t.end() +}) + +test('Check static json() throws when data is circular', (t) => { + const a = { b: 1 } + a.a = a + + t.throws(() => { + Response.json(a) + }, TypeError) + + t.end() +}) + +test('Check static json() propagates JSON serializer errors', (t) => { + class CustomError extends Error { + name = 'CustomError' + } + + t.throws(() => { + Response.json({ get foo () { throw new CustomError('bar') } }) + }, CustomError) + + t.end() +}) + +// note: these tests are not part of any WPTs +test('unserializable values', (t) => { + t.throws(() => { + Response.json(Symbol('symbol')) + }, TypeError) + + t.throws(() => { + Response.json(undefined) + }, TypeError) + + t.throws(() => { + Response.json() + }, TypeError) + + t.end() +}) + +test('invalid init', (t) => { + t.throws(() => { + Response.json(null, 3) + }, TypeError) + + t.end() +}) diff --git a/test/fetch/response.js b/test/fetch/response.js new file mode 100644 index 0000000..422c7ef --- /dev/null +++ b/test/fetch/response.js @@ -0,0 +1,257 @@ +'use strict' + +const { test } = require('tap') +const { + Response +} = require('../../') +const { ReadableStream } = require('stream/web') +const { + Blob: ThirdPartyBlob, + FormData: ThirdPartyFormData +} = require('formdata-node') + +test('arg validation', async (t) => { + // constructor + t.throws(() => { + // eslint-disable-next-line + new Response(null, 0) + }, TypeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: 99 + }) + }, RangeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: 600 + }) + }, RangeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: '600' + }) + }, RangeError) + t.throws(() => { + // eslint-disable-next-line + new Response(null, { + statusText: '\u0000' + }) + }, TypeError) + + for (const nullStatus of [204, 205, 304]) { + t.throws(() => { + // eslint-disable-next-line + new Response(new ArrayBuffer(16), { + status: nullStatus + }) + }, TypeError) + } + + t.doesNotThrow(() => { + Response.prototype[Symbol.toStringTag].charAt(0) + }, TypeError) + + t.throws(() => { + Response.prototype.type.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.url.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.redirected.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.status.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.ok.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.statusText.toString() + }, TypeError) + + t.throws(() => { + Response.prototype.headers.toString() + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Response.prototype.body + }, TypeError) + + t.throws(() => { + // eslint-disable-next-line no-unused-expressions + Response.prototype.bodyUsed + }, TypeError) + + t.throws(() => { + Response.prototype.clone.call(null) + }, TypeError) + + await t.rejects(async () => { + await new Response('http://localhost').text.call({ + blob () { + return { + text () { + return Promise.resolve('emulating response.blob()') + } + } + } + }) + }, TypeError) + + t.end() +}) + +test('response clone', (t) => { + // https://github.com/nodejs/undici/issues/1122 + const response1 = new Response(null, { status: 201 }) + const response2 = new Response(undefined, { status: 201 }) + + t.equal(response1.body, response1.clone().body) + t.equal(response2.body, response2.clone().body) + t.equal(response2.body, null) + t.end() +}) + +test('Symbol.toStringTag', (t) => { + const resp = new Response() + + t.equal(resp[Symbol.toStringTag], 'Response') + t.equal(Response.prototype[Symbol.toStringTag], 'Response') + t.end() +}) + +test('async iterable body', async (t) => { + const asyncIterable = { + async * [Symbol.asyncIterator] () { + yield 'a' + yield 'b' + yield 'c' + } + } + + const response = new Response(asyncIterable) + t.equal(await response.text(), 'abc') + t.end() +}) + +// https://github.com/nodejs/node/pull/43752#issuecomment-1179678544 +test('Modifying headers using Headers.prototype.set', (t) => { + const response = new Response('body', { + headers: { + 'content-type': 'test/test', + 'Content-Encoding': 'hello/world' + } + }) + + const response2 = response.clone() + + response.headers.set('content-type', 'application/wasm') + response.headers.set('Content-Encoding', 'world/hello') + + t.equal(response.headers.get('content-type'), 'application/wasm') + t.equal(response.headers.get('Content-Encoding'), 'world/hello') + + response2.headers.delete('content-type') + response2.headers.delete('Content-Encoding') + + t.equal(response2.headers.get('content-type'), null) + t.equal(response2.headers.get('Content-Encoding'), null) + + t.end() +}) + +// https://github.com/nodejs/node/issues/43838 +test('constructing a Response with a ReadableStream body', { skip: process.version.startsWith('v16.') }, async (t) => { + const text = '{"foo":"bar"}' + const uint8 = new TextEncoder().encode(text) + + t.test('Readable stream with Uint8Array chunks', async (t) => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(uint8) + controller.close() + } + }) + + const response1 = new Response(readable) + const response2 = response1.clone() + const response3 = response1.clone() + + t.equal(await response1.text(), text) + t.same(await response2.arrayBuffer(), uint8.buffer) + t.same(await response3.json(), JSON.parse(text)) + + t.end() + }) + + t.test('Readable stream with non-Uint8Array chunks', async (t) => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(text) // string + controller.close() + } + }) + + const response = new Response(readable) + + await t.rejects(response.text(), TypeError) + + t.end() + }) + + t.test('Readable with ArrayBuffer chunk still throws', { skip: process.version.startsWith('v16.') }, async (t) => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(uint8.buffer) + controller.close() + } + }) + + const response1 = new Response(readable) + const response2 = response1.clone() + const response3 = response1.clone() + // const response4 = response1.clone() + + await t.rejects(response1.arrayBuffer(), TypeError) + await t.rejects(response2.text(), TypeError) + await t.rejects(response3.json(), TypeError) + // TODO: on Node v16.8.0, this throws a TypeError + // because the body is detected as disturbed. + // await t.rejects(response4.blob(), TypeError) + + t.end() + }) + + t.end() +}) + +test('constructing Response with third party Blob body', async (t) => { + const blob = new ThirdPartyBlob(['text']) + const res = new Response(blob) + t.equal(await res.text(), 'text') +}) +test('constructing Response with third party FormData body', async (t) => { + const form = new ThirdPartyFormData() + form.set('key', 'value') + const res = new Response(form) + const contentType = res.headers.get('content-type').split('=') + t.equal(contentType[0], 'multipart/form-data; boundary') + t.ok((await res.text()).startsWith(`--${contentType[1]}`)) +}) + +// https://github.com/nodejs/undici/issues/2465 +test('Issue#2465', async (t) => { + t.plan(1) + const response = new Response(new SharedArrayBuffer(0)) + t.equal(await response.text(), '[object SharedArrayBuffer]') +}) diff --git a/test/fetch/user-agent.js b/test/fetch/user-agent.js new file mode 100644 index 0000000..2e37ea5 --- /dev/null +++ b/test/fetch/user-agent.js @@ -0,0 +1,32 @@ +'use strict' + +const { test, skip } = require('tap') +const events = require('events') +const http = require('http') +const undici = require('../../') +const { nodeMajor } = require('../../lib/core/util') + +if (nodeMajor === 16) { + skip('esbuild uses static blocks with --keep-names which node 16.8 does not have') + process.exit() +} + +const nodeBuild = require('../../undici-fetch.js') + +test('user-agent defaults correctly', async (t) => { + const server = http.createServer((req, res) => { + res.end(JSON.stringify({ userAgentHeader: req.headers['user-agent'] })) + }) + t.teardown(server.close.bind(server)) + + server.listen(0) + await events.once(server, 'listening') + const url = `http://localhost:${server.address().port}` + const [nodeBuildJSON, undiciJSON] = await Promise.all([ + nodeBuild.fetch(url).then((body) => body.json()), + undici.fetch(url).then((body) => body.json()) + ]) + + t.same(nodeBuildJSON.userAgentHeader, 'node') + t.same(undiciJSON.userAgentHeader, 'undici') +}) diff --git a/test/fetch/util.js b/test/fetch/util.js new file mode 100644 index 0000000..02b75bc --- /dev/null +++ b/test/fetch/util.js @@ -0,0 +1,281 @@ +'use strict' + +const t = require('tap') +const { test } = t + +const util = require('../../lib/fetch/util') +const { HeadersList } = require('../../lib/fetch/headers') + +test('responseURL', (t) => { + t.plan(2) + + t.ok(util.responseURL({ + urlList: [ + new URL('http://asd'), + new URL('http://fgh') + ] + })) + t.notOk(util.responseURL({ + urlList: [] + })) +}) + +test('responseLocationURL', (t) => { + t.plan(3) + + const acceptHeaderList = new HeadersList() + acceptHeaderList.append('Accept', '*/*') + + const locationHeaderList = new HeadersList() + locationHeaderList.append('Location', 'http://asd') + + t.notOk(util.responseLocationURL({ + status: 200 + })) + t.notOk(util.responseLocationURL({ + status: 301, + headersList: acceptHeaderList + })) + t.ok(util.responseLocationURL({ + status: 301, + headersList: locationHeaderList, + urlList: [ + new URL('http://asd'), + new URL('http://fgh') + ] + })) +}) + +test('requestBadPort', (t) => { + t.plan(3) + + t.equal('allowed', util.requestBadPort({ + urlList: [new URL('https://asd')] + })) + t.equal('blocked', util.requestBadPort({ + urlList: [new URL('http://asd:7')] + })) + t.equal('blocked', util.requestBadPort({ + urlList: [new URL('https://asd:7')] + })) +}) + +// https://html.spec.whatwg.org/multipage/origin.html#same-origin +// look at examples +test('sameOrigin', (t) => { + t.test('first test', (t) => { + const A = { + protocol: 'https:', + hostname: 'example.org', + port: '' + } + + const B = { + protocol: 'https:', + hostname: 'example.org', + port: '' + } + + t.ok(util.sameOrigin(A, B)) + t.end() + }) + + t.test('second test', (t) => { + const A = { + protocol: 'https:', + hostname: 'example.org', + port: '314' + } + + const B = { + protocol: 'https:', + hostname: 'example.org', + port: '420' + } + + t.notOk(util.sameOrigin(A, B)) + t.end() + }) + + t.test('obviously shouldn\'t be equal', (t) => { + t.notOk(util.sameOrigin( + { protocol: 'http:', hostname: 'example.org' }, + { protocol: 'https:', hostname: 'example.org' } + )) + + t.notOk(util.sameOrigin( + { protocol: 'https:', hostname: 'example.org' }, + { protocol: 'https:', hostname: 'example.com' } + )) + + t.end() + }) + + t.test('file:// urls', (t) => { + // urls with opaque origins should return true + + const a = new URL('file:///C:/undici') + const b = new URL('file:///var/undici') + + t.ok(util.sameOrigin(a, b)) + t.end() + }) + + t.end() +}) + +test('isURLPotentiallyTrustworthy', (t) => { + const valid = ['http://127.0.0.1', 'http://localhost.localhost', + 'http://[::1]', 'http://adb.localhost', 'https://something.com', 'wss://hello.com', + 'file:///link/to/file.txt', 'data:text/plain;base64,randomstring', 'about:blank', 'about:srcdoc'] + const invalid = ['http://121.3.4.5:55', 'null:8080', 'something:8080'] + + t.plan(valid.length + invalid.length + 1) + t.notOk(util.isURLPotentiallyTrustworthy('string')) + + for (const url of valid) { + const instance = new URL(url) + t.ok(util.isURLPotentiallyTrustworthy(instance)) + } + + for (const url of invalid) { + const instance = new URL(url) + t.notOk(util.isURLPotentiallyTrustworthy(instance)) + } +}) + +test('setRequestReferrerPolicyOnRedirect', nested => { + nested.plan(7) + + nested.test('should set referrer policy from response headers on redirect', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'origin') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should select the first valid policy from a response', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'asdas, origin') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should select the first valid policy from a response#2', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'no-referrer, asdas, origin, 0943sd') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should pick the last fallback over invalid policy tokens', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'origin, asdas, asdaw34') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'origin') + }) + + nested.test('should set not change request referrer policy if no Referrer-Policy from initial redirect response', t => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, 'no-referrer, strict-origin-when-cross-origin') + }) + + nested.test('should set not change request referrer policy if the policy is a non-valid Referrer Policy', t => { + const initial = 'no-referrer, strict-origin-when-cross-origin' + const request = { + referrerPolicy: initial + } + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'asdasd') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, initial) + }) + + nested.test('should set not change request referrer policy if the policy is a non-valid Referrer Policy', t => { + const initial = 'no-referrer, strict-origin-when-cross-origin' + const request = { + referrerPolicy: initial + } + const actualResponse = { + headersList: new HeadersList() + } + + t.plan(1) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + actualResponse.headersList.append('Referrer-Policy', 'asdasd, asdasa, 12daw,') + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + t.equal(request.referrerPolicy, initial) + }) +}) |