diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js index ea4f35b69..cc79bf3cb 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js @@ -1,12 +1,29 @@ -import { interpolate } from '@usebruno/common'; +import { interpolate, interpolateObject } from '@usebruno/common'; import { cloneDeep } from 'lodash'; +export const interpolateAuth = (auth, variables = {}) => { + if (!auth) return auth; + return interpolateObject(auth, variables); +}; + export const interpolateHeaders = (headers = [], variables = {}) => { - return headers.map((header) => ({ - ...header, - name: interpolate(header.name, variables), - value: interpolate(header.value, variables) - })); + if (!headers) return []; + return headers.map((header) => { + if (header.enabled) { + return interpolateObject(header, variables); + } + return header; + }); +}; + +export const interpolateParams = (params = [], variables = {}) => { + if (!params) return []; + return params.map((param) => { + if (param.enabled) { + return interpolateObject(param, variables); + } + return param; + }); }; export const interpolateBody = (body, variables = {}) => { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js index b174bf380..212e6b68f 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js @@ -1,48 +1,139 @@ -import { interpolateHeaders, interpolateBody } from './interpolation'; +import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation'; describe('interpolation utils', () => { + describe('interpolateAuth', () => { + it('should interpolate auth object', () => { + const auth = { + mode: 'basic', + basic: { + username: '{{user}}', + password: '{{pass}}' + } + }; + const variables = { user: 'admin', pass: 'secret' }; + + const result = interpolateAuth(auth, variables); + + expect(result).toEqual({ + mode: 'basic', + basic: { + username: 'admin', + password: 'secret' + } + }); + }); + + it('should return null for null auth', () => { + expect(interpolateAuth(null, {})).toBeNull(); + }); + + it('should return undefined for undefined auth', () => { + expect(interpolateAuth(undefined, {})).toBeUndefined(); + }); + }); + describe('interpolateHeaders', () => { - it('should interpolate variables in header name and value while preserving other props', () => { + it('should interpolate header names and values', () => { const headers = [ - { uid: '1', name: 'X-{{var}}', value: 'value-{{var}}', enabled: true } + { name: 'X-{{headerName}}', value: '{{headerValue}}', enabled: true }, + { name: 'Content-Type', value: 'application/json', enabled: true } ]; - const variables = { var: 'test' }; + const variables = { headerName: 'Custom', headerValue: 'test-value' }; const result = interpolateHeaders(headers, variables); + expect(result).toEqual([ - { - uid: '1', - name: 'X-test', - value: 'value-test', - enabled: true - } + { name: 'X-Custom', value: 'test-value', enabled: true }, + { name: 'Content-Type', value: 'application/json', enabled: true } ]); }); + + it('should return empty array for empty headers', () => { + expect(interpolateHeaders([], {})).toEqual([]); + }); }); describe('interpolateBody', () => { - it('should interpolate JSON body strings and keep formatting', () => { + it('should return null for null body', () => { + expect(interpolateBody(null, {})).toBeNull(); + }); + + it('should interpolate JSON body with escaping', () => { const body = { mode: 'json', - json: '{"name": "{{username}}"}' + json: '{"name": "{{name}}", "count": {{count}}}' }; - const variables = { username: 'bruno' }; + const variables = { name: 'Test', count: 42 }; const result = interpolateBody(body, variables); - expect(result.json).toBe('{\n "name": "bruno"\n}'); + + expect(result.mode).toBe('json'); + expect(JSON.parse(result.json)).toEqual({ name: 'Test', count: 42 }); }); it('should interpolate text body', () => { - const body = { - mode: 'text', - text: 'Hello {{name}}' - }; + const body = { mode: 'text', text: 'Hello {{name}}' }; const result = interpolateBody(body, { name: 'World' }); expect(result.text).toBe('Hello World'); }); - it('should return null when body is null', () => { - expect(interpolateBody(null, { a: 1 })).toBeNull(); + it('should interpolate xml body', () => { + const body = { mode: 'xml', xml: '{{name}}' }; + const result = interpolateBody(body, { name: 'Alice' }); + expect(result.xml).toBe('Alice'); + }); + + it('should interpolate formUrlEncoded body for enabled params only', () => { + const body = { + mode: 'formUrlEncoded', + formUrlEncoded: [ + { name: 'key1', value: '{{val1}}', enabled: true }, + { name: 'key2', value: '{{val2}}', enabled: false } + ] + }; + const variables = { val1: 'value1', val2: 'value2' }; + + const result = interpolateBody(body, variables); + + expect(result.formUrlEncoded[0].value).toBe('value1'); + expect(result.formUrlEncoded[1].value).toBe('{{val2}}'); + }); + + it('should interpolate multipartForm body for enabled text params only', () => { + const body = { + mode: 'multipartForm', + multipartForm: [ + { name: 'field1', value: '{{val}}', type: 'text', enabled: true }, + { name: 'field2', value: '{{val}}', type: 'file', enabled: true } + ] + }; + const variables = { val: 'interpolated' }; + + const result = interpolateBody(body, variables); + + expect(result.multipartForm[0].value).toBe('interpolated'); + expect(result.multipartForm[1].value).toBe('{{val}}'); + }); + }); + + describe('interpolateParams', () => { + it('should interpolate param names and values', () => { + const params = [ + { name: '{{paramName}}', value: '{{paramValue}}', enabled: true }, + { name: 'static', value: '{{val}}', enabled: false } + ]; + const variables = { paramName: 'key', paramValue: 'value', val: 'skipped' }; + + const result = interpolateParams(params, variables); + + expect(result[0].name).toBe('key'); + expect(result[0].value).toBe('value'); + expect(result[1].name).toBe('static'); + expect(result[1].value).toBe('{{val}}'); + }); + + it('should return empty array for empty params', () => { + expect(interpolateParams([], {})).toEqual([]); }); }); }); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js index f52faf118..b7a573baf 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -1,8 +1,7 @@ import { buildHarRequest } from 'utils/codegenerator/har'; import { getAuthHeaders } from 'utils/codegenerator/auth'; import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index'; -import { interpolateHeaders, interpolateBody } from './interpolation'; -import { get } from 'lodash'; +import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation'; const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => { try { @@ -10,26 +9,27 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false const { HTTPSnippet } = require('httpsnippet'); const variables = getAllVariables(collection, item); - const request = item.request; // Get the request tree path and merge headers const requestTreePath = getTreePathFromCollectionToItem(collection, item); let headers = mergeHeaders(collection, request, requestTreePath); - // Add auth headers if needed + // Add auth headers if needed (auth inheritance is resolved upstream) if (request.auth && request.auth.mode !== 'none') { - const collectionAuth = collection?.draft?.root ? get(collection, 'draft.root.request.auth', null) : get(collection, 'root.request.auth', null); - const authHeaders = getAuthHeaders(collectionAuth, request.auth, collection, item); + if (shouldInterpolate) { + request.auth = interpolateAuth(request.auth, variables); + } + + const authHeaders = getAuthHeaders(request.auth, collection, item); headers = [...headers, ...authHeaders]; } - // Interpolate headers and body if needed + // Interpolate headers, body and params if needed if (shouldInterpolate) { headers = interpolateHeaders(headers, variables); - if (request.body) { - request.body = interpolateBody(request.body, variables); - } + request.body = interpolateBody(request.body, variables); + request.params = interpolateParams(request.params, variables); } // Build HAR request diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js index 0946ce2bc..3c7e2c5ef 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -1,3 +1,5 @@ +import { getAuthHeaders } from 'utils/codegenerator/auth'; + jest.mock('httpsnippet', () => { return { HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({ @@ -56,7 +58,9 @@ jest.mock('utils/collections/index', () => { ...collection?.processEnvVariables, baseUrl: 'https://api.example.com', apiKey: 'secret-key-123', - userId: '12345' + userId: '12345', + user: 'admin', + pass: 'secret123' })), getTreePathFromCollectionToItem: jest.fn(() => []) }; @@ -426,6 +430,55 @@ describe('Snippet Generator - Simple Tests', () => { expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\''); }); + + it('should interpolate auth credentials correctly', () => { + // Auth inheritance is resolved upstream in index.js before calling generateSnippet + // So the item already has the resolved auth (not 'inherit' mode) + const item = { + request: { + method: 'GET', + url: 'https://api.example.com', + auth: { + mode: 'basic', + basic: { + username: '{{user}}', + password: '{{pass}}' + } + } + } + }; + + const collection = { + root: { + request: { + auth: { mode: 'none' } + } + } + }; + + const { HTTPSnippet: mockedHTTPSnippet } = require('httpsnippet'); + const { getAuthHeaders: actualGetAuthHeaders } = jest.requireActual('utils/codegenerator/auth'); + getAuthHeaders.mockImplementation(actualGetAuthHeaders); + + const language = { target: 'shell', client: 'curl' }; + + generateSnippet({ + language, + item, + collection, + shouldInterpolate: true + }); + + const harRequest = mockedHTTPSnippet.mock.calls[0][0]; + + // "admin:secret123" encoded is "YWRtaW46c2VjcmV0MTIz" + expect(harRequest.headers).toContainEqual( + expect.objectContaining({ + name: 'Authorization', + value: 'Basic YWRtaW46c2VjcmV0MTIz' + }) + ); + }); }); // Snippet should include inherited headers @@ -563,7 +616,7 @@ describe('generateSnippet with OAuth2 authentication', () => { jest.clearAllMocks(); // Mock getAuthHeaders to return OAuth2 headers based on the auth config const authUtils = require('utils/codegenerator/auth'); - authUtils.getAuthHeaders.mockImplementation((collectionRootAuth, requestAuth, collection = null, item = null) => { + authUtils.getAuthHeaders.mockImplementation((requestAuth, collection = null, item = null) => { if (requestAuth?.mode === 'oauth2') { const oauth2Config = requestAuth.oauth2 || {}; const tokenPlacement = oauth2Config.tokenPlacement || 'header'; diff --git a/packages/bruno-app/src/utils/codegenerator/auth.js b/packages/bruno-app/src/utils/codegenerator/auth.js index eefb82cfe..51da6d3c9 100644 --- a/packages/bruno-app/src/utils/codegenerator/auth.js +++ b/packages/bruno-app/src/utils/codegenerator/auth.js @@ -3,21 +3,16 @@ import { find } from 'lodash'; import { interpolate } from '@usebruno/common'; import { getAllVariables } from 'utils/collections/index'; -export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = null, item = null) => { - // Discovered edge case where code generation fails when you create a collection which has not been saved yet: - // Collection auth therefore null, and request inherits from collection, therefore it is also null - // TypeError: Cannot read properties of undefined (reading 'mode') - // at getAuthHeaders - if (!collectionRootAuth && !requestAuth) { +export const getAuthHeaders = (requestAuth, collection = null, item = null) => { + // Auth inheritance is resolved upstream, so requestAuth should never have mode 'inherit' + if (!requestAuth) { return []; } - const auth = collectionRootAuth && ['inherit'].includes(requestAuth?.mode) ? collectionRootAuth : requestAuth; - - switch (auth.mode) { + switch (requestAuth.mode) { case 'basic': - const username = get(auth, 'basic.username', ''); - const password = get(auth, 'basic.password', ''); + const username = get(requestAuth, 'basic.username', ''); + const password = get(requestAuth, 'basic.password', ''); const basicToken = Buffer.from(`${username}:${password}`).toString('base64'); return [ @@ -32,11 +27,11 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = nul { enabled: true, name: 'Authorization', - value: `Bearer ${get(auth, 'bearer.token', '')}` + value: `Bearer ${get(requestAuth, 'bearer.token', '')}` } ]; case 'apikey': - const apiKeyAuth = get(auth, 'apikey', {}); + const apiKeyAuth = get(requestAuth, 'apikey', {}); const key = get(apiKeyAuth, 'key', ''); const value = get(apiKeyAuth, 'value', ''); const placement = get(apiKeyAuth, 'placement', 'header'); @@ -52,7 +47,7 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = nul } return []; case 'oauth2': { - const oauth2Config = get(auth, 'oauth2', {}); + const oauth2Config = get(requestAuth, 'oauth2', {}); const tokenPlacement = get(oauth2Config, 'tokenPlacement', 'header'); const tokenHeaderPrefix = get(oauth2Config, 'tokenHeaderPrefix', 'Bearer');