From 84337298e8ccf5d4f1c24a7c4a419ab9e98c27d0 Mon Sep 17 00:00:00 2001 From: Sid Date: Mon, 25 May 2026 20:05:50 +0530 Subject: [PATCH] fix: multipart/mixed and multipart/form-data interpolation and generic request behaviour (#8087) * fix: multipart spec additions and interpolation fixes * test(cli): add interpolation multipart tests * Update packages/bruno-tests/collection/multipart/multipart-mixed-form-data-parse.bru Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: remove assert as curl also gives the same result * chore: codestyle --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../bruno-cli/src/runner/interpolate-vars.js | 4 +- .../src/runner/run-single-request.js | 2 +- .../tests/runner/interpolate-vars.spec.js | 54 +++++ .../bruno-electron/src/ipc/network/index.js | 2 +- .../src/ipc/network/interpolate-vars.js | 4 +- .../tests/network/interpolate-vars.spec.js | 194 ++++++++++++++++++ .../content-types-mixed-interpolation.bru | 34 +++ .../multipart-mixed-form-data-file.bru | 20 ++ .../multipart-mixed-form-data-parse.bru | 23 +++ 9 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 packages/bruno-tests/collection/multipart/content-types-mixed-interpolation.bru create mode 100644 packages/bruno-tests/collection/multipart/multipart-mixed-form-data-file.bru create mode 100644 packages/bruno-tests/collection/multipart/multipart-mixed-form-data-parse.bru diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index 0b19e0a79..c54e516fd 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -100,7 +100,9 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc })); } } else if (contentType.startsWith('multipart/')) { - if (Array.isArray(request?.data) && !isFormData(request.data)) { + if (request?.data && typeof request.data === 'string') { + request.data = _interpolate(request.data); + } else if (Array.isArray(request?.data) && !isFormData(request.data)) { try { request.data = request?.data?.map((d) => ({ ...d, diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 0fdda11ec..0beacf692 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -488,7 +488,7 @@ const runSingleRequest = async function ( const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : ''; if (typeof contentType === 'string' && contentType.startsWith('multipart/')) { - if (!isFormData(request?.data)) { + if (typeof request.data !== 'string' && !isFormData(request?.data)) { request._originalMultipartData = request.data; request.collectionPath = collectionPath; let form = createFormData(request.data, collectionPath); diff --git a/packages/bruno-cli/tests/runner/interpolate-vars.spec.js b/packages/bruno-cli/tests/runner/interpolate-vars.spec.js index 7349b5bdd..631d6aeaf 100644 --- a/packages/bruno-cli/tests/runner/interpolate-vars.spec.js +++ b/packages/bruno-cli/tests/runner/interpolate-vars.spec.js @@ -1,6 +1,60 @@ const { describe, it, expect } = require('@jest/globals'); const interpolateVars = require('../../src/runner/interpolate-vars'); +describe('interpolate-vars: interpolateVars', () => { + it('preserves raw string body when Content-Type is multipart/mixed', () => { + const rawMultipartBody = [ + '--TestBoundary123', + 'Content-Type: application/json', + '', + '{"test": true}', + '--TestBoundary123--', + '' + ].join('\r\n'); + + const request = { + method: 'POST', + mode: 'text', + url: 'https://httpbin.dev/post', + headers: { 'content-type': 'multipart/mixed; boundary=TestBoundary123' }, + data: rawMultipartBody + }; + + const result = interpolateVars(request, {}, null, null); + expect(result.data).toBe(rawMultipartBody); + }); + + it('interpolates variables in raw multipart/mixed string body', () => { + const boundary = 'CustomBoundary123'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: text/plain', + '', + 'Token: {{token}}', + `--${boundary}`, + 'Content-Type: application/json', + '', + '{"id": "{{id}}", "msg": "{{msg}}"}', + `--${boundary}--`, + '' + ].join('\r\n'); + + const request = { + method: 'POST', + mode: 'text', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + + const result = interpolateVars(request, { token: 'abc123', id: 42, msg: 'hello' }, null, null); + expect(result.data).toContain('Token: abc123'); + expect(result.data).toContain('{"id": "42", "msg": "hello"}'); + expect(result.data).toContain(`--${boundary}`); + expect(result.data).toContain(`--${boundary}--`); + }); +}); + describe('interpolate-vars: api key header name sidecar', () => { it('interpolates apiKeyHeaderName in lockstep with interpolated header keys', () => { const request = { diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 1445a0979..a975765b2 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -608,7 +608,7 @@ const registerNetworkIpc = (mainWindow) => { const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : ''; if (typeof contentType === 'string' && contentType.startsWith('multipart/')) { - if (!isFormData(request.data)) { + if (typeof request.data !== 'string' && !isFormData(request.data)) { request._originalMultipartData = request.data; request.collectionPath = collectionPath; let form = createFormData(request.data, collectionPath); diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index a90cc74a5..89166b070 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -137,7 +137,9 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc })); } } else if (contentType.startsWith('multipart/')) { - if (Array.isArray(request?.data) && !isFormData(request.data)) { + if (request?.data && typeof request.data === 'string') { + request.data = _interpolate(request.data); + } else if (Array.isArray(request?.data) && !isFormData(request.data)) { try { request.data = request?.data?.map((d) => ({ ...d, diff --git a/packages/bruno-electron/tests/network/interpolate-vars.spec.js b/packages/bruno-electron/tests/network/interpolate-vars.spec.js index 2eea40a93..294b8f62f 100644 --- a/packages/bruno-electron/tests/network/interpolate-vars.spec.js +++ b/packages/bruno-electron/tests/network/interpolate-vars.spec.js @@ -425,5 +425,199 @@ describe('interpolate-vars: interpolateVars', () => { expect(result.data).toContain('{"test": true}'); expect(result.data).toContain('--TestBoundary123--'); }); + + it('interpolates variables in text-based multipart/mixed body with manual boundaries', () => { + // User manually constructs a multipart/mixed body as a string + const boundary = 'CustomBoundary123'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: text/plain', + '', + 'Token: {{token}}', + `--${boundary}`, + 'Content-Type: application/json', + '', + '{"id": "{{id}}", "msg": "{{msg}}"}', + `--${boundary}--`, + '' + ].join('\r\n'); + + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + + const result = interpolateVars(request, { token: 'abc123', id: 42, msg: 'hello' }, null, null); + + expect(result.data).toContain('Token: abc123'); + expect(result.data).toContain('{"id": "42", "msg": "hello"}'); + // Ensure boundaries are preserved + expect(result.data).toContain(`--${boundary}`); + expect(result.data).toContain(`--${boundary}--`); + }); + + it('interpolates variables in boundary lines themselves', () => { + const boundaryVar = 'BoundaryVar'; + const rawMultipartBody = [ + `--{{boundary}}`, + 'Content-Type: text/plain', + '', + 'Hello', + `--{{boundary}}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': 'multipart/mixed; boundary={{boundary}}' }, + data: rawMultipartBody + }; + const result = interpolateVars(request, { boundary: boundaryVar }, null, null); + expect(result.data).toContain(`--${boundaryVar}`); + expect(result.data).toContain(`--${boundaryVar}--`); + }); + + it('interpolates variables that resolve to empty string or undefined', () => { + const boundary = 'B'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: text/plain', + '', + 'Token: {{missingVar}}', + `--${boundary}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + const result = interpolateVars(request, {} /* no missingVar */, null, null); + expect(result.data).toContain('Token: '); + }); + + it('interpolates multiple variables in a single line or JSON object', () => { + const boundary = 'B2'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: application/json', + '', + '{"id": "{{id}}", "msg": "{{msg}}", "extra": "{{extra}}"}', + `--${boundary}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + const result = interpolateVars(request, { id: 1, msg: 'hi', extra: 'x' }, null, null); + expect(result.data).toContain('"id": "1", "msg": "hi", "extra": "x"'); + }); + + it('interpolates variables inside quoted and unquoted contexts', () => { + const boundary = 'B3'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="{{fieldName}}"', + '', + 'Value', + `--${boundary}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + const result = interpolateVars(request, { fieldName: 'theField' }, null, null); + expect(result.data).toContain('name="theField"'); + }); + + it('interpolates variables in both part headers and part bodies', () => { + const boundary = 'B4'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: text/plain; charset={{charset}}', + '', + 'Token: {{token}}', + `--${boundary}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + const result = interpolateVars(request, { charset: 'utf-8', token: 'abc' }, null, null); + expect(result.data).toContain('charset=utf-8'); + expect(result.data).toContain('Token: abc'); + }); + + it('interpolates variables in the final boundary line', () => { + const boundary = 'B5'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: text/plain', + '', + 'End', + `--{{finalBoundary}}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + const result = interpolateVars(request, { finalBoundary: boundary }, null, null); + expect(result.data).toContain(`--${boundary}--`); + }); + + it('interpolates variables that appear multiple times in the body', () => { + const boundary = 'B6'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: text/plain', + '', + 'Token: {{token}}, Again: {{token}}', + `--${boundary}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + const result = interpolateVars(request, { token: 'repeat' }, null, null); + expect(result.data.match(/repeat/g).length).toBe(2); + }); + + it('leaves body unchanged if no variables present', () => { + const boundary = 'B7'; + const rawMultipartBody = [ + `--${boundary}`, + 'Content-Type: text/plain', + '', + 'No variables here', + `--${boundary}--`, + '' + ].join('\r\n'); + const request = { + method: 'POST', + url: 'https://api.example/send', + headers: { 'content-type': `multipart/mixed; boundary=${boundary}` }, + data: rawMultipartBody + }; + const result = interpolateVars(request, {}, null, null); + expect(result.data).toBe(rawMultipartBody); + }); }); }); diff --git a/packages/bruno-tests/collection/multipart/content-types-mixed-interpolation.bru b/packages/bruno-tests/collection/multipart/content-types-mixed-interpolation.bru new file mode 100644 index 000000000..0052b5c62 --- /dev/null +++ b/packages/bruno-tests/collection/multipart/content-types-mixed-interpolation.bru @@ -0,0 +1,34 @@ +meta { + name: content-types-mixed-interpolation + type: http + seq: 1 +} + +post { + url: {{echo-host}} + body: text + auth: inherit +} + +body:text { + ------MyCustomBoundaryString + Content-Disposition: form-data; name="metadata" + Content-Type: application/json + + {{version}} + + ------MyCustomBoundaryString-- +} + +vars:pre-request { + version: 0.0.1 +} + +assert { + res.body: contains 0.0.1 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/packages/bruno-tests/collection/multipart/multipart-mixed-form-data-file.bru b/packages/bruno-tests/collection/multipart/multipart-mixed-form-data-file.bru new file mode 100644 index 000000000..0da260b05 --- /dev/null +++ b/packages/bruno-tests/collection/multipart/multipart-mixed-form-data-file.bru @@ -0,0 +1,20 @@ +meta { + name: multipart-mixed-form-data-file + type: http + seq: 3 +} + +post { + url: {{echo-host}} + body: multipartForm + auth: none +} + +body:multipart-form { + sample: @file(bruno.png) @contentType(image/png) +} + +assert { + res.body: matches ^[-]+[a-z0-9]+ + res.body: contains Content-Type: image/png +} diff --git a/packages/bruno-tests/collection/multipart/multipart-mixed-form-data-parse.bru b/packages/bruno-tests/collection/multipart/multipart-mixed-form-data-parse.bru new file mode 100644 index 000000000..f25bf3b42 --- /dev/null +++ b/packages/bruno-tests/collection/multipart/multipart-mixed-form-data-parse.bru @@ -0,0 +1,23 @@ +meta { + name: multipart-mixed-form-data-parse + type: http + seq: 1 +} + +post { + url: {{echo-host}} + body: multipartForm + auth: none +} + +headers { + Content-Type: multipart/mixed +} + +body:multipart-form { + sample: sample +} + +assert { + res.body: matches ^[-]+[a-z0-9]+ +}