mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 09:58:35 +00:00
Compare commits
22 Commits
fix/remove
...
fix/oauth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
148d3f0e7d | ||
|
|
75e17610f0 | ||
|
|
154c45d87d | ||
|
|
0bf169562b | ||
|
|
967b073ded | ||
|
|
725dfeacac | ||
|
|
923d26ce56 | ||
|
|
7e258003d5 | ||
|
|
7689288763 | ||
|
|
81faa57808 | ||
|
|
bac9616de4 | ||
|
|
9ab1ed3d90 | ||
|
|
408c9d4a4e | ||
|
|
ebafdd813c | ||
|
|
6642f4d0b0 | ||
|
|
4f75474c87 | ||
|
|
e5b7aa5ab4 | ||
|
|
875df38501 | ||
|
|
a724f010ff | ||
|
|
f9423d1238 | ||
|
|
51e36519f7 | ||
|
|
bd0894ede0 |
62
package-lock.json
generated
62
package-lock.json
generated
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
"bypassProxy": ""
|
||||
},
|
||||
"scripts": {
|
||||
"moduleWhitelist": ["crypto", "buffer"],
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
"moduleWhitelist": ["crypto", "buffer"]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -257,6 +257,7 @@ class AssertRuntime {
|
||||
|
||||
const promptVariables = request?.promptVariables || {};
|
||||
const bru = new Bru(
|
||||
this.runtime,
|
||||
envVariables,
|
||||
runtimeVariables,
|
||||
processEnvVars,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ const Bru = require('../src/bru');
|
||||
describe('Bru.setEnvVar', () => {
|
||||
const makeBru = () =>
|
||||
new Bru(
|
||||
/* runtime */ 'quickjs',
|
||||
/* envVariables */ {},
|
||||
/* runtimeVariables */ {},
|
||||
/* processEnvVars */ {},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
508
packages/bruno-requests/src/grpc/grpc-client.spec.js
Normal file
508
packages/bruno-requests/src/grpc/grpc-client.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
445
packages/bruno-requests/src/utils/http-https-agents.ts
Normal file
445
packages/bruno-requests/src/utils/http-https-agents.ts
Normal 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 };
|
||||
336
packages/bruno-requests/src/utils/proxy-util.spec.ts
Normal file
336
packages/bruno-requests/src/utils/proxy-util.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
92
packages/bruno-requests/src/utils/proxy-util.ts
Normal file
92
packages/bruno-requests/src/utils/proxy-util.ts
Normal 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;
|
||||
};
|
||||
@@ -15,10 +15,7 @@
|
||||
"bypassProxy": ""
|
||||
},
|
||||
"scripts": {
|
||||
"moduleWhitelist": ["crypto", "buffer", "form-data"],
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
"moduleWhitelist": ["crypto", "buffer", "form-data"]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,10 +3,7 @@
|
||||
"name": "collection_level_oauth2",
|
||||
"type": "collection",
|
||||
"scripts": {
|
||||
"moduleWhitelist": ["crypto"],
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
"moduleWhitelist": ["crypto"]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
"scripts": {
|
||||
"moduleWhitelist": [
|
||||
"crypto"
|
||||
],
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -2916,10 +2916,7 @@
|
||||
"crypto",
|
||||
"buffer",
|
||||
"form-data"
|
||||
],
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -2917,10 +2917,7 @@
|
||||
"crypto",
|
||||
"buffer",
|
||||
"form-data"
|
||||
],
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "is-safe-mode-test",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "developer"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test"
|
||||
]
|
||||
}
|
||||
42
tests/scripting/bru-api/isSafeMode/isSafeMode.spec.ts
Normal file
42
tests/scripting/bru-api/isSafeMode/isSafeMode.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,5 @@
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
],
|
||||
"scripts": {
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "should_disallow_fs",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
],
|
||||
"scripts": {
|
||||
"filesystemAccess": {
|
||||
"allow": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user