From 148d3f0e7d3ebfb45af17aad05d321c3c7daa75d Mon Sep 17 00:00:00 2001 From: Cmarvin1 <44953569+Cmarvin1@users.noreply.github.com> Date: Wed, 21 Jan 2026 08:07:49 -0500 Subject: [PATCH] fix: Code Generation for Basic Auth (#6474) * Adding interpolation utilities * Refactor interpolation * Refactor interpolation * updating tests * updating tests * minor refinements to interpolation logic * update snippet generator to handle basic auth credentials * move interpolation upstream --- .../GenerateCodeItem/utils/interpolation.js | 87 +++++--------- .../utils/interpolation.spec.js | 107 +++++++++++++----- .../utils/snippet-generator.js | 21 ++-- .../utils/snippet-generator.spec.js | 86 ++++++++++---- 4 files changed, 180 insertions(+), 121 deletions(-) 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..b4697ddc1 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,71 +1,38 @@ import { interpolate } from '@usebruno/common'; -import { cloneDeep } from 'lodash'; +import { isPlainObject, mapValues } from 'lodash-es'; -export const interpolateHeaders = (headers = [], variables = {}) => { - return headers.map((header) => ({ - ...header, - name: interpolate(header.name, variables), - value: interpolate(header.value, variables) - })); -}; +/** + * Traverses an object and interpolates any strings it finds. + */ +export const interpolateObject = (obj, variables) => { + const seen = new WeakSet(); -export const interpolateBody = (body, variables = {}) => { - if (!body) return null; + const walk = (value) => { + if (value == null) return value; - const interpolatedBody = cloneDeep(body); + if (typeof value === 'string') { + return interpolate(value, variables); + } - switch (body.mode) { - case 'json': - let parsed = body.json; - // If it's already a string, use it directly; if it's an object, stringify it first - if (typeof parsed === 'object') { - parsed = JSON.stringify(parsed); + if (typeof value === 'object') { + if (seen.has(value)) { + throw new Error( + 'Circular reference detected during interpolation.' + ); } - parsed = interpolate(parsed, variables, { escapeJSONStrings: true }); - try { - const jsonObj = JSON.parse(parsed); - interpolatedBody.json = JSON.stringify(jsonObj, null, 2); - } catch { - interpolatedBody.json = parsed; - } - break; + seen.add(value); + } - case 'text': - interpolatedBody.text = interpolate(body.text, variables); - break; + if (Array.isArray(value)) { + return value.map(walk); + } - case 'xml': - interpolatedBody.xml = interpolate(body.xml, variables); - break; + if (isPlainObject(value)) { + return mapValues(value, walk); + } - case 'sparql': - interpolatedBody.sparql = interpolate(body.sparql, variables); - break; + return value; + }; - case 'formUrlEncoded': - interpolatedBody.formUrlEncoded = Array.isArray(body.formUrlEncoded) - ? body.formUrlEncoded.map((param) => ({ - ...param, - value: param.enabled ? interpolate(param.value, variables) : param.value - })) - : []; - break; - - case 'multipartForm': - interpolatedBody.multipartForm = Array.isArray(body.multipartForm) - ? body.multipartForm.map((param) => ({ - ...param, - value: - param.type === 'text' && param.enabled - ? interpolate(param.value, variables) - : param.value - })) - : []; - break; - - default: - break; - } - - return interpolatedBody; + return walk(obj); }; 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..1810f4016 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,35 +1,84 @@ -import { interpolateHeaders, interpolateBody } from './interpolation'; +import { interpolateObject } from './interpolation'; describe('interpolation utils', () => { - describe('interpolateHeaders', () => { - it('should interpolate variables in header name and value while preserving other props', () => { - const headers = [ - { uid: '1', name: 'X-{{var}}', value: 'value-{{var}}', enabled: true } - ]; - const variables = { var: 'test' }; - - const result = interpolateHeaders(headers, variables); - expect(result).toEqual([ - { - uid: '1', - name: 'X-test', - value: 'value-test', - enabled: true + describe('interpolateObject', () => { + it('should interpolate variables across all data types and nesting levels', () => { + const complexRequest = { + url: 'https://{{host}}/api', + method: 'POST', + headers: [{ name: 'X-{{headerName}}', value: '{{headerValue}}', enabled: true }], + auth: { + basic: { + username: '{{user}}', + password: 'pass-{{passVar}}' + } + }, + body: { + mode: 'json', + json: '{"id": "{{id}}"}' + }, + params: { + someArray: ['tag-{{id}}', 'stable'], + value: 100, + enabled: true, + isNull: null } - ]); - }); - }); - - describe('interpolateBody', () => { - it('should interpolate JSON body strings and keep formatting', () => { - const body = { - mode: 'json', - json: '{"name": "{{username}}"}' }; - const variables = { username: 'bruno' }; - const result = interpolateBody(body, variables); - expect(result.json).toBe('{\n "name": "bruno"\n}'); + const variables = { + host: 'api.example.com', + headerName: 'App-ID', + headerValue: 'val-123', + user: 'admin', + passVar: 'secure', + id: '99' + }; + + const result = interpolateObject(complexRequest, variables); + + expect(result).toEqual({ + url: 'https://api.example.com/api', + method: 'POST', + headers: [{ name: 'X-App-ID', value: 'val-123', enabled: true }], + auth: { + basic: { + username: 'admin', + password: 'pass-secure' + } + }, + body: { + mode: 'json', + json: '{"id": "99"}' + }, + params: { + someArray: ['tag-99', 'stable'], + value: 100, + enabled: true, + isNull: null + } + }); + }); + + it('should not iterate endlessly for circular references', () => { + const variables = { x: 'ok' }; + + const obj = { value: '{{x}}' }; + obj.self = obj; + + expect(() => interpolateObject(obj, variables)).toThrow('Circular reference detected during interpolation.'); + }); + + it('should leave the placeholder intact if the variable is missing', () => { + const variables = { known: 'value' }; + const obj = { + field: '{{known}} and {{missing}}' + }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + field: 'value and {{missing}}' + }); }); it('should interpolate text body', () => { @@ -37,12 +86,12 @@ describe('interpolation utils', () => { mode: 'text', text: 'Hello {{name}}' }; - const result = interpolateBody(body, { name: 'World' }); + const result = interpolateObject(body, { name: 'World' }); expect(result.text).toBe('Hello World'); }); it('should return null when body is null', () => { - expect(interpolateBody(null, { a: 1 })).toBeNull(); + expect(interpolateObject(null, { a: 1 })).toBeNull(); }); }); }); 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..746c2a864 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,7 +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 { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections'; +import { interpolateObject } from './interpolation'; import { get } from 'lodash'; const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => { @@ -11,7 +11,11 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false const variables = getAllVariables(collection, item); - const request = item.request; + let request = item.request; + + if (shouldInterpolate) { + request = interpolateObject(request, variables); + } // Get the request tree path and merge headers const requestTreePath = getTreePathFromCollectionToItem(collection, item); @@ -24,14 +28,6 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false headers = [...headers, ...authHeaders]; } - // Interpolate headers and body if needed - if (shouldInterpolate) { - headers = interpolateHeaders(headers, variables); - if (request.body) { - request.body = interpolateBody(request.body, variables); - } - } - // Build HAR request const harRequest = buildHarRequest({ request, @@ -40,9 +36,8 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false // Generate snippet using HTTPSnippet const snippet = new HTTPSnippet(harRequest); - const result = snippet.convert(language.target, language.client); - return result; + return snippet.convert(language.target, language.client); } catch (error) { console.error('Error generating code snippet:', error); return 'Error generating code snippet'; 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..1133b5a77 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(() => []) }; @@ -146,11 +150,8 @@ describe('Snippet Generator - Simple Tests', () => { shouldInterpolate: true }); - const expectedBody = `{ - "message": "Hello World", - "count": 42 -}`; - expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`); + const expectedBody = `{"message": "Hello World", "count": 42}`; + expect(result).toBe(`curl -X POST https://api.example.com/data -H "Content-Type: application/json" -d '${expectedBody}'`); }); it('should handle GET requests', () => { @@ -203,11 +204,8 @@ describe('Snippet Generator - Simple Tests', () => { }); // Body should have interpolated variables with proper formatting - const expectedBody = `{ - "message": "Hello World", - "count": 42 -}`; - expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`); + const expectedBody = `{"message": "Hello World", "count": 42}`; + expect(result).toBe(`curl -X POST https://api.example.com/data -H "Content-Type: application/json" -d '${expectedBody}'`); }); it('should handle complex nested JSON body', () => { @@ -269,7 +267,7 @@ describe('Snippet Generator - Simple Tests', () => { } }, null, 2); - expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`); + expect(result).toBe(`curl -X POST https://api.example.com/data -H "Content-Type: application/json" -d '${expectedComplexBody}'`); }); it('should handle errors gracefully', () => { @@ -369,13 +367,9 @@ describe('Snippet Generator - Simple Tests', () => { shouldInterpolate: true }); - const expectedInterpolatedBody = `{ - "name": "John Smith", - "email": "john@test.com", - "age": 30 -}`; + const expectedInterpolatedBody = `{"name": "John Smith", "email": "john@test.com", "age": 30}`; - expect(result).toBe(`curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`); + expect(result).toBe(`curl -X POST https://api.test.com/users -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`); }); it('should NOT interpolate when shouldInterpolate is false', () => { @@ -424,7 +418,61 @@ describe('Snippet Generator - Simple Tests', () => { shouldInterpolate: false }); - expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\''); + 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 basic auth credentials correctly', () => { + const item = { + request: { + method: 'GET', + url: 'https://api.example.com', + auth: { + mode: 'basic', + basic: { + username: '{{user}}', + password: '{{pass}}' + } + } + } + }; + + const collection = { + root: { + request: { + vars: { + req: [ + { name: 'user', value: 'admin', enabled: true }, + { name: 'pass', value: 'secret123', enabled: true } + ] + } + } + } + }; + + 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' + }) + ); }); });