Compare commits

...

22 Commits

Author SHA1 Message Date
Cmarvin1
148d3f0e7d 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
2026-01-21 18:37:49 +05:30
Pooja
75e17610f0 fix: openapi query param import (#6241) 2026-01-21 17:53:14 +05:30
sreelakshmi-bruno
154c45d87d skip loading CA certificates when SSL verification is disabled (#6829) 2026-01-21 12:38:45 +05:30
Yash
0bf169562b feat: enhance OAuth2 support in snippet generation (#6592)
* feat: enhance OAuth2 support in snippet generation

* Updated getAuthHeaders function to handle OAuth2 authentication, including token retrieval and placement.
* Added tests for OAuth2 scenarios, ensuring correct Authorization header generation and handling of edge cases.
* Improved error handling for access token retrieval from stored credentials.

* refactor: standardize comparison operators in getAuthHeaders function

* Updated comparison operators in the getAuthHeaders function to use strict equality (===) for improved consistency and reliability in credential checks.

* fix: correct block structure in OAuth2 case of getAuthHeaders function

* Added missing block structure for the 'oauth2' case in the getAuthHeaders function to ensure proper execution flow and maintain code clarity.

* feat: enhance OAuth2 credential retrieval in getAuthHeaders function

* Updated getAuthHeaders function to support retrieval of stored OAuth2 credentials based on collection and item context.
* Improved access token handling by checking for existing credentials before falling back to default values.
* Enhanced test coverage for OAuth2 scenarios to ensure accurate token management and error handling.

* fix: preserve tokenHeaderPrefix value in OAuth2 configuration

* Updated snippet-generator.spec.js to ensure that the tokenHeaderPrefix from OAuth2 configuration is preserved, allowing for empty string scenarios.
* Default to 'Bearer' only if the tokenHeaderPrefix is undefined, enhancing flexibility in token management.

* fix: ensure consistent formatting of authorization header in OAuth2 handling

* Updated getAuthHeaders function to always trim the final result of the authorization header for consistent formatting.
* Adjusted snippet-generator.spec.js to reflect the same trimming logic for the access token, enhancing test reliability.

* fix: clarify token placement handling in getAuthHeaders function

* Updated comments in the getAuthHeaders function to specify that when tokenPlacement is 'url', no auth headers are added, and that token placement in the URL/query params must be managed separately.

* fix: ensure safe handling of OAuth2 credentials in getAuthHeaders function

* Updated getAuthHeaders function to default to an empty array when accessing oauth2Credentials, preventing potential errors when no credentials are available.
2026-01-21 12:23:05 +05:30
gopu-bruno
967b073ded fix: prevent response truncation in recursive collection runner (#6862) 2026-01-21 11:32:33 +05:30
sanish chirayath
725dfeacac feat: add user-agent support in gRPC client channel options (#6808)
* feat: add user-agent support in gRPC client channel options

- Extracted user-agent from request headers and set it as grpc.primary_user_agent channel option.
- Updated client instantiation to merge user-agent with existing channel options for enhanced request handling.

* test: add unit tests for GrpcClient user-agent handling

* test: enhance GrpcClient user-agent tests with edge case handling

* test: enhance GrpcClient channelOptions handling with override capability
2026-01-20 23:59:25 +05:30
lohit
923d26ce56 fix: get certs and proxy config based on oauth2 token and refresh urls instead of resource url (#6164) 2026-01-20 21:43:54 +05:30
lohit
7e258003d5 feat: add certs and proxy config for bruno-cli oauth2 requests (#6423) 2026-01-20 21:42:48 +05:30
Abhinandan M.S
7689288763 fix:prevent JS hint leak on Ctrl+Space and show allowed root hints (#6776) 2026-01-20 13:55:49 +05:30
fake
81faa57808 fix: add timeout for prevent ui lag (#6771) 2026-01-20 13:28:06 +05:30
naman-bruno
bac9616de4 feat: enhance SaveRequestsModal to handle environment drafts (#6857) 2026-01-20 12:40:06 +05:30
naman-bruno
9ab1ed3d90 fix: update clone collection location logic based on active workspace (#6841) 2026-01-20 12:38:31 +05:30
Sid
408c9d4a4e chore: update project dependencies (#6858) 2026-01-20 12:37:09 +05:30
Sid
ebafdd813c chore: update qs package version to 6.14.1 (#6849)
Co-authored-by: Siddharth Gelera <ahoy@barelyhuman.dev>
2026-01-19 21:40:39 +05:30
lohit
6642f4d0b0 fix: cli proxy config updates (#6846)
* fix: cli `proxy config` updates

* fix: review comment fixes
2026-01-19 20:58:20 +05:30
naman-bruno
4f75474c87 remove allowScriptFilesystemAccess flag (#6834) 2026-01-19 19:33:00 +05:30
Bijin A B
e5b7aa5ab4 fix: variables set via setVar should be interpolated only during runtime (#6823) 2026-01-19 17:28:36 +05:30
Sid
875df38501 Merge pull request #6662 from usebruno/feature/bru-safemode-5760
feat: Implement `isSafeMode()` API (#5760)
2026-01-19 17:26:45 +05:30
Pooja
a724f010ff fix bru safe mode and add test (#6667)
* fix bru safe mode and add tests

* rm: settimeout

fix: isSafe mode test (#6844)
2026-01-19 17:26:12 +05:30
Dominik D. Geyer
f9423d1238 feat: Implement isSafeMode() API (#5760)
Add `isSafeMode()` to Bru API that returns `true` in
case the runtime is a sandbox.

This allows for scripts to test for and handle whether
running in sandbox or not:

```javascript
if (bru.isSafeMode()) {
  throw new Error('This script requires Developer mode')
}
```

Co-authored-by: Anoop M D <anoop@usebruno.com>
2026-01-19 17:26:12 +05:30
shubh-bruno
51e36519f7 fix: improve {{var}} detection using cursor-based brace matching (#6691) 2026-01-19 17:17:09 +05:30
gopu-bruno
bd0894ede0 fix: resolve tab flickering when switching between requests (#6825) 2026-01-16 12:08:14 +05:30
70 changed files with 3068 additions and 536 deletions

62
package-lock.json generated
View File

@@ -30060,7 +30060,7 @@
"polished": "^4.3.1",
"posthog-node": "4.2.1",
"prettier": "^2.7.1",
"qs": "^6.11.0",
"qs": "^6.14.1",
"query-string": "^7.0.1",
"react": "19.0.0",
"react-copy-to-clipboard": "^5.1.0",
@@ -31516,6 +31516,21 @@
"url": "https://opencollective.com/express"
}
},
"packages/bruno-app/node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/react-virtuoso": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.1.tgz",
@@ -31611,7 +31626,7 @@
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"qs": "^6.14.1",
"socks-proxy-agent": "^8.0.2",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
@@ -32661,6 +32676,21 @@
"node": ">=12"
}
},
"packages/bruno-cli/node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-common": {
"name": "@usebruno/common",
"version": "0.1.0",
@@ -33246,9 +33276,9 @@
"license": "MIT"
},
"packages/bruno-converters/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -33351,7 +33381,7 @@
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"qs": "^6.11.0",
"qs": "^6.14.1",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^9.0.0",
@@ -34837,6 +34867,21 @@
"dev": true,
"license": "MIT"
},
"packages/bruno-electron/node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-electron/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@@ -35200,7 +35245,10 @@
"debug": "^4.4.3",
"google-protobuf": "^4.0.0",
"grpc-js-reflection-client": "^1.3.0",
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"is-ip": "^5.0.1",
"socks-proxy-agent": "~8.0.5",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",
"ws": "^8.18.3"
@@ -35595,4 +35643,4 @@
}
}
}
}
}

View File

@@ -78,6 +78,7 @@
"build:electron:rpm": "./scripts/build-electron.sh rpm",
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",

View File

@@ -69,7 +69,7 @@
"polished": "^4.3.1",
"posthog-node": "4.2.1",
"prettier": "^2.7.1",
"qs": "^6.11.0",
"qs": "^6.14.1",
"query-string": "^7.0.1",
"react": "19.0.0",
"react-copy-to-clipboard": "^5.1.0",

View File

@@ -8,7 +8,7 @@
import React from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
@@ -111,8 +111,12 @@ export default class CodeEditor extends React.Component {
: cm.replaceSelection(' ', 'end');
},
'Shift-Tab': 'indentLess',
'Ctrl-Space': 'autocomplete',
'Cmd-Space': 'autocomplete',
'Ctrl-Space': (cm) => {
showRootHints(cm, this.props.showHintsFor);
},
'Cmd-Space': (cm) => {
showRootHints(cm, this.props.showHintsFor);
},
'Ctrl-Y': 'foldAll',
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',

View File

@@ -14,6 +14,10 @@ const Wrapper = styled.div`
z-index: 0;
}
.scroll-chevrons.hidden {
display: none;
}
.tabs-scroll-container {
overflow-x: auto;
overflow-y: clip;
@@ -192,10 +196,6 @@ const Wrapper = styled.div`
}
}
&.has-chevrons ul {
padding-left: 0;
}
.special-tab-icon {
color: ${(props) => props.theme.primary.text};
}

View File

@@ -103,14 +103,9 @@ const RequestTabs = () => {
});
};
const getRootClassname = () => {
return classnames({
'has-chevrons': showChevrons
});
};
// Todo: Must support ephemeral requests
return (
<StyledWrapper className={getRootClassname()}>
<StyledWrapper>
{newRequestModalOpen && (
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
)}
@@ -118,12 +113,11 @@ const RequestTabs = () => {
<>
<CollectionToolBar collection={activeCollection} />
<div className="flex items-center gap-2 pl-2" ref={collectionTabsRef}>
{showChevrons ? (
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={leftSlide} aria-label="Left Chevron" style={{ marginBottom: '3px' }}>
<IconChevronLeft size={18} strokeWidth={1.5} />
</ActionIcon>
) : null}
</div>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab mr-1" onClick={createNewTab}>
<div className="flex items-center home-icon-container">
@@ -175,11 +169,11 @@ const RequestTabs = () => {
</ActionIcon>
)}
{showChevrons ? (
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={rightSlide} aria-label="Right Chevron" style={{ marginBottom: '3px' }}>
<IconChevronRight size={18} strokeWidth={1.5} />
</ActionIcon>
) : null}
</div>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab choose-request">
<div className="flex items-center">

View File

@@ -20,7 +20,14 @@ const CloneCollection = ({ onClose, collectionUid }) => {
const [isEditing, toggleEditing] = useState(false);
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const preferences = useSelector((state) => state.app.preferences);
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
const workspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid);
const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid);
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const { name } = collection;
const formik = useFormik({

View File

@@ -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);
};

View File

@@ -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();
});
});
});

View File

@@ -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);
@@ -20,18 +24,10 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
// Add auth headers if needed
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);
const authHeaders = getAuthHeaders(collectionAuth, request.auth, collection, item);
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';

View File

@@ -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'
})
);
});
});
@@ -554,3 +602,223 @@ describe('generateSnippet with edge-case bodies', () => {
expect(result).toMatch(/^curl -X POST/);
});
});
describe('generateSnippet with OAuth2 authentication', () => {
const language = { target: 'shell', client: 'curl' };
const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
beforeEach(() => {
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) => {
if (requestAuth?.mode === 'oauth2') {
const oauth2Config = requestAuth.oauth2 || {};
const tokenPlacement = oauth2Config.tokenPlacement || 'header';
// Use the actual value from config, defaulting to 'Bearer' only if undefined
// Empty string should be preserved to test no-prefix scenarios
const tokenHeaderPrefix = oauth2Config.tokenHeaderPrefix !== undefined
? oauth2Config.tokenHeaderPrefix
: 'Bearer';
let accessToken = oauth2Config.accessToken || '<access_token>';
// If collection and item are provided, try to look up stored credentials
if (collection && item && collection.oauth2Credentials) {
const grantType = oauth2Config.grantType || '';
const urlToLookup = grantType === 'implicit'
? oauth2Config.authorizationUrl || ''
: oauth2Config.accessTokenUrl || '';
const credentialsId = oauth2Config.credentialsId || 'credentials';
const collectionUid = collection.uid;
if (urlToLookup && collectionUid) {
// Look up stored credentials (simplified - assumes URL is already interpolated in test data)
const credentialsData = collection.oauth2Credentials.find(
(creds) =>
creds?.url === urlToLookup
&& creds?.collectionUid === collectionUid
&& creds?.credentialsId === credentialsId
);
if (credentialsData?.credentials?.access_token) {
accessToken = credentialsData.credentials.access_token;
}
}
}
if (tokenPlacement === 'header') {
// Always trim the final result for consistent formatting
const headerValue = tokenHeaderPrefix
? `${tokenHeaderPrefix} ${accessToken}`.trim()
: accessToken.trim();
return [
{
enabled: true,
name: 'Authorization',
value: headerValue
}
];
}
}
return [];
});
});
it('should include OAuth2 Bearer token in Authorization header when tokenPlacement is header', () => {
const item = {
uid: 'oauth-req',
request: {
method: 'GET',
url: 'https://api.example.com/users',
headers: [],
auth: {
mode: 'oauth2',
oauth2: {
grantType: 'client_credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer',
accessToken: 'test-access-token-123'
}
}
}
};
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Authorization',
value: 'Bearer test-access-token-123'
})
])
);
});
it('should use custom tokenHeaderPrefix when provided', () => {
const item = {
uid: 'oauth-req-custom',
request: {
method: 'GET',
url: 'https://api.example.com/users',
headers: [],
auth: {
mode: 'oauth2',
oauth2: {
grantType: 'client_credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'OAuth',
accessToken: 'custom-token-456'
}
}
}
};
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Authorization',
value: 'OAuth custom-token-456'
})
])
);
});
it('should not include Authorization header when tokenPlacement is url', () => {
const item = {
uid: 'oauth-req-url',
request: {
method: 'GET',
url: 'https://api.example.com/users',
headers: [],
auth: {
mode: 'oauth2',
oauth2: {
grantType: 'client_credentials',
tokenPlacement: 'url',
tokenQueryKey: 'access_token',
accessToken: 'token-in-url'
}
}
}
};
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
const authHeader = harCall.headers.find((h) => h.name === 'Authorization');
expect(authHeader).toBeUndefined();
});
it('should use placeholder when accessToken is not available', () => {
const item = {
uid: 'oauth-req-placeholder',
request: {
method: 'GET',
url: 'https://api.example.com/users',
headers: [],
auth: {
mode: 'oauth2',
oauth2: {
grantType: 'client_credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer'
}
}
}
};
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Authorization',
value: 'Bearer <access_token>'
})
])
);
});
it('should handle empty tokenHeaderPrefix', () => {
const item = {
uid: 'oauth-req-no-prefix',
request: {
method: 'GET',
url: 'https://api.example.com/users',
headers: [],
auth: {
mode: 'oauth2',
oauth2: {
grantType: 'client_credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: '',
accessToken: 'token-without-prefix'
}
}
}
};
generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
const harUtils = require('utils/codegenerator/har');
const harCall = harUtils.buildHarRequest.mock.calls[0][0];
expect(harCall.headers).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Authorization',
value: 'token-without-prefix'
})
])
);
});
});

View File

@@ -4,10 +4,11 @@ import filter from 'lodash/filter';
import groupBy from 'lodash/groupBy';
import { useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections';
import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections';
import { pluralizeWord } from 'utils/common';
import { completeQuitFlow } from 'providers/ReduxStore/slices/app';
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders } from 'providers/ReduxStore/slices/collections/actions';
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Button from 'ui/Button';
@@ -16,12 +17,15 @@ const SaveRequestsModal = ({ onClose }) => {
const MAX_UNSAVED_ITEMS_TO_SHOW = 5;
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
const dispatch = useDispatch();
const allDrafts = useMemo(() => {
const requestDrafts = [];
const collectionDrafts = [];
const folderDrafts = [];
const environmentDrafts = [];
const tabsByCollection = groupBy(tabs, (t) => t.collectionUid);
Object.keys(tabsByCollection).forEach((collectionUid) => {
@@ -36,6 +40,21 @@ const SaveRequestsModal = ({ onClose }) => {
});
}
// Check for collection environment draft
if (collection.environmentsDraft) {
const { environmentUid, variables } = collection.environmentsDraft;
const environment = findEnvironmentInCollection(collection, environmentUid);
if (environment && variables) {
environmentDrafts.push({
type: 'collection-environment',
name: environment.name,
environmentUid,
variables,
collectionUid: collectionUid
});
}
}
// Check for request and folder drafts
const items = flattenItems(collection.items);
@@ -62,8 +81,22 @@ const SaveRequestsModal = ({ onClose }) => {
}
});
return [...collectionDrafts, ...folderDrafts, ...requestDrafts];
}, [collections, tabs]);
// Check for global environment draft
if (globalEnvironmentDraft) {
const { environmentUid, variables } = globalEnvironmentDraft;
const environment = globalEnvironments?.find((env) => env.uid === environmentUid);
if (environment && variables) {
environmentDrafts.push({
type: 'global-environment',
name: environment.name,
environmentUid,
variables
});
}
}
return [...collectionDrafts, ...folderDrafts, ...environmentDrafts, ...requestDrafts];
}, [collections, tabs, globalEnvironments, globalEnvironmentDraft]);
const totalDraftsCount = allDrafts.length;
@@ -84,6 +117,8 @@ const SaveRequestsModal = ({ onClose }) => {
const collectionDrafts = allDrafts.filter((d) => d.type === 'collection');
const folderDrafts = allDrafts.filter((d) => d.type === 'folder');
const requestDrafts = allDrafts.filter((d) => d.type === 'request');
const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment');
const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment');
// Save all collection drafts
if (collectionDrafts.length > 0) {
@@ -100,6 +135,16 @@ const SaveRequestsModal = ({ onClose }) => {
await dispatch(saveMultipleRequests(requestDrafts));
}
// Save all collection environment drafts
for (const draft of collectionEnvironmentDrafts) {
await dispatch(saveEnvironment(draft.variables, draft.environmentUid, draft.collectionUid));
}
// Save all global environment drafts
for (const draft of globalEnvironmentDrafts) {
await dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }));
}
dispatch(completeQuitFlow());
onClose();
} catch (error) {
@@ -134,12 +179,23 @@ const SaveRequestsModal = ({ onClose }) => {
<ul className="mt-4">
{allDrafts.slice(0, MAX_UNSAVED_ITEMS_TO_SHOW).map((item, index) => {
const prefix
= item.type === 'collection'
? 'Collection: '
: item.type === 'folder'
? 'Folder: '
: 'Request: ';
let prefix;
switch (item.type) {
case 'collection':
prefix = 'Collection: ';
break;
case 'folder':
prefix = 'Folder: ';
break;
case 'collection-environment':
prefix = 'Collection Environment: ';
break;
case 'global-environment':
prefix = 'Global Environment: ';
break;
default:
prefix = 'Request: ';
}
return (
<li key={`${item.type}-${item.collectionUid || item.uid}-${index}`} className="mt-1 text-xs">
{prefix}

View File

@@ -1,6 +1,9 @@
import get from 'lodash/get';
import { find } from 'lodash';
import { interpolate } from '@usebruno/common';
import { getAllVariables } from 'utils/collections/index';
export const getAuthHeaders = (collectionRootAuth, requestAuth) => {
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')
@@ -48,6 +51,72 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth) => {
];
}
return [];
case 'oauth2': {
const oauth2Config = get(auth, 'oauth2', {});
const tokenPlacement = get(oauth2Config, 'tokenPlacement', 'header');
const tokenHeaderPrefix = get(oauth2Config, 'tokenHeaderPrefix', 'Bearer');
// Only add header if token placement is 'header'
if (tokenPlacement === 'header') {
// Try to get access token from persisted credentials
let accessToken = '<access_token>';
if (collection && item) {
try {
const grantType = get(oauth2Config, 'grantType', '');
// For implicit grant type, use authorizationUrl; for others, use accessTokenUrl
const urlToLookup = grantType === 'implicit'
? get(oauth2Config, 'authorizationUrl', '')
: get(oauth2Config, 'accessTokenUrl', '');
const credentialsId = get(oauth2Config, 'credentialsId', 'credentials');
const collectionUid = get(collection, 'uid');
if (urlToLookup && collectionUid) {
// Interpolate the URL with variables
const variables = getAllVariables(collection, item);
const interpolatedUrl = interpolate(urlToLookup, variables);
// Look up stored credentials
const credentialsData = find(
collection?.oauth2Credentials || [],
(creds) =>
creds?.url === interpolatedUrl
&& creds?.collectionUid === collectionUid
&& creds?.credentialsId === credentialsId
);
if (credentialsData?.credentials?.access_token) {
accessToken = credentialsData.credentials.access_token;
}
}
} catch (error) {
console.error('Error retrieving OAuth2 access token:', error);
// Fall back to placeholder if lookup fails
}
}
// Build the authorization header value
// If tokenHeaderPrefix is empty, just use the token
// Otherwise, use the format: "prefix token"
// Always trim the final result for consistent formatting
const headerValue = (
tokenHeaderPrefix
? `${tokenHeaderPrefix} ${accessToken}`
: accessToken
).trim();
return [
{
enabled: true,
name: 'Authorization',
value: headerValue
}
];
}
// If tokenPlacement is 'url', this function does not add any auth headers;
// token placement in the URL/query params must be handled elsewhere.
return [];
}
default:
return [];
}

View File

@@ -76,6 +76,7 @@ const STATIC_API_HINTS = {
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getCollectionName()',
'bru.isSafeMode()',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
@@ -294,9 +295,14 @@ const calculateWordReplacementPositions = (cursor, start, end, word) => {
* @returns {string} The determined context
*/
const determineWordContext = (word) => {
if (word.startsWith('req') || word.startsWith('res') || word.startsWith('bru')) {
const isApiHint = Object.keys(STATIC_API_HINTS).some(
(apiRoot) => apiRoot.toLowerCase().startsWith(word.toLowerCase()) || word.toLowerCase().startsWith(apiRoot.toLowerCase())
);
if (isApiHint) {
return 'api';
}
return 'anyword';
};
@@ -513,6 +519,34 @@ const createStandardHintList = (filteredHints, from, to) => {
};
};
/**
* Show root-level API hints when the editor is empty
* @param {Object} cm - CodeMirror instance
* @param {string[]} showHintsFor - Array of hint types to show (e.g., ['req', 'res', 'bru'])
* @returns {boolean} True if hints were shown, false otherwise
*/
export const showRootHints = (cm, showHintsFor = []) => {
const wordInfo = getCurrentWordWithContext(cm);
// If user is currently typing a word, let handleKeyupForAutocomplete
// handle it instead of showing root hints.
if (wordInfo) {
return false;
}
const hints = Object.keys(STATIC_API_HINTS).filter((rootHint) => showHintsFor.includes(rootHint));
if (hints.length === 0) return false;
const cursor = cm.getCursor();
const hintList = createStandardHintList(hints, cursor, cursor);
cm.showHint({
hint: () => hintList,
completeSingle: false
});
return true;
};
/**
* Bruno AutoComplete Helper - Main function with context awareness
* @param {Object} cm - CodeMirror instance
@@ -624,7 +658,8 @@ const handleKeyupForAutocomplete = (cm, event, options) => {
const hints = getAutoCompleteHints(cm, allVariables, anywordAutocompleteHints, options);
if (!hints) {
if (cm.state.completionActive) {
const wordInfo = getCurrentWordWithContext(cm);
if (cm.state.completionActive && wordInfo) {
cm.state.completionActive.close();
}
return;

View File

@@ -482,7 +482,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.state.completionActive = mockCompletion;
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 });
mockedCodemirror.getLine.mockReturnValue(' ');
mockedCodemirror.getLine.mockReturnValue('req.bodyy');
mockedCodemirror.getRange.mockReturnValue('');
const mockEvent = { key: 'a' };

View File

@@ -682,54 +682,89 @@ if (!SERVER_RENDERED) {
const state = cm.state.brunoVarInfo;
const options = state.options;
let token = cm.getTokenAt(pos, true);
if (token) {
const line = cm.getLine(pos.line);
// Get the full line text where the hover happened
const line = cm.getLine(pos.line);
if (!line) return;
// Find the opening {{ before the cursor
let start = token.start;
while (start > 0 && !line.substring(start - 2, start).includes('{{')) {
// Stop if we encounter }} - we've gone past the start of our variable
if (line.substring(start - 2, start) === '}}') {
break;
}
start--;
}
if (line.substring(start - 2, start) === '{{') {
start = start - 2;
// If the line doesn't even contain both braces, no need to run loops
if (!line.includes('{{') || !line.includes('}}')) {
return;
}
// lastIndexOf searches backward from the cursor indexOf searches forward
if (line.lastIndexOf('{{', pos.ch) === -1 || line.indexOf('}}', pos.ch) === -1) {
return;
}
let start = pos.ch;
let end = pos.ch;
// ---------- Find opening '{{' to the LEFT ----------
while (start > 0) {
const leftTwo = line.substring(start - 2, start);
// If we find opening braces, stop
if (leftTwo === '{{') {
start -= 2;
break;
}
// Find the closing }} after the cursor
let end = token.end;
while (end < line.length && !line.substring(end, end + 2).includes('}}')) {
// Stop if we encounter {{ - we've gone past the end of our variable
if (line.substring(end, end + 2) === '{{') {
break;
}
end++;
}
if (line.substring(end, end + 2) === '}}') {
end = end + 2;
// If we cross a closing braces before finding '{{', we're not inside a variable
if (leftTwo === '}}') {
return;
}
// Extract the full variable string including {{ and }}
const fullVariableString = line.substring(start, end);
start--;
}
// Only use the expanded string if it looks like a complete variable
if (fullVariableString.startsWith('{{') && fullVariableString.endsWith('}}')) {
token = {
...token,
string: fullVariableString,
start: start,
end: end
};
// If we reached the start of the line and didn't match '{{', return
if (start < 0 || line.substring(start, start + 2) !== '{{') {
return;
}
// ---------- Find closing '}}' to the RIGHT ----------
while (end < line.length) {
const rightTwo = line.substring(end, end + 2);
// If we find closing braces, stop
if (rightTwo === '}}') {
end += 2;
break;
}
const brunoVarInfo = renderVarInfo(token, options);
if (brunoVarInfo) {
showPopup(cm, box, brunoVarInfo);
// If we hit another '{{' before a '}}', then this isn't a valid enclosing pair
if (rightTwo === '{{') {
return;
}
end++;
}
// If we reached end-of-line without finding '}}', return
if (end > line.length || line.substring(end - 2, end) !== '}}') {
return;
}
const fullVariableString = line.substring(start, end);
// Basic validation to ensure it's a non-empty variable
if (!fullVariableString.startsWith('{{') || !fullVariableString.endsWith('}}')) {
return;
}
// Prevent tooltips for empty variables like {{ }}
const inner = fullVariableString.slice(2, -2).trim();
if (!inner) return;
// Build a token object compatible with renderVarInfo
const token = {
string: fullVariableString,
start: start,
end: end
};
const brunoVarInfo = renderVarInfo(token, options);
if (brunoVarInfo) {
showPopup(cm, box, brunoVarInfo);
}
}

View File

@@ -66,7 +66,7 @@
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"qs": "^6.14.1",
"socks-proxy-agent": "^8.0.2",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"

View File

@@ -1,13 +1,19 @@
const { forOwn, cloneDeep } = require('lodash');
const { interpolate } = require('@usebruno/common');
const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
const { interpolate, interpolateObject: interpolateObjectCommon } = require('@usebruno/common');
const buildCombinedVars = ({
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars
}) => {
processEnvVars = processEnvVars || {};
runtimeVariables = runtimeVariables || {};
collectionVariables = collectionVariables || {};
folderVariables = folderVariables || {};
requestVariables = requestVariables || {};
// we clone envVars because we don't want to modify the original object
envVars = envVars ? cloneDeep(envVars) : {};
@@ -25,8 +31,11 @@ const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) =
});
// runtimeVariables take precedence over envVars
const combinedVars = {
return {
...collectionVariables,
...envVars,
...folderVariables,
...requestVariables,
...runtimeVariables,
process: {
env: {
@@ -34,10 +43,26 @@ const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) =
}
}
};
};
const interpolateString = (str, interpolationOptions) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
const combinedVars = buildCombinedVars(interpolationOptions);
return interpolate(str, combinedVars);
};
module.exports = {
interpolateString
/**
* recursively interpolating all string values in a object
*/
const interpolateObject = (obj, interpolationOptions) => {
const combinedVars = buildCombinedVars(interpolationOptions);
return interpolateObjectCommon(obj, combinedVars);
};
module.exports = {
interpolateString,
interpolateObject
};

View File

@@ -5,7 +5,7 @@ const fs = require('fs');
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
const { interpolateString, interpolateObject } = require('./interpolate-string');
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
@@ -21,8 +21,8 @@ const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const { getCACertificates } = require('@usebruno/requests');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
@@ -288,25 +288,27 @@ const runSingleRequest = async function (
let proxyMode = 'off';
let proxyConfig = {};
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false);
const collectionProxyConfig = transformProxyConfig(get(brunoConfig, 'proxy', {}));
const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false);
const collectionProxyInherit = get(collectionProxyConfig, 'inherit', true);
const collectionProxyConfigData = get(collectionProxyConfig, 'config', {});
if (noproxy) {
// If noproxy flag is set, don't use any proxy
if (noproxy || collectionProxyDisabled) {
// If noproxy flag is set or collection proxy is disabled, don't use any proxy
proxyMode = 'off';
} else if (collectionProxyEnabled === true) {
// If collection proxy is enabled, use it
proxyConfig = collectionProxyConfig;
} else if (!collectionProxyDisabled && !collectionProxyInherit) {
// Use collection-specific proxy
proxyConfig = collectionProxyConfigData;
proxyMode = 'on';
} else if (collectionProxyEnabled === 'global') {
// If collection proxy is set to 'global', use system proxy
} else if (!collectionProxyDisabled && collectionProxyInherit) {
// Inherit from system proxy
const { http_proxy, https_proxy } = getSystemProxyEnvVariables();
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
} else {
proxyMode = 'off';
// else: no system proxy available, proxyMode stays 'off'
}
// else: collection proxy is disabled, proxyMode stays 'off'
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
@@ -314,7 +316,7 @@ const runSingleRequest = async function (
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
@@ -458,7 +460,54 @@ const runSingleRequest = async function (
// Handle OAuth2 authentication
if (request.oauth2) {
try {
const token = await getOAuth2Token(request.oauth2);
// Prepare interpolation options with all available variables
const oauth2InterpolationOptions = {
envVars: envVariables,
runtimeVariables,
processEnvVars,
collectionVariables: request.collectionVariables || {},
folderVariables: request.folderVariables || {},
requestVariables: request.requestVariables || {}
};
const accessTokenUrl = request.oauth2.accessTokenUrl ? interpolateString(request.oauth2.accessTokenUrl, oauth2InterpolationOptions) : undefined;
const refreshTokenUrl = request.oauth2.refreshTokenUrl ? interpolateString(request.oauth2.refreshTokenUrl, oauth2InterpolationOptions) : undefined;
const oauth2RequestUrl = accessTokenUrl || refreshTokenUrl;
let token;
if (oauth2RequestUrl) {
const tlsOptions = {
noproxy: options.noproxy,
shouldVerifyTls: !insecure,
shouldUseCustomCaCertificate: !!options['cacert'],
customCaCertificateFilePath: options['cacert'],
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
};
const clientCertificates = get(brunoConfig, 'clientCertificates');
const proxyConfig = get(brunoConfig, 'proxy');
const interpolatedClientCertificates = clientCertificates ? interpolateObject(clientCertificates, oauth2InterpolationOptions) : undefined;
const interpolatedProxyConfig = proxyConfig ? interpolateObject(proxyConfig, oauth2InterpolationOptions) : undefined;
const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({
requestUrl: oauth2RequestUrl,
collectionPath,
options: tlsOptions,
clientCertificates: interpolatedClientCertificates,
collectionLevelProxy: interpolatedProxyConfig,
systemProxyConfig: getSystemProxyEnvVariables()
});
const oauth2AxiosInstance = makeAxiosInstanceForOauth2({
requestMaxRedirects: requestMaxRedirects,
disableCookies: options.disableCookies,
httpAgent: oauth2HttpAgent,
httpsAgent: oauth2HttpsAgent
});
token = await getOAuth2Token(request.oauth2, oauth2AxiosInstance);
}
if (token) {
const { tokenPlacement = 'header', tokenHeaderPrefix = '', tokenQueryKey = 'access_token' } = request.oauth2;

View File

@@ -21,10 +21,10 @@ const getFormattedOauth2Credentials = () => {
return credentialsVariables;
};
const getOAuth2Token = (oauth2Config) => {
const getOAuth2Token = (oauth2Config, axiosInstance) => {
let options = getOptions();
let verbose = options?.verbose;
return _getOAuth2Token(oauth2Config, tokenStore, verbose);
return _getOAuth2Token(oauth2Config, tokenStore, verbose, axiosInstance);
};
module.exports = {

View File

@@ -36,7 +36,6 @@ describe('create collection json from pathname', () => {
expect(c).toHaveProperty('brunoConfig.proxy.auth.password', '<password>');
expect(c).toHaveProperty('brunoConfig.proxy.bypassProxy', '');
expect(c).toHaveProperty('brunoConfig.scripts.moduleWhitelist', ['crypto', 'buffer']);
expect(c).toHaveProperty('brunoConfig.scripts.filesystemAccess.allow', true);
expect(c).toHaveProperty('brunoConfig.clientCertificates.enabled', true);
expect(c).toHaveProperty('brunoConfig.clientCertificates.certs', []);

View File

@@ -19,10 +19,7 @@
"bypassProxy": ""
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer"],
"filesystemAccess": {
"allow": true
}
"moduleWhitelist": ["crypto", "buffer"]
},
"clientCertificates": {
"enabled": true,

View File

@@ -1,5 +1,5 @@
export { mockDataFunctions, timeBasedDynamicVars } from './utils/faker-functions';
export { default as interpolate } from './interpolate';
export { default as interpolate, interpolateObject } from './interpolate';
export { default as isRequestTagsIncluded } from './tags';
export * as utils from './utils';

View File

@@ -1,4 +1,4 @@
import interpolate from './index';
import interpolate, { interpolateObject } from './index';
import moment from 'moment';
const BRUNO_BIRTH_DATE = new Date('2019-08-08');
@@ -678,3 +678,182 @@ describe('interpolate - moment() handling', () => {
expect(result).toBe('Date is {"now":"2025-04-17T15:33:41.117Z"}');
});
});
describe('interpolateObject', () => {
it('should interpolate strings in a flat object', () => {
const obj = {
url: '{{baseUrl}}/api/users',
name: '{{userName}}'
};
const variables = { baseUrl: 'https://api.example.com', userName: 'Bruno' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
url: 'https://api.example.com/api/users',
name: 'Bruno'
});
});
it('should interpolate strings in nested objects', () => {
const obj = {
request: {
url: '{{baseUrl}}/api',
headers: {
Authorization: 'Bearer {{token}}'
}
}
};
const variables = { baseUrl: 'https://api.example.com', token: 'abc123' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
request: {
url: 'https://api.example.com/api',
headers: {
Authorization: 'Bearer abc123'
}
}
});
});
it('should interpolate strings in arrays', () => {
const obj = {
urls: ['{{baseUrl}}/one', '{{baseUrl}}/two']
};
const variables = { baseUrl: 'https://api.example.com' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
urls: ['https://api.example.com/one', 'https://api.example.com/two']
});
});
it('should preserve non-string values', () => {
const obj = {
name: '{{name}}',
age: 5,
active: true,
data: null
};
const variables = { name: 'Bruno' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
name: 'Bruno',
age: 5,
active: true,
data: null
});
});
it('should return null and undefined as-is', () => {
expect(interpolateObject(null, {})).toBeNull();
expect(interpolateObject(undefined, {})).toBeUndefined();
});
it('should throw on circular references', () => {
const obj: any = { a: 1 };
obj.self = obj;
expect(() => interpolateObject(obj, {})).toThrow('Circular reference detected during interpolation.');
});
it('should handle shared object references without throwing false positives', () => {
const shared = { value: '{{sharedValue}}' };
const obj = {
x: shared,
y: shared
};
const variables = { sharedValue: 'test' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
x: { value: 'test' },
y: { value: 'test' }
});
});
it('should handle shared object references in arrays', () => {
const shared = { id: '{{id}}' };
const obj = {
items: [shared, shared, shared]
};
const variables = { id: '123' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
items: [{ id: '123' }, { id: '123' }, { id: '123' }]
});
});
it('should handle shared object references in nested structures', () => {
const shared = { name: '{{name}}' };
const obj = {
user: shared,
profile: {
user: shared,
metadata: {
user: shared
}
}
};
const variables = { name: 'Bruno' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
user: { name: 'Bruno' },
profile: {
user: { name: 'Bruno' },
metadata: {
user: { name: 'Bruno' }
}
}
});
});
it('should handle shared array references', () => {
const shared = ['{{item1}}', '{{item2}}'];
const obj = {
list1: shared,
list2: shared
};
const variables = { item1: 'a', item2: 'b' };
const result = interpolateObject(obj, variables);
expect(result).toEqual({
list1: ['a', 'b'],
list2: ['a', 'b']
});
});
it('should still detect actual circular references', () => {
const obj: any = {
a: { value: '{{val}}' },
b: { value: '{{val}}' }
};
obj.a.circular = obj.a; // Circular reference
expect(() => interpolateObject(obj, { val: 'test' })).toThrow('Circular reference detected during interpolation.');
});
it('should handle deeply nested circular references', () => {
const obj: any = {
level1: {
level2: {
level3: {}
}
}
};
obj.level1.level2.level3.circular = obj.level1;
expect(() => interpolateObject(obj, {})).toThrow('Circular reference detected during interpolation.');
});
});

View File

@@ -12,7 +12,7 @@
*/
import { mockDataFunctions } from '../utils/faker-functions';
import { get, isPlainObject } from 'lodash-es';
import { get, isPlainObject, mapValues } from 'lodash-es';
// regex to match {{$keyword}}
const MOCK_PATTERN = /\{\{\$(\w+)\}\}/g;
@@ -129,4 +129,33 @@ const replace = (
return resultStr;
};
export const interpolateObject = (obj: unknown, variables: Record<string, any>): unknown => {
const seen = new WeakSet<object>();
const walk = (value: unknown): unknown => {
if (value == null) return value;
if (typeof value === 'string') {
return interpolate(value, variables);
}
if (typeof value === 'object') {
if (seen.has(value as object)) {
throw new Error('Circular reference detected during interpolation.');
}
seen.add(value as object);
try {
if (Array.isArray(value)) {
return value.map(walk);
}
if (isPlainObject(value)) {
return mapValues(value as Record<string, unknown>, walk);
}
return value;
} finally {
seen.delete(value as object);
}
}
return value;
};
return walk(obj);
};
export default interpolate;

View File

@@ -340,32 +340,71 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
};
each(_operationObject.parameters || [], (param) => {
if (param.in === 'query') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required
// Check if parameter schema is an object type with properties
// If so, expand the properties into individual parameters
const isObjectSchema = param.schema && param.schema.properties;
if (isObjectSchema) {
// Expand object schema properties into individual parameters
each(param.schema.properties, (prop, propName) => {
const isRequired = Array.isArray(param.schema.required) && param.schema.required.includes(propName);
if (param.in === 'query') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: '',
description: prop.description || '',
enabled: isRequired,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: '',
description: prop.description || '',
enabled: isRequired,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: propName,
value: '',
description: prop.description || '',
enabled: isRequired
});
}
});
} else {
if (param.in === 'query') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required
});
}
}
});

View File

@@ -357,3 +357,87 @@ const expectedOutput = {
uid: 'mockeduuidvalue123456',
version: '1'
};
describe('openapi-collection: object schema parameters', () => {
it('should expand object schema query parameters with $ref into individual properties', () => {
const openApiSpec = `
openapi: '3.0.3'
info:
title: 'Test API for Object Schema Parameters'
version: '1.0.0'
servers:
- url: 'https://api.example.com/v1'
paths:
/items:
get:
summary: 'Get items with pagination'
operationId: 'getItems'
parameters:
- name: date
in: query
required: true
schema:
type: string
format: date
description: 'Filter by date'
- name: paginationParams
in: query
required: true
schema:
$ref: '#/components/schemas/PaginationParams'
responses:
'200':
description: 'Successful response'
components:
schemas:
PaginationParams:
type: object
properties:
page:
type: integer
format: int32
minimum: 0
description: 'Page number'
size:
type: integer
format: int32
maximum: 100
minimum: 1
description: 'Page size'
required:
- page
- size
`;
const result = openApiToBruno(openApiSpec);
// Find the request item
const requestItem = result.items[0];
// Verify that we have 3 query parameters: date, page, size
const queryParams = requestItem.request.params.filter((p) => p.type === 'query');
expect(queryParams.length).toBe(3);
// Check that 'date' parameter exists
const dateParam = queryParams.find((p) => p.name === 'date');
expect(dateParam).toBeDefined();
expect(dateParam.description).toBe('Filter by date');
expect(dateParam.enabled).toBe(true);
// Check that 'page' parameter exists (expanded from PaginationParams)
const pageParam = queryParams.find((p) => p.name === 'page');
expect(pageParam).toBeDefined();
expect(pageParam.description).toBe('Page number');
expect(pageParam.enabled).toBe(true); // required in schema
// Check that 'size' parameter exists (expanded from PaginationParams)
const sizeParam = queryParams.find((p) => p.name === 'size');
expect(sizeParam).toBeDefined();
expect(sizeParam.description).toBe('Page size');
expect(sizeParam.enabled).toBe(true); // required in schema
// Verify that 'paginationParams' does NOT exist as a parameter
const paginationParam = queryParams.find((p) => p.name === 'paginationParams');
expect(paginationParam).toBeUndefined();
});
});

View File

@@ -68,7 +68,7 @@
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"qs": "^6.11.0",
"qs": "^6.14.1",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^9.0.0",

View File

@@ -220,9 +220,15 @@ app.on('ready', async () => {
}
});
let boundsTimeout;
const handleBoundsChange = () => {
if (!mainWindow.isMaximized()) {
saveBounds(mainWindow);
if (boundsTimeout) {
clearTimeout(boundsTimeout);
}
boundsTimeout = setTimeout(() => {
saveBounds(mainWindow);
}, 100);
}
};

View File

@@ -58,6 +58,7 @@ const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
const interpolateVars = require('./network/interpolate-vars');
const { interpolateString } = require('./network/interpolate-string');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, refreshOauth2Token } = require('../utils/oauth2');
@@ -1290,19 +1291,63 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
const requestTreePath = getTreePathFromCollectionToItem(collection, partialItem);
mergeVars(collection, requestCopy, requestTreePath);
const globalEnvironmentVariables = collection.globalEnvironmentVariables;
const promptVariables = collection.promptVariables;
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
collection,
request: requestCopy,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
const { oauth2: { grantType } } = requestCopy || {};
const { oauth2: { grantType, accessTokenUrl, refreshTokenUrl }, collectionVariables, folderVariables, requestVariables } = requestCopy || {};
// For OAuth2 token requests, use accessTokenUrl for cert/proxy config instead of main request URL
let certsAndProxyConfigForTokenUrl = null;
let certsAndProxyConfigForRefreshUrl = null;
if (accessTokenUrl && grantType !== 'implicit') {
const interpolatedTokenUrl = interpolateString(accessTokenUrl, {
globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
let tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl };
certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({
collectionUid,
collection,
request: tokenRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
}
// For refresh token requests, use refreshTokenUrl if available, otherwise accessTokenUrl
const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl;
if (tokenUrlForRefresh && grantType !== 'implicit') {
const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, {
globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
let refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl };
certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({
collectionUid,
collection,
request: refreshRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
}
const handleOAuth2Response = (response) => {
if (response.error && !response.debugInfo) {
@@ -1318,7 +1363,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
request: requestCopy,
collectionUid,
forceFetch: true,
certsAndProxyConfig
certsAndProxyConfigForTokenUrl,
certsAndProxyConfigForRefreshUrl
}).then(handleOAuth2Response);
case 'client_credentials':
@@ -1327,7 +1373,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
request: requestCopy,
collectionUid,
forceFetch: true,
certsAndProxyConfig
certsAndProxyConfigForTokenUrl,
certsAndProxyConfigForRefreshUrl
}).then(handleOAuth2Response);
case 'password':
@@ -1336,7 +1383,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
request: requestCopy,
collectionUid,
forceFetch: true,
certsAndProxyConfig
certsAndProxyConfigForTokenUrl,
certsAndProxyConfigForRefreshUrl
}).then(handleOAuth2Response);
case 'implicit':

View File

@@ -28,14 +28,20 @@ const getCertsAndProxyConfig = async ({
httpsAgentRequestFields['rejectUnauthorized'] = false;
}
let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath();
let caCertificatesData = getCACertificates({
caCertFilePath,
shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates()
});
let caCertificates = '';
let caCertificatesCount = { system: 0, root: 0, custom: 0, extra: 0 };
let caCertificates = caCertificatesData.caCertificates;
let caCertificatesCount = caCertificatesData.caCertificatesCount;
// Only load CA certificates if SSL validation is enabled (otherwise they're unused)
if (preferencesUtil.shouldVerifyTls()) {
let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath();
let caCertificatesData = getCACertificates({
caCertFilePath,
shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates()
});
caCertificates = caCertificatesData.caCertificates;
caCertificatesCount = caCertificatesData.caCertificatesCount;
}
// configure HTTPS agent with aggregated CA certificates
httpsAgentRequestFields['caCertificatesCount'] = caCertificatesCount;

View File

@@ -159,12 +159,66 @@ const configureRequest = async (
if (request.oauth2) {
let requestCopy = cloneDeep(request);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, accessTokenUrl, refreshTokenUrl } = {}, collectionVariables, folderVariables, requestVariables } = requestCopy || {};
// Get cert/proxy configs for token and refresh URLs
let certsAndProxyConfigForTokenUrl = certsAndProxyConfig;
let certsAndProxyConfigForRefreshUrl = certsAndProxyConfig;
if (accessTokenUrl && grantType !== 'implicit') {
const interpolatedTokenUrl = interpolateString(accessTokenUrl, {
globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
const tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl };
certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({
collectionUid,
collection,
request: tokenRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
}
const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl;
if (tokenUrlForRefresh && grantType !== 'implicit') {
const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, {
globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
const refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl };
certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({
collectionUid,
collection,
request: refreshRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
}
let credentials, credentialsId, oauth2Url, debugInfo;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig }));
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
@@ -192,7 +246,7 @@ const configureRequest = async (
break;
case 'client_credentials':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
@@ -206,7 +260,7 @@ const configureRequest = async (
break;
case 'password':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
@@ -1423,7 +1477,7 @@ const registerNetworkIpc = (mainWindow) => {
}
if (error?.response) {
error.response.data = await promisifyStream(error.response.data, currentAbortController, true);
error.response.data = await promisifyStream(error.response.data, currentAbortController, false);
const { data, dataBuffer } = parseDataFromResponse(error.response);
error.response.responseTime = error.response.headers.get('request-duration');
error.response.headers.delete('request-duration');

View File

@@ -4,6 +4,8 @@ const { getEnvVars, getTreePathFromCollectionToItem, mergeHeaders, mergeScripts,
const { getProcessEnvVars } = require('../../store/process-env');
const { getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingAuthorizationCode } = require('../../utils/oauth2');
const { setAuthHeaders } = require('./prepare-request');
const { getCertsAndProxyConfig } = require('./cert-utils');
const { interpolateString } = require('./interpolate-string');
const processHeaders = (headers) => {
Object.entries(headers).forEach(([key, value]) => {
@@ -30,25 +32,80 @@ const placeOAuth2Token = (grpcRequest, credentials, tokenPlacement, tokenHeaderP
const configureRequest = async (grpcRequest, request, collection, envVars, runtimeVariables, processEnvVars, promptVariables, certsAndProxyConfig) => {
if (grpcRequest.oauth2) {
let requestCopy = cloneDeep(grpcRequest);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
const { uid: collectionUid, pathname: collectionPath, globalEnvironmentVariables } = collection;
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, accessTokenUrl, refreshTokenUrl } = {}, collectionVariables, folderVariables, requestVariables } = requestCopy || {};
let credentials, credentialsId, oauth2Url, debugInfo;
// Get cert/proxy configs for token and refresh URLs
let certsAndProxyConfigForTokenUrl = certsAndProxyConfig;
let certsAndProxyConfigForRefreshUrl = certsAndProxyConfig;
if (accessTokenUrl && grantType !== 'implicit') {
const interpolatedTokenUrl = interpolateString(accessTokenUrl, {
globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
const tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl };
certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({
collectionUid,
collection,
request: tokenRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
}
const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl;
if (tokenUrlForRefresh && grantType !== 'implicit') {
const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, {
globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
const refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl };
certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({
collectionUid,
collection,
request: refreshRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
}
try {
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
placeOAuth2Token(grpcRequest, credentials, tokenPlacement, tokenHeaderPrefix, tokenQueryKey);
break;
case 'client_credentials':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
placeOAuth2Token(grpcRequest, credentials, tokenPlacement, tokenHeaderPrefix, tokenQueryKey);
break;
case 'password':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
placeOAuth2Token(grpcRequest, credentials, tokenPlacement, tokenHeaderPrefix, tokenQueryKey);
break;

View File

@@ -71,6 +71,7 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collection.uid);
const { promptVariables = {} } = collection;
let wsRequest = {
uid: item.uid,
@@ -94,7 +95,61 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
if (wsRequest.oauth2) {
let requestCopy = cloneDeep(wsRequest);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, accessTokenUrl, refreshTokenUrl } = {}, collectionVariables, folderVariables, requestVariables } = requestCopy || {};
// Get cert/proxy configs for token and refresh URLs
let certsAndProxyConfigForTokenUrl = certsAndProxyConfig;
let certsAndProxyConfigForRefreshUrl = certsAndProxyConfig;
if (accessTokenUrl && grantType !== 'implicit') {
const interpolatedTokenUrl = interpolateString(accessTokenUrl, {
globalEnvironmentVariables: request.globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
const tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl };
certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({
collectionUid: collection.uid,
collection,
request: tokenRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath: collection.pathname,
globalEnvironmentVariables: request.globalEnvironmentVariables
});
}
const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl;
if (tokenUrlForRefresh && grantType !== 'implicit') {
const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, {
globalEnvironmentVariables: request.globalEnvironmentVariables,
collectionVariables,
envVars,
folderVariables,
requestVariables,
runtimeVariables,
processEnvVars,
promptVariables
});
const refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl };
certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({
collectionUid: collection.uid,
collection,
request: refreshRequestForConfig,
envVars,
runtimeVariables,
processEnvVars,
collectionPath: collection.pathname,
globalEnvironmentVariables: request.globalEnvironmentVariables
});
}
let credentials, credentialsId, oauth2Url, debugInfo;
switch (grantType) {
@@ -108,7 +163,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
} = await getOAuth2TokenUsingAuthorizationCode({
request: requestCopy,
collectionUid: collection.uid,
certsAndProxyConfig
certsAndProxyConfigForTokenUrl,
certsAndProxyConfigForRefreshUrl
}));
wsRequest.oauth2Credentials = {
credentials,
@@ -138,7 +194,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
} = await getOAuth2TokenUsingClientCredentials({
request: requestCopy,
collectionUid: collection.uid,
certsAndProxyConfig
certsAndProxyConfigForTokenUrl,
certsAndProxyConfigForRefreshUrl
}));
wsRequest.oauth2Credentials = {
credentials,
@@ -168,7 +225,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
} = await getOAuth2TokenUsingPasswordCredentials({
request: requestCopy,
collectionUid: collection.uid,
certsAndProxyConfig
certsAndProxyConfigForTokenUrl,
certsAndProxyConfigForRefreshUrl
}));
wsRequest.oauth2Credentials = {
credentials,

View File

@@ -52,7 +52,7 @@ const safeParseJSONBuffer = (data) => {
const getCredentialsFromTokenUrl = async ({ requestConfig, certsAndProxyConfig }) => {
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
let requestDetails, parsedResponseData;
let requestDetails = { request: {}, response: {} }, parsedResponseData;
try {
const response = await axiosInstance(requestConfig);
const { url: responseUrl, headers: responseHeaders, status: responseStatus, statusText: responseStatusText, data: responseData, timeline, config } = response || {};
@@ -112,7 +112,7 @@ const getCredentialsFromTokenUrl = async ({ requestConfig, certsAndProxyConfig }
statusText: error?.code,
headers: {},
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
timeline: error?.timeline
}
};
}
@@ -132,7 +132,7 @@ const getCredentialsFromTokenUrl = async ({ requestConfig, certsAndProxyConfig }
// AUTHORIZATION CODE
const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }) => {
let codeVerifier = generateCodeVerifier();
let codeChallenge = generateCodeChallenge(codeVerifier);
@@ -204,7 +204,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
if (autoRefreshToken && storedCredentials.refresh_token) {
// Try to refresh token
try {
const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig: certsAndProxyConfigForRefreshUrl });
return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId };
} catch (error) {
// Refresh failed
@@ -281,7 +281,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
}
axiosRequestConfig.data = qs.stringify(data);
try {
const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig });
const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig: certsAndProxyConfigForTokenUrl });
// Ensure debugInfo.data is initialized
if (!debugInfo) {
@@ -366,7 +366,7 @@ const getAdditionalHeaders = (params) => {
// CLIENT CREDENTIALS
const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }) => {
let requestCopy = cloneDeep(request);
const oAuth = get(requestCopy, 'oauth2', {});
const {
@@ -414,7 +414,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo
if (autoRefreshToken && storedCredentials.refresh_token) {
// Try to refresh token
try {
const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig: certsAndProxyConfigForRefreshUrl });
return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId };
} catch (error) {
clearOauth2Credentials({ collectionUid, url, credentialsId });
@@ -483,7 +483,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo
axiosRequestConfig.data = qs.stringify(data);
let debugInfo = { data: [] };
try {
const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig });
const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig: certsAndProxyConfigForTokenUrl });
debugInfo.data.push(requestDetails);
credentials && persistOauth2Credentials({ collectionUid, url, credentials, credentialsId });
return { collectionUid, url, credentials, credentialsId, debugInfo };
@@ -494,7 +494,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo
// PASSWORD CREDENTIALS
const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }) => {
let requestCopy = cloneDeep(request);
const oAuth = get(requestCopy, 'oauth2', {});
const {
@@ -561,7 +561,7 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid,
if (autoRefreshToken && storedCredentials.refresh_token) {
// Try to refresh token
try {
const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig: certsAndProxyConfigForRefreshUrl });
return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId };
} catch (error) {
clearOauth2Credentials({ collectionUid, url, credentialsId });
@@ -633,7 +633,7 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid,
axiosRequestConfig.data = qs.stringify(data);
let debugInfo = { data: [] };
try {
const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig });
const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig: certsAndProxyConfigForTokenUrl });
debugInfo.data.push(requestDetails);
credentials && persistOauth2Credentials({ collectionUid, url, credentials, credentialsId });
return { collectionUid, url, credentials, credentialsId, debugInfo };

View File

@@ -1,6 +1,6 @@
const path = require('path');
const { isFile, isDirectory } = require('./filesystem');
const { get } = require('lodash');
const { transformProxyConfig } = require('@usebruno/requests');
function transformBrunoConfigBeforeSave(brunoConfig) {
// remove exists from importPaths and protoFiles
@@ -76,55 +76,7 @@ async function transformBrunoConfigAfterRead(brunoConfig, collectionPathname) {
// Migrate proxy configuration from old format to new format
if (brunoConfig.proxy) {
const proxy = brunoConfig.proxy || {};
// Check if this is an old format (has 'enabled' property)
if (proxy.hasOwnProperty('enabled')) {
const enabled = proxy.enabled;
let newProxy = {
inherit: true,
config: {
protocol: proxy.protocol || 'http',
hostname: proxy.hostname || '',
port: proxy.port || null,
auth: {
username: get(proxy, 'auth.username', ''),
password: get(proxy, 'auth.password', '')
},
bypassProxy: proxy.bypassProxy || ''
}
};
// Handle old format: enabled (true | false | 'global')
if (enabled === true) {
newProxy.disabled = false;
newProxy.inherit = false;
} else if (enabled === false) {
newProxy.disabled = true;
newProxy.inherit = false;
} else if (enabled === 'global') {
newProxy.disabled = false;
newProxy.inherit = true;
}
// Migrate auth.enabled to auth.disabled
if (get(proxy, 'auth.enabled') === false) {
newProxy.config.auth.disabled = true;
}
// If auth.enabled is true or undefined, omit disabled (defaults to false)
// Omit disabled: false at top level (optional field)
if (newProxy.disabled === false) {
delete newProxy.disabled;
}
// Omit auth.disabled: false (optional field)
if (newProxy.config.auth.disabled === false) {
delete newProxy.config.auth.disabled;
}
brunoConfig.proxy = newProxy;
}
brunoConfig.proxy = transformProxyConfig(brunoConfig.proxy);
}
return brunoConfig;

View File

@@ -7,7 +7,7 @@ const { jar: createCookieJar } = require('@usebruno/requests').cookies;
const variableNameRegex = /^[\w-.]*$/;
class Bru {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables) {
constructor(runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables) {
this.envVariables = envVariables || {};
this.runtimeVariables = runtimeVariables || {};
this.promptVariables = promptVariables || {};
@@ -20,6 +20,7 @@ class Bru {
this.collectionPath = collectionPath;
this.collectionName = collectionName;
this.sendRequest = sendRequest;
this.runtime = runtime;
this.cookies = {
jar: () => {
const cookieJar = createCookieJar();
@@ -230,7 +231,7 @@ class Bru {
);
}
this.runtimeVariables[key] = this.interpolate(value);
this.runtimeVariables[key] = value;
}
getVar(key) {
@@ -279,6 +280,10 @@ class Bru {
getCollectionName() {
return this.collectionName;
}
isSafeMode() {
return this.runtime === 'quickjs';
}
}
module.exports = Bru;

View File

@@ -257,6 +257,7 @@ class AssertRuntime {
const promptVariables = request?.promptVariables || {};
const bru = new Bru(
this.runtime,
envVariables,
runtimeVariables,
processEnvVars,

View File

@@ -33,7 +33,7 @@ class ScriptRuntime {
const requestVariables = request?.requestVariables || {};
const promptVariables = request?.promptVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables);
const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables);
const req = new BrunoRequest(request);
// extend bru with result getter methods
@@ -128,7 +128,7 @@ class ScriptRuntime {
const requestVariables = request?.requestVariables || {};
const promptVariables = request?.promptVariables || {};
const assertionResults = request?.assertionResults || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables);
const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);

View File

@@ -32,7 +32,7 @@ class TestRuntime {
const requestVariables = request?.requestVariables || {};
const promptVariables = request?.promptVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName, promptVariables);
const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName, promptVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);

View File

@@ -36,7 +36,7 @@ class VarsRuntime {
}
const promptVariables = request?.promptVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, undefined, promptVariables);
const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, undefined, promptVariables);
const req = new BrunoRequest(request);
const res = createResponseParser(response);

View File

@@ -36,8 +36,6 @@ async function runScriptInNodeVm({
}
try {
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
// Compute additional context roots
const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
const additionalContextRootsAbsolute = lodash
@@ -88,7 +86,6 @@ async function runScriptInNodeVm({
scriptContext,
currentModuleDir: collectionPath,
localModuleCache,
allowScriptFilesystemAccess,
additionalContextRootsAbsolute
});
@@ -116,7 +113,6 @@ async function runScriptInNodeVm({
* @param {Object} options.scriptContext - Script execution context
* @param {string} options.currentModuleDir - Current module directory for relative imports
* @param {Map} options.localModuleCache - Cache for loaded local modules
* @param {boolean} options.allowScriptFilesystemAccess - Whether to allow fs module access
* @param {Array<string>} options.additionalContextRootsAbsolute - Pre-computed absolute context roots
* @returns {Function} Custom require function
*/
@@ -126,7 +122,6 @@ function createCustomRequire({
scriptContext,
currentModuleDir = collectionPath,
localModuleCache = new Map(),
allowScriptFilesystemAccess = false,
additionalContextRootsAbsolute = []
}) {
return (moduleName) => {
@@ -137,40 +132,11 @@ function createCustomRequire({
return loadLocalModule({ moduleName: normalizedModuleName, collectionPath, scriptContext, localModuleCache, currentModuleDir, additionalContextRootsAbsolute });
}
// Helper function to check if a module is the fs module or a submodule
const isFsModule = (module) => {
if (!module) return false;
const fsModule = require('fs');
// Check if it's the fs module itself
if (module === fsModule) return true;
// Check if it's fs/promises submodule
if (module === fsModule.promises) return true;
// Check if it's fs/promises by comparing with require('fs/promises')
try {
if (module === require('fs/promises')) return true;
} catch {
// fs/promises might not be available in all Node versions
}
return false;
};
// First try to require as a native/npm module
try {
const requiredModulePath = require.resolve(moduleName, { paths: [...additionalContextRootsAbsolute, ...module.paths] });
const requiredModule = require(requiredModulePath);
// Block filesystem module access if filesystem access is not allowed
if (!allowScriptFilesystemAccess && isFsModule(requiredModule)) {
throw new Error('Filesystem access is not allowed. Enable "filesystemAccess.allow" in scripting config to use the fs module.');
}
return requiredModule;
return require(requiredModulePath);
} catch (requireError) {
// Re-throw if it's our filesystem access error
if (requireError.message && requireError.message.includes('Enable "filesystemAccess.allow"')) {
throw requireError;
}
// If that fails, try to resolve from additionalContextRoots
throw new Error(`Could not resolve module "${moduleName}": ${requireError.message}\n\nThis most likely means you did not install the module under the collection or the "additionalContextRoots" using a package manager like npm.\n\nThese are your current "additionalContextRoots":\n${additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n') || ' - No "additionalContextRoots" defined'}`);
}
@@ -251,7 +217,6 @@ function loadLocalModule({
scriptContext,
currentModuleDir: moduleDir,
localModuleCache,
allowScriptFilesystemAccess: get(scriptContext.scriptingConfig, 'filesystemAccess.allow', false),
additionalContextRootsAbsolute
})
};

View File

@@ -23,6 +23,12 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getCollectionName', getCollectionName);
getCollectionName.dispose();
let isSafeMode = vm.newFunction('isSafeMode', function () {
return marshallToVm(bru.isSafeMode(), vm);
});
vm.setProp(bruObject, 'isSafeMode', isSafeMode);
isSafeMode.dispose();
let getProcessEnv = vm.newFunction('getProcessEnv', function (key) {
return marshallToVm(bru.getProcessEnv(vm.dump(key)), vm);
});

View File

@@ -248,14 +248,14 @@ describe('runtime', () => {
});
describe('bru.setVar random variable', () => {
it('should not be equal to {{$randomFirstName}}', async () => {
it('should be able to set random variables as values', async () => {
const script = `bru.setVar('title', '{{$randomFirstName}}')`;
const runtime = new ScriptRuntime({ runtime: 'nodevm' });
const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
expect(result.runtimeVariables.title).not.toBe('{{$randomFirstName}}');
expect(result.runtimeVariables.title).toBe('{{$randomFirstName}}');
});
});
});

View File

@@ -3,6 +3,7 @@ const Bru = require('../src/bru');
describe('Bru.setEnvVar', () => {
const makeBru = () =>
new Bru(
/* runtime */ 'quickjs',
/* envVariables */ {},
/* runtimeVariables */ {},
/* processEnvVars */ {},

View File

@@ -28,7 +28,10 @@
"debug": "^4.4.3",
"google-protobuf": "^4.0.0",
"grpc-js-reflection-client": "^1.3.0",
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"is-ip": "^5.0.1",
"socks-proxy-agent": "~8.0.5",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",
"ws": "^8.18.3"

View File

@@ -1,4 +1,4 @@
import axios, { AxiosRequestConfig, ResponseType } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig, ResponseType } from 'axios';
import qs from 'qs';
import debug from 'debug';
@@ -107,7 +107,7 @@ const safeParseJSONBuffer = (data: any) => {
/**
* Fetches an OAuth2 token using client credentials grant
*/
const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => {
const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config, axiosInstance?: AxiosInstance) => {
const {
accessTokenUrl,
clientId,
@@ -167,7 +167,8 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => {
debug('oauth2')(JSON.stringify(requestConfig, null, 2));
try {
const response = await axios(requestConfig);
const httpClient = axiosInstance || axios;
const response = await httpClient(requestConfig);
const parsedData = safeParseJSONBuffer(response.data);
if (parsedData && typeof parsedData === 'object') {
@@ -197,7 +198,7 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => {
/**
* Fetches an OAuth2 token using password grant
*/
const fetchTokenPassword = async (oauth2Config: OAuth2Config) => {
const fetchTokenPassword = async (oauth2Config: OAuth2Config, axiosInstance?: AxiosInstance) => {
const {
accessTokenUrl,
clientId,
@@ -269,7 +270,8 @@ const fetchTokenPassword = async (oauth2Config: OAuth2Config) => {
debug('oauth2')(JSON.stringify(requestConfig, null, 2));
try {
const response = await axios(requestConfig);
const httpClient = axiosInstance || axios;
const response = await httpClient(requestConfig);
const parsedData = safeParseJSONBuffer(response.data);
if (parsedData && typeof parsedData === 'object') {
@@ -313,7 +315,7 @@ const isTokenExpired = (credentials: any): boolean => {
/**
* Manages OAuth2 token retrieval and storage
*/
export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string): Promise<string | null> => {
export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string, axiosInstance?: AxiosInstance): Promise<string | null> => {
const {
grantType,
accessTokenUrl,
@@ -367,9 +369,9 @@ export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: Tok
let tokenResponse;
if (grantType === 'client_credentials') {
tokenResponse = await fetchTokenClientCredentials(oauth2Config);
tokenResponse = await fetchTokenClientCredentials(oauth2Config, axiosInstance);
} else if (grantType === 'password') {
tokenResponse = await fetchTokenPassword(oauth2Config);
tokenResponse = await fetchTokenPassword(oauth2Config, axiosInstance);
} else {
throw new Error(`Unsupported grant type: ${grantType}`);
}

View File

@@ -528,8 +528,19 @@ class GrpcClient {
}
}
// Extract user-agent from headers if provided (case-insensitive)
// Set it as grpc.primary_user_agent channel option to prepend to the default user-agent
const userAgentKey = Object.keys(request.headers).find(
(key) => key.toLowerCase() === 'user-agent'
);
const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null;
const mergedChannelOptions = userAgentValue
? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions }
: channelOptions;
const Client = makeGenericClientConstructor({});
const client = new Client(host, credentials, channelOptions);
const client = new Client(host, credentials, mergedChannelOptions);
if (!client) {
throw new Error('Failed to create client');
}
@@ -612,9 +623,19 @@ class GrpcClient {
passphrase,
pfx,
verifyOptions,
sendEvent
sendEvent,
channelOptions = {}
}) {
const { host, path } = getParsedGrpcUrlObject(request.url);
// Extract user-agent from headers if provided (case-insensitive)
// Set it as grpc.primary_user_agent channel option to prepend to the default user-agent
const userAgentKey = Object.keys(request.headers).find(
(key) => key.toLowerCase() === 'user-agent'
);
const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null;
const mergedChannelOptions = userAgentValue ? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions } : channelOptions;
const metadata = new Metadata();
Object.entries(request.headers).forEach(([name, value]) => {
metadata.add(name, value);
@@ -630,7 +651,7 @@ class GrpcClient {
});
try {
const { client, services, callOptions } = await this.#getReflectionClient(host, credentials, metadata, {});
const { client, services, callOptions } = await this.#getReflectionClient(host, credentials, metadata, mergedChannelOptions);
const methods = [];
for (const service of services) {

View File

@@ -0,0 +1,508 @@
/**
* @jest-environment node
*/
// Store captured channel options for assertions
let capturedChannelOptions = null;
// Mock GrpcReflection to capture options
const mockListServices = jest.fn().mockResolvedValue(['test.Service']);
const mockListMethods = jest.fn().mockResolvedValue([
{
path: '/test.Service/TestMethod',
definition: {
requestStream: false,
responseStream: false
}
}
]);
jest.mock('grpc-js-reflection-client', () => ({
GrpcReflection: jest.fn().mockImplementation((host, credentials, options) => {
capturedChannelOptions = options;
return {
listServices: mockListServices,
listMethods: mockListMethods
};
})
}));
// Mock @grpc/grpc-js
jest.mock('@grpc/grpc-js', () => {
const createMockMetadata = () => {
const map = {};
return {
add: jest.fn((key, value) => {
if (map[key] === undefined) {
map[key] = value;
} else if (Array.isArray(map[key])) {
map[key].push(value);
} else {
map[key] = [map[key], value];
}
}),
getMap: jest.fn(() => map)
};
};
// Create a mock RPC object with event emitter interface
const createMockRpc = () => {
const handlers = {};
const mockRpc = {
on: jest.fn((event, handler) => {
handlers[event] = handler;
return mockRpc; // Return the mock object for chaining
}),
write: jest.fn(),
end: jest.fn(),
cancel: jest.fn(),
call: {
channel: { close: jest.fn() }
}
};
return mockRpc;
};
return {
makeGenericClientConstructor: jest.fn(() => {
return jest.fn().mockImplementation((host, credentials, options) => {
capturedChannelOptions = options;
const mockRpc = createMockRpc();
return {
close: jest.fn(),
makeUnaryRequest: jest.fn().mockReturnValue(mockRpc),
makeClientStreamRequest: jest.fn().mockReturnValue(mockRpc),
makeServerStreamRequest: jest.fn().mockReturnValue(mockRpc),
makeBidiStreamRequest: jest.fn().mockReturnValue(mockRpc)
};
});
}),
ChannelCredentials: {
createInsecure: jest.fn().mockReturnValue('insecure-credentials'),
createSsl: jest.fn().mockReturnValue('ssl-credentials'),
createFromSecureContext: jest.fn().mockReturnValue('secure-context-credentials')
},
Metadata: jest.fn().mockImplementation(() => createMockMetadata()),
status: {},
credentials: {},
CallCredentials: {
createFromMetadataGenerator: jest.fn().mockReturnValue('call-credentials')
}
};
});
// Mock proto-loader
jest.mock('@grpc/proto-loader', () => ({
load: jest.fn().mockResolvedValue({})
}));
import { GrpcClient } from './grpc-client';
describe('GrpcClient', () => {
let grpcClient;
let mockEventCallback;
beforeEach(() => {
jest.clearAllMocks();
capturedChannelOptions = null;
mockEventCallback = jest.fn();
grpcClient = new GrpcClient(mockEventCallback);
});
describe('User-Agent behavior in loadMethodsFromReflection', () => {
const baseRequest = {
url: 'grpc://localhost:50051',
uid: 'test-request-uid',
headers: {}
};
const baseParams = {
collectionUid: 'test-collection-uid',
sendEvent: jest.fn()
};
describe('case-insensitive header extraction', () => {
test('should extract User-Agent header (capitalized)', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
test('should extract user-agent header (lowercase)', async () => {
const request = {
...baseRequest,
headers: { 'user-agent': 'Bruno/1.0' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
test('should extract USER-AGENT header (uppercase)', async () => {
const request = {
...baseRequest,
headers: { 'USER-AGENT': 'Bruno/1.0' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
test('should extract uSeR-aGeNt header (mixed case)', async () => {
const request = {
...baseRequest,
headers: { 'uSeR-aGeNt': 'Bruno/1.0' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
});
describe('channel options merging', () => {
test('should preserve existing channelOptions when user-agent is set', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams,
channelOptions: {
'grpc.max_receive_message_length': 1024 * 1024,
'grpc.keepalive_time_ms': 30000
}
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
expect(capturedChannelOptions['grpc.keepalive_time_ms']).toBe(30000);
});
test('should include grpc.primary_user_agent in merged options alongside other options', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams,
channelOptions: {
'grpc.other_option': 'value'
}
});
// Use array notation for keys containing dots to avoid Jest interpreting as nested path
expect(capturedChannelOptions).toHaveProperty(['grpc.primary_user_agent'], 'Bruno/1.0');
expect(capturedChannelOptions).toHaveProperty(['grpc.other_option'], 'value');
});
test('should allow channelOptions to override grpc.primary_user_agent', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams,
channelOptions: {
'grpc.primary_user_agent': 'ExistingUA'
}
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('ExistingUA');
});
});
describe('missing user-agent handling', () => {
test('should pass channelOptions unchanged when no user-agent header', async () => {
const request = {
...baseRequest,
headers: { 'Content-Type': 'application/grpc' }
};
const channelOptions = {
'grpc.max_receive_message_length': 1024 * 1024
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams,
channelOptions
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
});
test('should pass empty object when no user-agent and no channelOptions', async () => {
const request = {
...baseRequest,
headers: {}
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
});
test('should not add grpc.primary_user_agent when user-agent header is missing', async () => {
const request = {
...baseRequest,
headers: { Authorization: 'Bearer token' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams,
channelOptions: {}
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
expect(Object.keys(capturedChannelOptions)).not.toContain('grpc.primary_user_agent');
});
});
describe('edge cases', () => {
test('should handle empty user-agent value', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': '' }
};
await grpcClient.loadMethodsFromReflection({
request,
...baseParams
});
// Empty string is falsy, so grpc.primary_user_agent should not be set
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
});
});
});
describe('User-Agent behavior in startConnection', () => {
const baseRequest = {
url: 'grpc://localhost:50051',
uid: 'test-request-uid',
method: '/test.Service/TestMethod',
headers: {},
body: {
grpc: [{ content: '{}' }]
}
};
const baseCollection = {
uid: 'test-collection-uid',
pathname: '/test/path'
};
beforeEach(() => {
// Pre-register a method so startConnection can find it
grpcClient.methods.set('/test.Service/TestMethod', {
path: '/test.Service/TestMethod',
requestStream: false,
responseStream: false,
requestSerialize: (val) => Buffer.from(JSON.stringify(val)),
responseDeserialize: (val) => JSON.parse(val.toString())
});
});
describe('case-insensitive header extraction', () => {
test('should extract User-Agent header (capitalized)', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.startConnection({
request,
collection: baseCollection
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
test('should extract user-agent header (lowercase)', async () => {
const request = {
...baseRequest,
headers: { 'user-agent': 'Bruno/1.0' }
};
await grpcClient.startConnection({
request,
collection: baseCollection
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
test('should extract USER-AGENT header (uppercase)', async () => {
const request = {
...baseRequest,
headers: { 'USER-AGENT': 'Bruno/1.0' }
};
await grpcClient.startConnection({
request,
collection: baseCollection
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
test('should extract uSeR-aGeNt header (mixed case)', async () => {
const request = {
...baseRequest,
headers: { 'uSeR-aGeNt': 'Bruno/1.0' }
};
await grpcClient.startConnection({
request,
collection: baseCollection
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
});
});
describe('channel options merging', () => {
test('should preserve existing channelOptions when user-agent is set', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.startConnection({
request,
collection: baseCollection,
channelOptions: {
'grpc.max_receive_message_length': 1024 * 1024,
'grpc.keepalive_time_ms': 30000
}
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
expect(capturedChannelOptions['grpc.keepalive_time_ms']).toBe(30000);
});
test('should include grpc.primary_user_agent in merged options alongside other options', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.startConnection({
request,
collection: baseCollection,
channelOptions: {
'grpc.other_option': 'value'
}
});
// Use array notation for keys containing dots to avoid Jest interpreting as nested path
expect(capturedChannelOptions).toHaveProperty(['grpc.primary_user_agent'], 'Bruno/1.0');
expect(capturedChannelOptions).toHaveProperty(['grpc.other_option'], 'value');
});
test('should allow channelOptions to override grpc.primary_user_agent', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': 'Bruno/1.0' }
};
await grpcClient.startConnection({
request,
collection: baseCollection,
channelOptions: {
'grpc.primary_user_agent': 'ExistingUA'
}
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('ExistingUA');
});
});
describe('missing user-agent handling', () => {
test('should pass channelOptions unchanged when no user-agent header', async () => {
const request = {
...baseRequest,
headers: { 'Content-Type': 'application/grpc' }
};
const channelOptions = {
'grpc.max_receive_message_length': 1024 * 1024
};
await grpcClient.startConnection({
request,
collection: baseCollection,
channelOptions
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
});
test('should not add grpc.primary_user_agent when user-agent header is missing', async () => {
const request = {
...baseRequest,
headers: { Authorization: 'Bearer token' }
};
await grpcClient.startConnection({
request,
collection: baseCollection,
channelOptions: {}
});
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
expect(Object.keys(capturedChannelOptions)).not.toContain('grpc.primary_user_agent');
});
});
describe('edge cases', () => {
test('should handle empty user-agent value', async () => {
const request = {
...baseRequest,
headers: { 'User-Agent': '' }
};
await grpcClient.startConnection({
request,
collection: baseCollection
});
// Empty string is falsy, so grpc.primary_user_agent should not be set
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
});
});
});
});

View File

@@ -4,7 +4,11 @@ export { WsClient } from './ws/ws-client';
export { default as cookies } from './cookies';
export { getCACertificates } from './utils/ca-cert';
export { transformProxyConfig } from './utils/proxy-util';
export { default as createVaultClient, VaultError } from './utils/node-vault';
export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault';
export { getHttpHttpsAgents } from './utils/http-https-agents';
export * as scripting from './scripting';
export { makeAxiosInstance } from './network/axios-instance';

View File

@@ -0,0 +1,445 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import https from 'node:https';
import type { Agent as HttpAgent } from 'node:http';
import type { Agent as HttpsAgent } from 'node:https';
import { parse as parseUrl, type Url } from 'url';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { SocksProxyAgent } from 'socks-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent';
import { isEmpty, get, isUndefined, isNull } from 'lodash';
import { getCACertificates } from './ca-cert';
import { transformProxyConfig } from './proxy-util';
const DEFAULT_PORTS: Record<string, number> = {
ftp: 21,
gopher: 70,
http: 80,
https: 443,
ws: 80,
wss: 443
};
type ProxyMode = 'on' | 'off' | 'system';
type ProxyAuth = {
enabled: boolean;
username?: string;
password?: string;
};
type ProxyConfig = {
enabled?: boolean | 'global';
protocol?: string;
hostname?: string;
port?: number | null;
auth?: ProxyAuth;
bypassProxy?: string;
mode?: ProxyMode;
};
type SystemProxyConfig = {
http_proxy?: string;
https_proxy?: string;
no_proxy?: string;
};
type ClientCertificate = {
domain?: string;
type?: 'cert' | 'pfx';
certFilePath?: string;
keyFilePath?: string;
pfxFilePath?: string;
passphrase?: string;
};
type CACertificatesCount = {
system: number;
root: number;
custom: number;
extra: number;
};
type CertsConfig = {
caCertificatesCount?: CACertificatesCount;
ca?: string | string[];
cert?: Buffer;
key?: Buffer;
pfx?: Buffer;
passphrase?: string;
};
type HttpsAgentRequestFields = {
keepAlive?: boolean;
rejectUnauthorized?: boolean;
caCertificatesCount?: CACertificatesCount;
ca?: string | string[];
};
type TlsOptions = HttpsAgentRequestFields & CertsConfig & {
secureProtocol?: string;
minVersion?: string;
ALPNProtocols?: string[];
};
type AgentResult = {
httpAgent?: HttpAgent;
httpsAgent?: HttpsAgent | HttpsProxyAgent<any> | SocksProxyAgent;
};
type ConfigOptions = {
noproxy: boolean;
shouldVerifyTls: boolean;
shouldUseCustomCaCertificate: boolean;
customCaCertificateFilePath?: string;
shouldKeepDefaultCaCertificates: boolean;
};
type GetCertsAndProxyConfigParams = {
requestUrl?: string;
collectionPath: string;
options: ConfigOptions;
clientCertificates?: {
certs?: ClientCertificate[];
};
collectionLevelProxy?: ProxyConfig;
systemProxyConfig?: SystemProxyConfig;
};
type GetCertsAndProxyConfigResult = {
proxyMode: ProxyMode;
proxyConfig: ProxyConfig;
certsConfig: CertsConfig;
};
type CreateAgentsParams = {
requestUrl?: string;
proxyMode: ProxyMode;
proxyConfig: ProxyConfig;
certsConfig: CertsConfig;
httpsAgentRequestFields: HttpsAgentRequestFields;
systemProxyConfig?: SystemProxyConfig;
};
type GetHttpHttpsAgentsParams = {
requestUrl?: string;
collectionPath: string;
options: ConfigOptions;
clientCertificates?: {
certs?: ClientCertificate[];
};
collectionLevelProxy?: ProxyConfig;
systemProxyConfig?: SystemProxyConfig;
};
/**
* check for proxy bypass, copied from 'proxy-from-env'
*/
const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined): boolean => {
if (proxyBypass === '*') {
return false; // Never proxy if wildcard is set.
}
// use proxy if no proxyBypass is set
if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) {
return true;
}
const parsedUrl: Url | {} = typeof url === 'string' ? parseUrl(url) : (url ? (url as unknown as Url) : {});
const urlObj = parsedUrl as Url;
let proto = urlObj.protocol;
let hostname = urlObj.host;
let port: string | null = urlObj.port;
if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') {
return false; // Don't proxy URLs without a valid scheme or host.
}
proto = proto.split(':', 1)[0];
// Stripping ports in this way instead of using parsedUrl.hostname to make
// sure that the brackets around IPv6 addresses are kept.
hostname = hostname.replace(/:\d*$/, '');
const portNum = parseInt(port || '', 10) || DEFAULT_PORTS[proto] || 0;
return proxyBypass.split(/[,;\s]/).every(function (dontProxyFor) {
if (!dontProxyFor) {
return true; // Skip zero-length hosts.
}
const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/);
let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor;
const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2], 10) : 0;
if (parsedProxyPort && parsedProxyPort !== portNum) {
return true; // Skip if ports don't match.
}
if (!/^[.*]/.test(parsedProxyHostname)) {
// No wildcards, so stop proxying if there is an exact match.
return hostname !== parsedProxyHostname;
}
if (parsedProxyHostname.charAt(0) === '*') {
// Remove leading wildcard.
parsedProxyHostname = parsedProxyHostname.slice(1);
}
// Stop proxying if the hostname ends with the no_proxy host.
return !hostname.endsWith(parsedProxyHostname);
});
};
/**
* Patched version of HttpsProxyAgent to get around a bug that ignores options
* such as ca and rejectUnauthorized when upgrading the proxied socket to TLS:
* https://github.com/TooTallNate/proxy-agents/issues/194
*/
class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
private constructorOpts: any;
constructor(proxy: string, opts: any) {
super(proxy, opts);
this.constructorOpts = opts;
}
async connect(req: any, opts: any) {
const combinedOpts = { ...this.constructorOpts, ...opts };
return super.connect(req, combinedOpts);
}
}
const getCertsAndProxyConfig = ({
requestUrl,
collectionPath,
options,
clientCertificates,
collectionLevelProxy,
systemProxyConfig
}: GetCertsAndProxyConfigParams): GetCertsAndProxyConfigResult => {
const certsConfig: CertsConfig = {};
const caCertFilePath = options.shouldUseCustomCaCertificate && options.customCaCertificateFilePath ? options.customCaCertificateFilePath : undefined;
const caCertificatesData = getCACertificates({
caCertFilePath,
shouldKeepDefaultCerts: options.shouldKeepDefaultCaCertificates
});
const caCertificates = caCertificatesData.caCertificates;
const caCertificatesCount = caCertificatesData.caCertificatesCount;
// configure HTTPS agent with aggregated CA certificates
certsConfig.caCertificatesCount = caCertificatesCount;
certsConfig.ca = caCertificates || [];
// client certificate config
const clientCertConfig = get(clientCertificates, 'certs', []) as ClientCertificate[];
for (const clientCert of clientCertConfig) {
const domain = clientCert?.domain;
const type = clientCert?.type || 'cert';
if (domain) {
const hostRegex = '^(https:\\/\\/|grpc:\\/\\/|grpcs:\\/\\/)?' + domain.replace(/\./g, '\\.').replace(/\*/g, '.*');
if (requestUrl && requestUrl.match(hostRegex)) {
if (type === 'cert') {
try {
let certFilePath = clientCert?.certFilePath;
if (!certFilePath) {
throw new Error('certFilePath is required for cert type');
}
certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath);
let keyFilePath = clientCert?.keyFilePath;
if (!keyFilePath) {
throw new Error('keyFilePath is required for cert type');
}
keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath);
certsConfig.cert = fs.readFileSync(certFilePath);
certsConfig.key = fs.readFileSync(keyFilePath);
} catch (err: any) {
console.error('Error reading cert/key file', err);
throw new Error(`Error reading cert/key file: ${err.message}`);
}
} else if (type === 'pfx') {
try {
let pfxFilePath = clientCert?.pfxFilePath;
if (!pfxFilePath) {
throw new Error('pfxFilePath is required for pfx type');
}
pfxFilePath = path.isAbsolute(pfxFilePath) ? pfxFilePath : path.join(collectionPath, pfxFilePath);
certsConfig.pfx = fs.readFileSync(pfxFilePath);
} catch (err: any) {
console.error('Error reading pfx file', err);
throw new Error(`Error reading pfx file: ${err.message}`);
}
}
certsConfig.passphrase = clientCert.passphrase;
break;
}
}
}
/**
* Proxy configuration
*
* Preferences proxyMode has three possible values: on, off, system
* Collection proxyMode has three possible values: true, false, global
*
* When collection proxyMode is true, it overrides the app-level proxy settings
* When collection proxyMode is false, it ignores the app-level proxy settings
* When collection proxyMode is global, it uses the app-level proxy settings
*
* Below logic calculates the proxyMode and proxyConfig to be used for the request
*/
let proxyMode: ProxyMode = 'off';
let proxyConfig: ProxyConfig = {};
const collectionProxyConfig = transformProxyConfig(collectionLevelProxy || {}) as ProxyConfig;
const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false);
const collectionProxyInherit = get(collectionProxyConfig, 'inherit', true);
const collectionProxyConfigData = get(collectionProxyConfig, 'config', {});
if (options.noproxy || collectionProxyDisabled) {
// If noproxy flag is set or collection proxy is disabled, don't use any proxy
proxyMode = 'off';
} else if (!collectionProxyDisabled && !collectionProxyInherit) {
// Use collection-specific proxy
proxyConfig = collectionProxyConfigData;
proxyMode = 'on';
} else if (!collectionProxyDisabled && collectionProxyInherit) {
// Inherit from system proxy
const { http_proxy, https_proxy } = systemProxyConfig || {};
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
// else: no system proxy available, proxyMode stays 'off'
}
// else: collection proxy is disabled, proxyMode stays 'off'
return { proxyMode, proxyConfig, certsConfig };
};
function createAgents({
requestUrl,
proxyMode,
proxyConfig,
systemProxyConfig,
certsConfig,
httpsAgentRequestFields
}: CreateAgentsParams): AgentResult {
// Ensure TLS options are properly set
const tlsOptions: TlsOptions = {
...httpsAgentRequestFields,
...certsConfig,
// Enable all secure protocols by default
secureProtocol: undefined,
// Allow Node.js to choose the protocol
minVersion: 'TLSv1',
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true
};
let httpAgent: HttpAgent | undefined;
let httpsAgent: HttpsAgent | HttpsProxyAgent<any> | SocksProxyAgent | undefined;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = get(proxyConfig, 'protocol');
const proxyHostname = get(proxyConfig, 'hostname');
const proxyPort = get(proxyConfig, 'port');
const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
const socksEnabled = proxyProtocol && proxyProtocol.includes('socks');
if (!proxyProtocol || !proxyHostname) {
throw new Error('Proxy protocol and hostname are required when proxy is enabled');
}
const uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri: string;
if (proxyAuthEnabled) {
const proxyAuthUsername = encodeURIComponent(get(proxyConfig, 'auth.username', ''));
const proxyAuthPassword = encodeURIComponent(get(proxyConfig, 'auth.password', ''));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
if (socksEnabled) {
httpAgent = new SocksProxyAgent(proxyUri);
httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any);
} else {
httpAgent = new HttpProxyAgent(proxyUri);
httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions);
}
} else {
// If proxy should not be used, set default HTTPS agent
httpsAgent = new https.Agent(tlsOptions as any);
}
} else if (proxyMode === 'system') {
const http_proxy = get(systemProxyConfig, 'http_proxy');
const https_proxy = get(systemProxyConfig, 'https_proxy');
const no_proxy = get(systemProxyConfig, 'no_proxy');
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length) {
new URL(http_proxy);
httpAgent = new HttpProxyAgent(http_proxy);
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length) {
new URL(https_proxy);
httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any);
} else {
httpsAgent = new https.Agent(tlsOptions as any);
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
httpsAgent = new https.Agent(tlsOptions as any);
}
} else {
httpsAgent = new https.Agent(tlsOptions as any);
}
return { httpAgent, httpsAgent };
}
const getHttpHttpsAgents = async ({
requestUrl,
collectionPath,
clientCertificates,
collectionLevelProxy,
systemProxyConfig,
options
}: GetHttpHttpsAgentsParams): Promise<AgentResult> => {
const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({
requestUrl,
collectionPath,
clientCertificates,
collectionLevelProxy,
systemProxyConfig,
options
});
/**
* @see https://github.com/usebruno/bruno/issues/211 set keepAlive to true, this should fix socket hang up errors
* @see https://github.com/nodejs/node/pull/43522 keepAlive was changed to true globally on Node v19+
*/
const httpsAgentRequestFields: HttpsAgentRequestFields = { keepAlive: true };
if (!options.shouldVerifyTls) {
httpsAgentRequestFields.rejectUnauthorized = false;
}
const { httpAgent, httpsAgent } = createAgents({
requestUrl,
proxyMode,
proxyConfig,
systemProxyConfig,
certsConfig,
httpsAgentRequestFields
});
return { httpAgent, httpsAgent };
};
export { getHttpHttpsAgents };

View File

@@ -0,0 +1,336 @@
import { transformProxyConfig } from './proxy-util';
describe('transformProxyConfig', () => {
describe('Migration from old to new format', () => {
describe('Old Format: enabled (true | false | "global")', () => {
test('should migrate enabled: true to disabled: false, inherit: false', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
enabled: true,
username: 'user',
password: 'pass'
},
bypassProxy: 'localhost'
};
const result = transformProxyConfig(oldConfig);
expect(result).toEqual({
inherit: false,
config: {
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
username: 'user',
password: 'pass'
},
bypassProxy: 'localhost'
}
});
expect((result as any).disabled).toBeUndefined(); // disabled: false is omitted
});
test('should migrate enabled: false to disabled: true, inherit: false', () => {
const oldConfig = {
enabled: false,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
enabled: false,
username: '',
password: ''
},
bypassProxy: ''
};
const result = transformProxyConfig(oldConfig);
expect((result as any).disabled).toBe(true);
expect((result as any).inherit).toBe(false);
});
test('should migrate enabled: "global" to disabled: false, inherit: true', () => {
const oldConfig = {
enabled: 'global' as const,
protocol: 'http',
hostname: '',
port: null,
auth: {
enabled: false,
username: '',
password: ''
},
bypassProxy: ''
};
const result = transformProxyConfig(oldConfig);
expect((result as any).disabled).toBeUndefined(); // disabled: false is omitted
expect((result as any).inherit).toBe(true);
});
test('should migrate auth.enabled: false to auth.disabled: true', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
enabled: false,
username: 'user',
password: 'pass'
},
bypassProxy: ''
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.auth.disabled).toBe(true);
expect((result as any).config.auth.username).toBe('user');
expect((result as any).config.auth.password).toBe('pass');
});
test('should omit auth.disabled when auth.enabled: true', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
enabled: true,
username: 'user',
password: 'pass'
},
bypassProxy: ''
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.auth.disabled).toBeUndefined();
expect((result as any).config.auth.username).toBe('user');
expect((result as any).config.auth.password).toBe('pass');
});
});
describe('New Format (no migration)', () => {
test('should not modify new format with inherit: false', () => {
const newConfig = {
inherit: false,
config: {
protocol: 'https',
hostname: 'proxy.example.com',
port: 8443,
auth: {
username: 'user',
password: 'pass'
},
bypassProxy: '*.local'
}
};
const result = transformProxyConfig(newConfig);
expect(result).toEqual(newConfig);
});
test('should not modify new format with inherit: true', () => {
const newConfig = {
inherit: true,
config: {
protocol: 'http',
hostname: '',
port: null,
auth: {
username: '',
password: ''
},
bypassProxy: ''
}
};
const result = transformProxyConfig(newConfig);
expect(result).toEqual(newConfig);
});
test('should not modify new format with disabled: true', () => {
const newConfig = {
disabled: true,
inherit: false,
config: {
protocol: 'http',
hostname: '',
port: null,
auth: {
username: '',
password: ''
},
bypassProxy: ''
}
};
const result = transformProxyConfig(newConfig);
expect(result).toEqual(newConfig);
});
test('should not modify new format with auth.disabled: true', () => {
const newConfig = {
inherit: false,
config: {
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
disabled: true,
username: 'user',
password: 'pass'
},
bypassProxy: ''
}
};
const result = transformProxyConfig(newConfig);
expect(result).toEqual(newConfig);
});
});
describe('Edge Cases', () => {
test('should handle missing/null/undefined proxy config', () => {
expect(transformProxyConfig(null)).toEqual({});
expect(transformProxyConfig(undefined)).toEqual({});
expect(transformProxyConfig({})).toEqual({});
});
test('should handle null port values', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: null,
auth: {
enabled: false,
username: '',
password: ''
},
bypassProxy: ''
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.port).toBeNull();
});
test('should handle SOCKS protocols', () => {
const oldConfig = {
enabled: true,
protocol: 'socks5',
hostname: 'socks.example.com',
port: 1080,
auth: {
enabled: true,
username: 'socksuser',
password: 'sockspass'
},
bypassProxy: ''
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.protocol).toBe('socks5');
expect((result as any).config.hostname).toBe('socks.example.com');
expect((result as any).config.port).toBe(1080);
});
test('should handle missing auth object', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
bypassProxy: ''
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.auth).toEqual({
username: '',
password: ''
});
});
test('should handle missing protocol (defaults to http)', () => {
const oldConfig = {
enabled: true,
hostname: 'proxy.example.com',
port: 8080
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.protocol).toBe('http');
});
test('should handle missing hostname (defaults to empty string)', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
port: 8080
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.hostname).toBe('');
});
test('should handle missing port (defaults to null)', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com'
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.port).toBeNull();
});
test('should handle missing bypassProxy (defaults to empty string)', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.bypassProxy).toBe('');
});
test('should handle auth with missing username/password', () => {
const oldConfig = {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
enabled: true
}
};
const result = transformProxyConfig(oldConfig);
expect((result as any).config.auth.username).toBe('');
expect((result as any).config.auth.password).toBe('');
});
});
});
});

View File

@@ -0,0 +1,92 @@
/**
* Transform proxy config from old format to new format.
* Old format: { enabled: true | false | 'global', protocol, hostname, port, auth: { enabled, ... }, ... }
* New format: { disabled?, inherit, config: { protocol, hostname, port, auth: { disabled?, ... }, ... } }
*/
interface OldProxyAuth {
enabled?: boolean;
username?: string;
password?: string;
}
interface OldProxyConfig {
enabled?: true | false | 'global';
protocol?: string;
hostname?: string;
port?: number | null;
auth?: OldProxyAuth;
bypassProxy?: string;
}
interface NewProxyAuth {
disabled?: boolean;
username?: string;
password?: string;
}
interface NewProxyConfig {
disabled?: boolean;
inherit: boolean;
config: {
protocol: string;
hostname: string;
port: number | null;
auth: NewProxyAuth;
bypassProxy: string;
};
}
export const transformProxyConfig = (proxy: OldProxyConfig | NewProxyConfig | null | undefined): NewProxyConfig | OldProxyConfig => {
proxy = proxy || {};
// Check if this is an old format (has 'enabled' property)
if (proxy.hasOwnProperty('enabled')) {
const oldProxy = proxy as OldProxyConfig;
const enabled = oldProxy.enabled;
const newProxy: NewProxyConfig = {
inherit: true,
config: {
protocol: oldProxy.protocol || 'http',
hostname: oldProxy.hostname || '',
port: oldProxy.port || null,
auth: {
username: oldProxy.auth?.username || '',
password: oldProxy.auth?.password || ''
},
bypassProxy: oldProxy.bypassProxy || ''
}
};
// Handle old format: enabled (true | false | 'global')
if (enabled === true) {
newProxy.disabled = false;
newProxy.inherit = false;
} else if (enabled === false) {
newProxy.disabled = true;
newProxy.inherit = false;
} else if (enabled === 'global') {
newProxy.disabled = false;
newProxy.inherit = true;
}
// Migrate auth.enabled to auth.disabled
if (oldProxy.auth?.enabled === false) {
newProxy.config.auth.disabled = true;
}
// If auth.enabled is true or undefined, omit disabled (defaults to false)
// Omit disabled: false at top level (optional field)
if (newProxy.disabled === false) {
delete newProxy.disabled;
}
// Omit auth.disabled: false (optional field)
if (newProxy.config.auth.disabled === false) {
delete newProxy.config.auth.disabled;
}
return newProxy;
}
return proxy;
};

View File

@@ -15,10 +15,7 @@
"bypassProxy": ""
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer", "form-data"],
"filesystemAccess": {
"allow": true
}
"moduleWhitelist": ["crypto", "buffer", "form-data"]
},
"clientCertificates": {
"enabled": true,

View File

@@ -0,0 +1,22 @@
meta {
name: isSafeMode
type: http
seq: 18
}
get {
url: {{host}}/ping
body: none
auth: inherit
}
script:pre-request {
test("bru.isSafeMode() returns true in safe mode", function() {
expect(bru.isSafeMode()).to.be.false;
});
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -3,10 +3,7 @@
"name": "collection_level_oauth2",
"type": "collection",
"scripts": {
"moduleWhitelist": ["crypto"],
"filesystemAccess": {
"allow": true
}
"moduleWhitelist": ["crypto"]
},
"clientCertificates": {
"enabled": true,

View File

@@ -5,10 +5,7 @@
"scripts": {
"moduleWhitelist": [
"crypto"
],
"filesystemAccess": {
"allow": true
}
]
},
"clientCertificates": {
"enabled": true,

View File

@@ -2916,10 +2916,7 @@
"crypto",
"buffer",
"form-data"
],
"filesystemAccess": {
"allow": true
}
]
},
"clientCertificates": {
"enabled": true,

View File

@@ -2917,10 +2917,7 @@
"crypto",
"buffer",
"form-data"
],
"filesystemAccess": {
"allow": true
}
]
},
"clientCertificates": {
"enabled": true,

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "is-safe-mode-test",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,17 @@
meta {
name: test-safe-mode-false
type: http
seq: 2
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}
tests {
test("bru.isSafeMode() returns false in developer mode", function() {
expect(bru.isSafeMode()).to.be.false;
});
}

View File

@@ -0,0 +1,17 @@
meta {
name: test-safe-mode-true
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}
tests {
test("bru.isSafeMode() returns true in safe mode", function() {
expect(bru.isSafeMode()).to.be.true;
});
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test",
"securityConfig": {
"jsSandboxMode": "developer"
}
}
]
}

View File

@@ -0,0 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test"
]
}

View File

@@ -0,0 +1,42 @@
import { test } from '../../../../playwright';
import { setSandboxMode, runCollection, validateRunnerResults } from '../../../utils/page';
test.describe.parallel('bru.isSafeMode() API', () => {
test('returns false when running in developer mode', async ({ pageWithUserData: page }) => {
// Set up developer mode
await setSandboxMode(page, 'is-safe-mode-test', 'developer');
// Run the collection
await runCollection(page, 'is-safe-mode-test');
// Validate test results
// In developer mode:
// - test-safe-mode-false should PASS (expects false, gets false)
// - test-safe-mode-true should FAIL (expects true, gets false)
await validateRunnerResults(page, {
totalRequests: 2,
passed: 1,
failed: 1,
skipped: 0
});
});
test('returns true when running in safe mode', async ({ pageWithUserData: page }) => {
// Set up safe mode
await setSandboxMode(page, 'is-safe-mode-test', 'safe');
// Run the collection
await runCollection(page, 'is-safe-mode-test');
// Validate test results
// In safe mode:
// - test-safe-mode-false should FAIL (expects false, gets true)
// - test-safe-mode-true should PASS (expects true, gets true)
await validateRunnerResults(page, {
totalRequests: 2,
passed: 1,
failed: 1,
skipped: 0
});
});
});

View File

@@ -5,10 +5,5 @@
"ignore": [
"node_modules",
".git"
],
"scripts": {
"filesystemAccess": {
"allow": true
}
}
]
}

View File

@@ -1,14 +0,0 @@
{
"version": "1",
"name": "should_disallow_fs",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"scripts": {
"filesystemAccess": {
"allow": false
}
}
}

View File

@@ -1,15 +0,0 @@
meta {
name: request
type: http
seq: 1
}
post {
url: https://echo.usebruno.com
body: none
auth: none
}
script:pre-request {
const fs = require('fs');
}

View File

@@ -2,79 +2,39 @@ import { test } from '../../../../playwright';
import { setSandboxMode, runCollection, validateRunnerResults } from '../../../utils/page';
test.describe.serial('`fs` library', () => {
test.describe('should allow `fs` library', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
test('developer mode allows fs', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
// Set up developer mode
await setSandboxMode(page, 'should_allow_fs', 'developer');
// Set up developer mode
await setSandboxMode(page, 'should_allow_fs', 'developer');
// Run the collection
await runCollection(page, 'should_allow_fs');
// Run the collection
await runCollection(page, 'should_allow_fs');
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
failed: 0,
skipped: 0
});
});
test('safe mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
// Set up safe mode
await setSandboxMode(page, 'should_allow_fs', 'safe');
// Run the collection
await runCollection(page, 'should_allow_fs');
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 0,
failed: 1,
skipped: 0
});
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
failed: 0,
skipped: 0
});
});
test.describe('should disallow `fs` library', () => {
test('developer mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
test('safe mode blocks fs', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
// Set up developer mode
await setSandboxMode(page, 'should_disallow_fs', 'developer');
// Set up safe mode
await setSandboxMode(page, 'should_allow_fs', 'safe');
// Run the collection
await runCollection(page, 'should_disallow_fs');
// Run the collection
await runCollection(page, 'should_allow_fs');
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 0,
failed: 1,
skipped: 0
});
});
test('safe mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
// Set up safe mode
await setSandboxMode(page, 'should_disallow_fs', 'safe');
// Run the collection
await runCollection(page, 'should_disallow_fs');
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 0,
failed: 1,
skipped: 0
});
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 0,
failed: 1,
skipped: 0
});
});
});

View File

@@ -1,7 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs",
"{{projectRoot}}/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs"
"{{projectRoot}}/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs"
]
}