diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 44ad9e54e..9a3c7a5e7 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -26,7 +26,7 @@ const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosIn const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests'); const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2'); const tokenStore = require('../store/tokenStore'); -const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils; +const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils; const onConsoleLog = (type, args) => { console[type](...args); @@ -570,7 +570,12 @@ const runSingleRequest = async function ( if (contentType !== 'multipart/form-data') { // Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched const formHeaders = form.getHeaders(); - formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`; + const existingBoundary = extractBoundaryFromContentType(contentType); + if (existingBoundary) { + formHeaders['content-type'] = contentType; + } else { + formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`; + } form.getHeaders = function () { return formHeaders; }; diff --git a/packages/bruno-common/src/utils/form-data.spec.ts b/packages/bruno-common/src/utils/form-data.spec.ts index 76fe581c6..a57635694 100644 --- a/packages/bruno-common/src/utils/form-data.spec.ts +++ b/packages/bruno-common/src/utils/form-data.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from '@jest/globals'; -import { buildFormUrlEncodedPayload, isFormData } from './form-data'; +import { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } from './form-data'; import FormData from 'form-data'; describe('buildFormUrlEncodedPayload', () => { @@ -161,3 +161,51 @@ describe('isFormData', () => { expect(isFormData(formData)).toBe(true); }); }); + +describe('extractBoundaryFromContentType', () => { + it('should extract boundary from Content-Type header', () => { + expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary')).toBe('my-boundary'); + }); + + it('should extract boundary with dashes', () => { + expect(extractBoundaryFromContentType('multipart/mixed; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW')).toBe('----WebKitFormBoundary7MA4YWxkTrZu0gW'); + }); + + it('should extract boundary case-insensitively', () => { + expect(extractBoundaryFromContentType('multipart/mixed; BOUNDARY=my-boundary')).toBe('my-boundary'); + expect(extractBoundaryFromContentType('multipart/mixed; Boundary=my-boundary')).toBe('my-boundary'); + }); + + it('should extract boundary when other params exist', () => { + expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary=my-boundary')).toBe('my-boundary'); + expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary; charset=utf-8')).toBe('my-boundary'); + }); + + it('should return null when no boundary exists', () => { + expect(extractBoundaryFromContentType('multipart/mixed')).toBeNull(); + expect(extractBoundaryFromContentType('application/json')).toBeNull(); + }); + + it('should return null for non-string input', () => { + expect(extractBoundaryFromContentType(null)).toBeNull(); + expect(extractBoundaryFromContentType(undefined)).toBeNull(); + expect(extractBoundaryFromContentType(123)).toBeNull(); + expect(extractBoundaryFromContentType({})).toBeNull(); + }); + + it('should handle empty string', () => { + expect(extractBoundaryFromContentType('')).toBeNull(); + }); + + it('should extract boundary from quoted value', () => { + expect(extractBoundaryFromContentType('multipart/mixed; boundary="my-boundary"')).toBe('my-boundary'); + }); + + it('should extract quoted boundary with spaces', () => { + expect(extractBoundaryFromContentType('multipart/mixed; boundary="my boundary value"')).toBe('my boundary value'); + }); + + it('should extract quoted boundary when other params exist', () => { + expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary="my-boundary"')).toBe('my-boundary'); + }); +}); diff --git a/packages/bruno-common/src/utils/form-data.ts b/packages/bruno-common/src/utils/form-data.ts index 84cd7f333..59d3af91b 100644 --- a/packages/bruno-common/src/utils/form-data.ts +++ b/packages/bruno-common/src/utils/form-data.ts @@ -43,3 +43,16 @@ export const isFormData = (obj: unknown): boolean => { // todo: checking constructor.name can produce false positives for objects that have a constructor.name property set to 'FormData', but this is rare. return obj?.constructor?.name === 'FormData'; }; + +/** + * Extracts boundary parameter from a Content-Type header value. + * @param contentType - The Content-Type header value (e.g., "multipart/mixed; boundary=my-boundary") + * @returns The boundary value if found, or null if not present + */ +export const extractBoundaryFromContentType = (contentType: unknown): string | null => { + if (typeof contentType !== 'string') { + return null; + } + const match = contentType.match(/boundary="([^"]+)"|boundary=([^;\s]+)/i); + return match ? (match[1] || match[2]) : null; +}; diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts index 0527715df..3c76116dd 100644 --- a/packages/bruno-common/src/utils/index.ts +++ b/packages/bruno-common/src/utils/index.ts @@ -7,7 +7,8 @@ export { export { buildFormUrlEncodedPayload, - isFormData + isFormData, + extractBoundaryFromContentType } from './form-data'; export { diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index c0267237b..0c844ac04 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -35,7 +35,7 @@ const { cookiesStore } = require('../../store/cookies'); const registerGrpcEventHandlers = require('./grpc-event-handlers'); const { registerWsEventHandlers } = require('./ws-event-handlers'); const { getCertsAndProxyConfig, buildCertsAndProxyConfig } = require('./cert-utils'); -const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils; +const { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils; const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!'; @@ -609,7 +609,12 @@ const registerNetworkIpc = (mainWindow) => { if (contentType !== 'multipart/form-data') { // Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched const formHeaders = form.getHeaders(); - formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`; + const existingBoundary = extractBoundaryFromContentType(contentType); + if (existingBoundary) { + formHeaders['content-type'] = contentType; + } else { + formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`; + } form.getHeaders = function () { return formHeaders; }; diff --git a/packages/bruno-tests/src/echo/index.js b/packages/bruno-tests/src/echo/index.js index 044065536..f17197b0a 100644 --- a/packages/bruno-tests/src/echo/index.js +++ b/packages/bruno-tests/src/echo/index.js @@ -5,6 +5,15 @@ router.get('/path/*', (req, res) => { return res.json({ url: req.url }); }); +// Echo back request headers - useful for testing header manipulation +router.all('/headers', (req, res) => { + return res.json({ + method: req.method, + headers: req.headers, + url: req.url + }); +}); + router.post('/json', (req, res) => { return res.json(req.body); }); diff --git a/tests/request/multipart-boundary/multipart-boundary.spec.ts b/tests/request/multipart-boundary/multipart-boundary.spec.ts new file mode 100644 index 000000000..8f7b193c2 --- /dev/null +++ b/tests/request/multipart-boundary/multipart-boundary.spec.ts @@ -0,0 +1,177 @@ +import { test, expect } from '../../../playwright'; +import { + createCollection, + createRequest, + openRequest, + closeAllCollections, + selectRequestPaneTab, + sendRequest +} from '../../utils/page'; +import { buildCommonLocators, getTableCell } from '../../utils/page/locators'; + +const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + +/** + * E2E test for multipart/mixed boundary preservation + * Regression test for: https://github.com/usebruno/bruno/issues/7523 + * + * When a user specifies a boundary parameter in their Content-Type header + * for multipart/mixed requests with TEXT body mode, Bruno should preserve + * the user-defined boundary instead of generating a new one. + */ +test.describe.serial('Multipart boundary preservation', () => { + const collectionName = 'multipart-boundary-test'; + const requestName = 'Boundary Test'; + const testServerUrl = 'http://localhost:8081/headers'; + const customBoundary = 'my-custom-boundary-12345'; + + test.beforeAll(async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('multipart-boundary-collection'); + await createCollection(page, collectionName, collectionPath); + await createRequest(page, requestName, collectionName, { + url: testServerUrl, + method: 'GET' + }); + }); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('should preserve user-defined boundary in multipart/mixed Content-Type header', async ({ page }) => { + const locators = buildCommonLocators(page); + + await test.step('Open request and configure headers', async () => { + await openRequest(page, collectionName, requestName); + + // Go to Headers tab and add Content-Type header with custom boundary + await selectRequestPaneTab(page, 'Headers'); + + // Find the first row in headers table (empty row for adding new headers) + const headerRow = page.locator('table tbody tr').first(); + await headerRow.waitFor({ state: 'visible' }); + + // Get the name cell (first column after checkbox) and enter header name + const nameCell = getTableCell(headerRow, 0); + await nameCell.locator('.CodeMirror').click(); + await nameCell.locator('textarea').fill('Content-Type'); + + // Get the value cell (second column) and enter header value with custom boundary + const valueCell = getTableCell(headerRow, 1); + await valueCell.locator('.CodeMirror').click(); + await valueCell.locator('textarea').fill(`multipart/mixed; boundary=${customBoundary}`); + }); + + await test.step('Set body to TEXT mode with multipart content', async () => { + await selectRequestPaneTab(page, 'Body'); + + // Select Text body mode + await locators.request.bodyModeSelector().click(); + await locators.dropdown.item('Text').click(); + + // Enter multipart body content using the custom boundary + const bodyCodeMirror = locators.request.bodyEditor().locator('.CodeMirror'); + await bodyCodeMirror.click(); + await page.keyboard.press(selectAllShortcut); + + const multipartBody = `--${customBoundary}\r +Content-Disposition: form-data; name="field1"\r +\r +value1\r +--${customBoundary}--`; + + await page.keyboard.type(multipartBody); + }); + + await test.step('Send request and verify boundary is preserved', async () => { + // Use longer timeout for external service (httpbin.org) + await sendRequest(page, 200, 30000); + + // httpbin.org/post returns request headers in the response JSON + // We need to verify the Content-Type header contains our custom boundary + // and NOT a duplicate auto-generated boundary + const responseBody = locators.response.previewContainer(); + + // Verify the response contains our custom boundary + await expect(responseBody).toContainText(customBoundary, { timeout: 10000 }); + + // Verify there's only one boundary parameter (not duplicated) + // The response should show: "Content-Type": "multipart/mixed; boundary=my-custom-boundary-12345" + const responseText = await responseBody.innerText(); + + // Count occurrences of "boundary=" - should be exactly 1 (not duplicated) + const boundaryMatches = responseText.match(/boundary=/gi); + expect(boundaryMatches).not.toBeNull(); + expect(boundaryMatches?.length).toBe(1); + }); + }); + + test('should auto-generate boundary when none is specified in Content-Type header', async ({ page }) => { + const locators = buildCommonLocators(page); + const requestNameNoBoundary = 'No Boundary Test'; + + await test.step('Create a new request without boundary', async () => { + await createRequest(page, requestNameNoBoundary, collectionName, { + url: testServerUrl, + method: 'GET' + }); + }); + + await test.step('Open request and configure headers without boundary', async () => { + await openRequest(page, collectionName, requestNameNoBoundary); + + // Go to Headers tab and add Content-Type header WITHOUT boundary + await selectRequestPaneTab(page, 'Headers'); + + const headerRow = page.locator('table tbody tr').first(); + await headerRow.waitFor({ state: 'visible' }); + + const nameCell = getTableCell(headerRow, 0); + await nameCell.locator('.CodeMirror').click(); + await nameCell.locator('textarea').fill('Content-Type'); + + // Set Content-Type to multipart/mixed WITHOUT specifying a boundary + const valueCell = getTableCell(headerRow, 1); + await valueCell.locator('.CodeMirror').click(); + await valueCell.locator('textarea').fill('multipart/mixed'); + }); + + await test.step('Set body to Multipart Form mode and add a field', async () => { + await selectRequestPaneTab(page, 'Body'); + + // Select Multipart Form body mode so Bruno has data to create FormData from + await locators.request.bodyModeSelector().click(); + await locators.dropdown.item('Multipart Form').click(); + + // Wait for the body editor to switch to multipart form mode + await page.waitForTimeout(500); + + // The multipart form has an editable table - find and fill the first row + // The name column has placeholder "Key" (defined in MultipartFormParams columns) + const nameInput = page.locator('[data-testid="editable-table"] input[placeholder="Key"]').first(); + await nameInput.waitFor({ state: 'visible', timeout: 5000 }); + await nameInput.click(); + await nameInput.fill('testField'); + + // Tab to value and fill it + await page.keyboard.press('Tab'); + await page.keyboard.type('testValue'); + }); + + await test.step('Send request and verify boundary was auto-generated', async () => { + await sendRequest(page, 200, 30000); + + const responseBody = locators.response.previewContainer(); + const responseText = await responseBody.innerText(); + + // Verify that a boundary parameter exists (was auto-generated) + const boundaryMatches = responseText.match(/boundary=/gi); + expect(boundaryMatches).not.toBeNull(); + expect(boundaryMatches?.length).toBe(1); + + // Verify the Content-Type contains multipart/mixed with a boundary + await expect(responseBody).toContainText('multipart/mixed', { timeout: 5000 }); + await expect(responseBody).toContainText('boundary=', { timeout: 5000 }); + }); + }); +});