diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index 7f76aab14..fa531082f 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -102,7 +102,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 0479ad951..b7f6a3054 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -487,7 +487,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 4f39185c3..ce105c67b 100644 --- a/packages/bruno-cli/tests/runner/interpolate-vars.spec.js +++ b/packages/bruno-cli/tests/runner/interpolate-vars.spec.js @@ -18,6 +18,58 @@ describe('interpolate-vars: interpolateVars', () => { const result = interpolateVars(request, { shouldNotApply: 'value' }, null, null); expect(result.data).toBe(streamPayload); }); + + 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', () => { diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 73fbf34d1..ca9a28c55 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -607,7 +607,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 81e170e5d..7497926e7 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -140,7 +140,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 48e1a9f9d..9622e8f6d 100644 --- a/packages/bruno-electron/tests/network/interpolate-vars.spec.js +++ b/packages/bruno-electron/tests/network/interpolate-vars.spec.js @@ -425,6 +425,200 @@ 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); + }); }); describe('File body streaming', () => { 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]+ +}