diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js index 1022405d239..5b6b5bd79cf 100644 --- a/lib/mock/mock-utils.js +++ b/lib/mock/mock-utils.js @@ -424,7 +424,9 @@ function buildMockDispatch () { throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)${interceptsMessage}`) } if (checkNetConnect(netConnect, origin)) { - originalDispatch.call(this, opts, handler) + originalDispatch.call(this, '__mockAgentBodyForDispatch' in opts + ? { ...opts, body: opts.__mockAgentBodyForDispatch } + : opts, handler) } else { throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)${interceptsMessage}`) } diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index 33d7761cecd..1959f27c04a 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -2184,6 +2184,8 @@ async function httpNetworkFetch ( origin: url.origin, method: request.method, body: agent.isMockActive ? request.body && (request.body.source || request.body.stream) : body, + // Preserve the serialized fetch body for MockAgent net-connect fallthroughs. + __mockAgentBodyForDispatch: body, headers: request.headersList.entries, maxRedirections: 0, upgrade: request.mode === 'websocket' ? 'websocket' : undefined, diff --git a/test/fetch/issue-5241.js b/test/fetch/issue-5241.js new file mode 100644 index 00000000000..e7c5249c18e --- /dev/null +++ b/test/fetch/issue-5241.js @@ -0,0 +1,73 @@ +'use strict' + +const { createServer } = require('node:http') +const { once } = require('node:events') +const { test } = require('node:test') +const { + fetch, + FormData, + MockAgent, + getGlobalDispatcher, + setGlobalDispatcher +} = require('../..') + +// https://github.com/nodejs/undici/issues/5241 +test('MockAgent net connect keeps multipart boundaries in sync with fetch headers', async (t) => { + let requestBody = null + let contentType = null + + const server = createServer((req, res) => { + const chunks = [] + + req.on('data', chunk => { + chunks.push(chunk) + }) + + req.on('end', () => { + requestBody = Buffer.concat(chunks).toString('utf8') + contentType = req.headers['content-type'] + + res.writeHead(200, { + 'content-type': 'application/json' + }) + res.end('{}') + }) + }) + + t.after(() => { + server.closeAllConnections?.() + server.close() + }) + + await once(server.listen(0), 'listening') + + const previousDispatcher = getGlobalDispatcher() + const mockAgent = new MockAgent() + const origin = `http://localhost:${server.address().port}` + + mockAgent.enableNetConnect(`localhost:${server.address().port}`) + setGlobalDispatcher(mockAgent) + + t.after(async () => { + setGlobalDispatcher(previousDispatcher) + await mockAgent.close() + }) + + const form = new FormData() + form.append('file', new Blob([Buffer.from('hello world')], { type: 'text/plain' }), 'document.txt') + + const response = await fetch(origin, { + method: 'POST', + body: form + }) + + t.assert.strictEqual(response.status, 200) + t.assert.deepStrictEqual(await response.json(), {}) + + t.assert.ok(contentType.startsWith('multipart/form-data; boundary=')) + + const boundary = contentType.slice(contentType.indexOf('boundary=') + 'boundary='.length) + + t.assert.strictEqual(requestBody.split('\r\n', 1)[0], `--${boundary}`) + t.assert.ok(requestBody.includes(`\r\n--${boundary}--\r\n`)) +})