mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 17:38:36 +00:00
Compare commits
38 Commits
v2.13.1
...
bugfix/inc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa1498e2a8 | ||
|
|
045141efaf | ||
|
|
c997b91698 | ||
|
|
986d5b0b2a | ||
|
|
a2a521477a | ||
|
|
8e70adcbf9 | ||
|
|
87296776fa | ||
|
|
9df70cd759 | ||
|
|
8f9fb3b3c9 | ||
|
|
6d018f5648 | ||
|
|
789d0b23c0 | ||
|
|
81e1e403e4 | ||
|
|
ad2add4026 | ||
|
|
02554c3ad9 | ||
|
|
62815e3429 | ||
|
|
9859b69559 | ||
|
|
440c688bbb | ||
|
|
416eb754b7 | ||
|
|
b85d6efa60 | ||
|
|
19dea18629 | ||
|
|
636901c23d | ||
|
|
a4b1941817 | ||
|
|
7d8fde9180 | ||
|
|
4197304bf9 | ||
|
|
b75422a010 | ||
|
|
e9f03c46c7 | ||
|
|
73e828621f | ||
|
|
2becf49542 | ||
|
|
e57162b79a | ||
|
|
1de9203dd5 | ||
|
|
cffa37ed50 | ||
|
|
bcf61f507a | ||
|
|
325b573da9 | ||
|
|
4b5c7dcca6 | ||
|
|
d2888daa88 | ||
|
|
ec9d63219f | ||
|
|
9173ffbdee | ||
|
|
5f112a318d |
2
.github/workflows/npm-bru-cli.yml
vendored
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
6
.github/workflows/ssl-tests.yml
vendored
6
.github/workflows/ssl-tests.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: v22.11.x
|
||||
|
||||
@@ -37,13 +37,37 @@ Bruno 直接在您的电脑文件夹中存储您的 API 信息。我们使用纯
|
||||
|
||||
Bruno 仅限离线使用。我们计划永不向 Bruno 添加云同步功能。我们重视您的数据隐私,并认为它应该留在您的设备上。阅读我们的长期愿景 [点击查看](https://github.com/usebruno/bruno/discussions/269)
|
||||
|
||||
[下载 Bruno](https://www.usebruno.com/downloads)
|
||||
|
||||
📢 观看我们在印度 FOSS 3.0 会议上的最新演讲 [点击查看](https://www.youtube.com/watch?v=7bSMFpbcPiY)
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### 安装
|
||||
## 商业版本 ✨
|
||||
|
||||
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) Mac、Windows 和 Linux 的可执行文件。
|
||||
我们的大多数功能都是免费且开源的。
|
||||
我们致力于在 [开源与可持续性发展](https://github.com/usebruno/bruno/discussions/269) 之间取得和谐的平衡
|
||||
|
||||
欢迎使用我们的 [付费版本](https://www.usebruno.com/pricing) ,看看附加的功能是否对您或团队有所帮助! <br/>
|
||||
|
||||
## 目录
|
||||
- [安装](#安装)
|
||||
- [特性](#特性)
|
||||
- [跨平台使用 🖥️](#跨平台使用-)
|
||||
- [通过Git协作 👩💻🧑💻](#通过git协作-)
|
||||
- [重要链接 📌](#重要链接-)
|
||||
- [展示 🎥](#展示-)
|
||||
- [分享评价 📣](#分享评价-)
|
||||
- [发布到新的包管理器](#发布到新的包管理器)
|
||||
- [联系方式 🌐](#联系方式-)
|
||||
- [商标](#商标)
|
||||
- [贡献 👩💻🧑💻](#贡献-)
|
||||
- [作者](#作者)
|
||||
- [许可证 📄](#许可证-)
|
||||
|
||||
## 安装
|
||||
|
||||
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) 适用于Mac、Windows 和 Linux 的可执行文件。
|
||||
|
||||
您也可以通过包管理器如 Homebrew、Chocolatey、Scoop、Snap 和 Apt 安装 Bruno。
|
||||
|
||||
@@ -58,9 +82,15 @@ choco install bruno
|
||||
scoop bucket add extras
|
||||
scoop install bruno
|
||||
|
||||
# 在 Windows 上用 winget 安装
|
||||
winget install Bruno.Bruno
|
||||
|
||||
# 在 Linux 上用 Snap 安装
|
||||
snap install bruno
|
||||
|
||||
# 在 Linux 上用 Flatpak 安装
|
||||
flatpak install com.usebruno.Bruno
|
||||
|
||||
# 在 Linux 上用 Apt 安装
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
sudo apt update && sudo apt install gpg curl
|
||||
@@ -73,67 +103,50 @@ echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebr
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
### 在 Mac 上通过 Homebrew 安装 🖥️
|
||||
## 特性
|
||||
|
||||
### 跨平台使用 🖥️
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Collaborate 安装 👩💻🧑💻
|
||||
### 通过Git协作 👩💻🧑💻
|
||||
|
||||
或者任何您选择的版本控制系统
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### 重要链接 📌
|
||||
## 重要链接 📌
|
||||
|
||||
- [我们的愿景](https://github.com/usebruno/bruno/discussions/269)
|
||||
- [路线图](https://github.com/usebruno/bruno/discussions/384)
|
||||
- [路线图](https://www.usebruno.com/roadmap)
|
||||
- [文档](https://docs.usebruno.com)
|
||||
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
|
||||
- [网站](https://www.usebruno.com)
|
||||
- [价格](https://www.usebruno.com/pricing)
|
||||
- [下载](https://www.usebruno.com/downloads)
|
||||
- [GitHub 赞助](https://github.com/sponsors/helloanoop).
|
||||
|
||||
### 展示 🎥
|
||||
## 展示 🎥
|
||||
|
||||
- [Testimonials](https://github.com/usebruno/bruno/discussions/343)
|
||||
- [Knowledge Hub](https://github.com/usebruno/bruno/discussions/386)
|
||||
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
|
||||
|
||||
### 支持 ❤️
|
||||
|
||||
如果您喜欢 Bruno 并想支持我们的开源工作,请考虑通过 [GitHub Sponsors](https://github.com/sponsors/helloanoop) 来赞助我们。
|
||||
|
||||
### 分享评价 📣
|
||||
## 分享评价 📣
|
||||
|
||||
如果 Bruno 在您的工作和团队中帮助了您,请不要忘记在我们的 GitHub 讨论上分享您的 [评价](https://github.com/usebruno/bruno/discussions/343)
|
||||
|
||||
### 发布到新的包管理器
|
||||
## 发布到新的包管理器
|
||||
|
||||
有关更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
|
||||
如需了解更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
|
||||
|
||||
### 贡献 👩💻🧑💻
|
||||
|
||||
我很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
|
||||
|
||||
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
|
||||
|
||||
### 作者
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/usebruno/bruno/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### 联系方式 🌐
|
||||
## 联系方式 🌐
|
||||
|
||||
[𝕏 (Twitter)](https://twitter.com/use_bruno) <br />
|
||||
[Website](https://www.usebruno.com) <br />
|
||||
[Discord](https://discord.com/invite/KgcZUncpjq) <br />
|
||||
[LinkedIn](https://www.linkedin.com/company/usebruno)
|
||||
|
||||
### 商标
|
||||
## 商标
|
||||
|
||||
**名称**
|
||||
|
||||
@@ -143,6 +156,20 @@ sudo apt update && sudo apt install bruno
|
||||
|
||||
Logo 源自 [OpenMoji](https://openmoji.org/library/emoji-1F436/). License: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||
|
||||
### 许可证 📄
|
||||
## 贡献 👩💻🧑💻
|
||||
|
||||
很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
|
||||
|
||||
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
|
||||
|
||||
## 作者
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/usebruno/bruno/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 许可证 📄
|
||||
|
||||
[MIT](../../license.md)
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -31986,6 +31986,7 @@
|
||||
"cheerio": "^1.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"json-query": "^2.2.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "3.3.8",
|
||||
|
||||
@@ -14,7 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import CustomSearch from './CustomSearch';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
@@ -267,7 +267,7 @@ export default class CodeEditor extends React.Component {
|
||||
font={this.props.font}
|
||||
fontSize={this.props.fontSize}
|
||||
>
|
||||
<CustomSearch
|
||||
<CodeMirrorSearch
|
||||
visible={this.state.searchBarVisible}
|
||||
editor={this.editor}
|
||||
onClose={() => this.setState({ searchBarVisible: false })}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -9,7 +8,7 @@ function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
|
||||
}
|
||||
|
||||
const CustomSearch = ({ visible, editor, onClose }) => {
|
||||
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [regex, setRegex] = useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
@@ -199,4 +198,4 @@ const CustomSearch = ({ visible, editor, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSearch;
|
||||
export default CodeMirrorSearch;
|
||||
@@ -44,7 +44,11 @@ const CollectionSettings = ({ collection }) => {
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
|
||||
|
||||
const presets = get(collection, 'brunoConfig.presets', []);
|
||||
const hasPresets = presets && presets.requestUrl !== "";
|
||||
|
||||
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyEnabled = proxyConfig.hostname ? true : false;
|
||||
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const protobufConfig = get(collection, 'brunoConfig.protobuf', {});
|
||||
|
||||
@@ -163,10 +167,11 @@ const CollectionSettings = ({ collection }) => {
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
|
||||
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
|
||||
@@ -17,7 +17,7 @@ import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -48,15 +48,7 @@ const Auth = ({ collection, folder }) => {
|
||||
let request = get(folder, 'root.request', {});
|
||||
const authMode = get(folder, 'root.request.auth.mode');
|
||||
|
||||
const getTreePathFromCollectionToFolder = (collection, _folder) => {
|
||||
let path = [];
|
||||
let item = findItemInCollection(collection, _folder?.uid);
|
||||
while (item) {
|
||||
path.unshift(item);
|
||||
item = findParentItemInCollection(collection, item?.uid);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
if (authMode !== 'inherit') return null;
|
||||
@@ -69,7 +61,7 @@ const Auth = ({ collection, folder }) => {
|
||||
};
|
||||
|
||||
// Get path from collection to current folder
|
||||
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
|
||||
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
|
||||
|
||||
// Check parent folders to find closest auth configuration
|
||||
// Skip the last item which is the current folder
|
||||
|
||||
@@ -136,7 +136,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
@@ -153,7 +153,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
|
||||
@@ -9,7 +9,8 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
|
||||
<div className="bruno-modal-header">
|
||||
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
|
||||
{handleCancel && !hideClose ? (
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button">
|
||||
// TODO: Remove data-test-id and use data-testid instead across the codebase.
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button" data-testid="modal-close-button">
|
||||
×
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -9,6 +9,7 @@ import WsseAuth from './WsseAuth';
|
||||
import NTLMAuth from './NTLMAuth';
|
||||
import { updateAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ApiKeyAuth from './ApiKeyAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -27,6 +28,7 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
|
||||
};
|
||||
|
||||
const Auth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
|
||||
|
||||
@@ -37,7 +39,7 @@ const Auth = ({ item, collection }) => {
|
||||
|
||||
// Save function for request level
|
||||
const save = () => {
|
||||
return saveRequest(item.uid, collection.uid);
|
||||
return dispatch(saveRequest(item.uid, collection.uid));
|
||||
};
|
||||
|
||||
const getEffectiveAuthSource = () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { humanizeRequestBodyMode } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { format, applyEdits } from 'jsonc-parser';
|
||||
import { prettifyJSON } from 'utils/common';
|
||||
import xmlFormat from 'xml-formatter';
|
||||
|
||||
const RequestBodyMode = ({ item, collection }) => {
|
||||
@@ -39,8 +39,7 @@ const RequestBodyMode = ({ item, collection }) => {
|
||||
const onPrettify = () => {
|
||||
if (body?.json && bodyMode === 'json') {
|
||||
try {
|
||||
const edits = format(body.json, undefined, { tabSize: 2, insertSpaces: true });
|
||||
const prettyBodyJson = applyEdits(body.json, edits);
|
||||
const prettyBodyJson = prettifyJSON(body.json);
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
content: prettyBodyJson,
|
||||
|
||||
@@ -96,15 +96,17 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const getMethodText = useCallback((item) => {
|
||||
if (!item) return;
|
||||
const isGrpc = item.type === 'grpc-request';
|
||||
const isWS = item.type === 'ws-request';
|
||||
if (!isWS && !isGrpc) {
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
|
||||
switch (item.type) {
|
||||
case 'grpc-request':
|
||||
return 'gRPC';
|
||||
case 'ws-request':
|
||||
return 'WS';
|
||||
case 'graphql-request':
|
||||
return 'GQL';
|
||||
default:
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
}
|
||||
if (isGrpc) {
|
||||
return 'gRPC';
|
||||
}
|
||||
return 'WS';
|
||||
}, [item]);
|
||||
|
||||
if (!item) {
|
||||
@@ -249,6 +251,28 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
|
||||
function handleRevertChanges(event) {
|
||||
event.stopPropagation();
|
||||
dropdownTippyRef.current.hide();
|
||||
|
||||
if (!currentTabUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = findItemInCollection(collection, currentTabUid);
|
||||
if (item.draft) {
|
||||
dispatch(
|
||||
deleteRequestDraft({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
function handleCloseOtherTabs(event) {
|
||||
dropdownTippyRef.current.hide();
|
||||
|
||||
@@ -316,6 +340,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
>
|
||||
Clone Request
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item w-full"
|
||||
onClick={handleRevertChanges}
|
||||
disabled={!currentTabItem?.draft}
|
||||
>
|
||||
Revert Changes
|
||||
</button>
|
||||
<button className="dropdown-item w-full" onClick={(e) => handleCloseTab(e, currentTabUid)}>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { get } from 'lodash';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { Document, Page } from 'react-pdf';
|
||||
import 'pdfjs-dist/build/pdf.worker';
|
||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
@@ -76,6 +76,8 @@ const QueryResultPreview = ({
|
||||
dispatch(sendRequest(item, collection.uid));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const onScroll = (event) => {
|
||||
dispatch(
|
||||
updateResponsePaneScrollPosition({
|
||||
@@ -127,6 +129,7 @@ const QueryResultPreview = ({
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={mode}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { findCollectionByItemUid, getGlobalEnvironmentVariables } from 'utils/co
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { generateSnippet } from '../utils/snippet-generator';
|
||||
|
||||
const CodeView = ({ language, item }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -33,7 +32,12 @@ const CodeView = ({ language, item }) => {
|
||||
}, [collectionOriginal, globalEnvironments, activeGlobalEnvironmentUid]);
|
||||
|
||||
const snippet = useMemo(() => {
|
||||
return generateSnippet({ language, item, collection, shouldInterpolate: generateCodePrefs.shouldInterpolate });
|
||||
return generateSnippet({
|
||||
language,
|
||||
item,
|
||||
collection,
|
||||
shouldInterpolate: generateCodePrefs.shouldInterpolate
|
||||
});
|
||||
}, [language, item, collection, generateCodePrefs.shouldInterpolate]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,6 +40,9 @@ const Wrapper = styled.div`
|
||||
.method-ws {
|
||||
color: ${(props) => props.theme.request.ws};
|
||||
}
|
||||
.method-graphql {
|
||||
color: ${(props) => props.theme.request.gql};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -4,18 +4,20 @@ import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const getMethodFlags = (item) => ({
|
||||
isGrpc: item.type === 'grpc-request',
|
||||
isWS: item.type === 'ws-request'
|
||||
isWS: item.type === 'ws-request',
|
||||
isGraphQL: item.type === 'graphql-request'
|
||||
});
|
||||
|
||||
const getMethodText = (item, { isGrpc, isWS }) => {
|
||||
const getMethodText = (item, { isGrpc, isWS, isGraphQL }) => {
|
||||
if (isGrpc) return 'grpc';
|
||||
if (isWS) return 'ws';
|
||||
if (isGraphQL) return 'gql';
|
||||
return item.request.method.length > 5
|
||||
? item.request.method.substring(0, 3)
|
||||
: item.request.method;
|
||||
};
|
||||
|
||||
const getClassname = (method = '', { isGrpc, isWS }) => {
|
||||
const getClassname = (method = '', { isGrpc, isWS, isGraphQL }) => {
|
||||
method = method.toLocaleLowerCase();
|
||||
return classnames('mr-1', {
|
||||
'method-get': method === 'get',
|
||||
@@ -26,7 +28,8 @@ const getClassname = (method = '', { isGrpc, isWS }) => {
|
||||
'method-head': method === 'head',
|
||||
'method-options': method === 'options',
|
||||
'method-grpc': isGrpc,
|
||||
'method-ws': isWS
|
||||
'method-ws': isWS,
|
||||
'method-graphql': isGraphQL
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import filter from 'lodash/filter';
|
||||
import { Inspector } from 'react-inspector';
|
||||
import { Inspector, chromeDark, chromeLight } from 'react-inspector';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { findEnvironmentInCollection, maskInputValue } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -84,9 +84,12 @@ const RuntimeVariables = ({ collection, theme }) => {
|
||||
};
|
||||
|
||||
const VariablesEditor = ({ collection }) => {
|
||||
const { storedTheme } = useTheme();
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
|
||||
const reactInspectorTheme = storedTheme === 'light' ? 'chromeLight' : 'chromeDark';
|
||||
const reactInspectorTheme
|
||||
= displayedTheme === 'light'
|
||||
? { ...chromeLight, OBJECT_VALUE_STRING_COLOR: theme.variables.runtime.color }
|
||||
: { ...chromeDark, OBJECT_VALUE_STRING_COLOR: theme.variables.runtime.color };
|
||||
|
||||
return (
|
||||
<StyledWrapper className="px-4 py-4 overflow-auto">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { uuid } from 'utils/common/index';
|
||||
import { environmentSchema } from '@usebruno/schema';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { cloneDeep, has } from 'lodash';
|
||||
|
||||
const initialState = {
|
||||
globalEnvironments: [],
|
||||
@@ -195,11 +195,15 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
|
||||
}
|
||||
|
||||
let variables = cloneDeep(environment?.variables);
|
||||
console.log('globalEnvironmentVariables', globalEnvironmentVariables);
|
||||
|
||||
// update existing values
|
||||
// "globalEnvironmentVariables" will include only the enabled variables and newly added variables created using the script.
|
||||
// Update the value of each variable if it's present in "globalEnvironmentVariables", otherwise keep the existing value.
|
||||
variables = variables?.map?.(variable => ({
|
||||
...variable,
|
||||
value: globalEnvironmentVariables?.[variable?.name]
|
||||
value: has(globalEnvironmentVariables, variable?.name)
|
||||
? globalEnvironmentVariables[variable?.name]
|
||||
: variable?.value
|
||||
}));
|
||||
|
||||
// add new env values
|
||||
|
||||
@@ -32,6 +32,10 @@ const darkTheme = {
|
||||
|
||||
name: {
|
||||
color: '#569cd6'
|
||||
},
|
||||
|
||||
runtime: {
|
||||
color: 'rgb(255, 255, 255)'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -103,7 +107,8 @@ const darkTheme = {
|
||||
head: '#d69956'
|
||||
},
|
||||
grpc: '#6366f1',
|
||||
ws: '#f59e0b'
|
||||
ws: '#f59e0b',
|
||||
gql: '#e535ab'
|
||||
},
|
||||
|
||||
requestTabPanel: {
|
||||
|
||||
@@ -36,6 +36,10 @@ const lightTheme = {
|
||||
|
||||
name: {
|
||||
color: '#546de5'
|
||||
},
|
||||
|
||||
runtime: {
|
||||
color: 'rgb(0, 0, 0)'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -103,7 +107,8 @@ const lightTheme = {
|
||||
head: '#ca7811'
|
||||
},
|
||||
grpc: '#6366f1',
|
||||
ws: '#f59e0b'
|
||||
ws: '#f59e0b',
|
||||
gql: '#e535ab'
|
||||
},
|
||||
|
||||
requestTabPanel: {
|
||||
|
||||
@@ -33,6 +33,22 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth) => {
|
||||
value: `Bearer ${get(auth, 'bearer.token', '')}`
|
||||
}
|
||||
];
|
||||
case 'apikey':
|
||||
const apiKeyAuth = get(auth, 'apikey', {});
|
||||
const key = get(apiKeyAuth, 'key', '');
|
||||
const value = get(apiKeyAuth, 'value', '');
|
||||
const placement = get(apiKeyAuth, 'placement', 'header');
|
||||
|
||||
if (placement === 'header') {
|
||||
return [
|
||||
{
|
||||
enabled: true,
|
||||
name: key,
|
||||
value: value
|
||||
}
|
||||
];
|
||||
}
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -44,13 +44,25 @@ const createHeaders = (request, headers) => {
|
||||
return enabledHeaders;
|
||||
};
|
||||
|
||||
const createQuery = (queryParams = []) => {
|
||||
return queryParams
|
||||
const createQuery = (queryParams = [], request) => {
|
||||
const params = queryParams
|
||||
.filter((param) => param.enabled && param.type === 'query')
|
||||
.map((param) => ({
|
||||
name: param.name,
|
||||
value: param.value
|
||||
}));
|
||||
|
||||
if (request?.auth?.mode === 'apikey' &&
|
||||
request?.auth?.apikey?.placement === 'queryparams' &&
|
||||
request?.auth?.apikey?.key &&
|
||||
request?.auth?.apikey?.value) {
|
||||
params.push({
|
||||
name: request.auth.apikey.key,
|
||||
value: request.auth.apikey.value
|
||||
});
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const createPostData = (body) => {
|
||||
@@ -119,13 +131,20 @@ const createPostData = (body) => {
|
||||
};
|
||||
|
||||
export const buildHarRequest = ({ request, headers }) => {
|
||||
// NOTE:
|
||||
// This is just a safety check.
|
||||
// The interpolateUrlPathParams method validates the url, but it does not throw
|
||||
if (!URL.canParse(request.url)) {
|
||||
throw new Error('invalid request url');
|
||||
}
|
||||
|
||||
return {
|
||||
method: request.method,
|
||||
url: encodeURI(request.url),
|
||||
url: request.url,
|
||||
httpVersion: 'HTTP/1.1',
|
||||
cookies: [],
|
||||
headers: createHeaders(request, headers),
|
||||
queryString: createQuery(request.params),
|
||||
queryString: createQuery(request.params, request),
|
||||
postData: createPostData(request.body),
|
||||
headersSize: 0,
|
||||
bodySize: 0,
|
||||
|
||||
@@ -93,6 +93,9 @@ const STATIC_API_HINTS = {
|
||||
'bru.cookies.jar().clear(callback)',
|
||||
'bru.cookies.jar().deleteCookies(url, callback)',
|
||||
'bru.cookies.jar().deleteCookie(url, name, callback)',
|
||||
'bru.utils',
|
||||
'bru.utils.minifyJson(json)',
|
||||
'bru.utils.minifyXml(xml)'
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -54,10 +54,27 @@ export const safeStringifyJSON = (obj, indent = false) => {
|
||||
|
||||
export const prettifyJSON = (obj, spaces = 2) => {
|
||||
try {
|
||||
const formatted = obj.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
||||
const edits = format(formatted, undefined, { tabSize: spaces, insertSpaces: true });
|
||||
const text = obj.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
||||
|
||||
return applyEdits(formatted, edits);
|
||||
const placeholders = [];
|
||||
const modifiedJson = text.replace(/"[^"]*?"|{{[^{}]+}}/g, (match) => {
|
||||
if (match.startsWith('{{')) {
|
||||
const placeholder = `__BRUNO_VAR_PLACEHOLDER_${placeholders.length}__`;
|
||||
placeholders.push(match);
|
||||
return `"${placeholder}"`; // Wrap bare variable in quotes to make it a valid JSON string
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
const edits = format(modifiedJson, undefined, { tabSize: spaces, insertSpaces: true });
|
||||
let result = applyEdits(modifiedJson, edits);
|
||||
|
||||
for (let i = 0; i < placeholders.length; i++) {
|
||||
const placeholder = `__BRUNO_VAR_PLACEHOLDER_${i}__`;
|
||||
result = result.replace(`"${placeholder}"`, placeholders[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
|
||||
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType, formatSize } from './index';
|
||||
import {
|
||||
normalizeFileName,
|
||||
startsWith,
|
||||
humanizeDate,
|
||||
relativeDate,
|
||||
getContentType,
|
||||
formatSize,
|
||||
prettifyJSON
|
||||
} from './index';
|
||||
|
||||
describe('common utils', () => {
|
||||
describe('normalizeFileName', () => {
|
||||
@@ -184,4 +192,30 @@ describe('common utils', () => {
|
||||
expect(formatSize(NaN)).toBe('0B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prettifyJSON', () => {
|
||||
it('should prettify a standard JSON string', () => {
|
||||
const input = '{"key":"value","number":123}';
|
||||
const expected = '{\n "key": "value",\n "number": 123\n}';
|
||||
expect(prettifyJSON(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle JSON with a Bruno variable as a value', () => {
|
||||
const input = '{"id":{{request_id}}}';
|
||||
const expected = '{\n "id": {{request_id}}\n}';
|
||||
expect(prettifyJSON(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle JSON with a Bruno variable inside a string value', () => {
|
||||
const input = '{"url":"https://example.com/{{path}}"}';
|
||||
const expected = '{\n "url": "https://example.com/{{path}}"\n}';
|
||||
expect(prettifyJSON(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return the original string for invalid JSON', () => {
|
||||
const input = '{"key":"value",';
|
||||
const expected = '{\n "key": "value",';
|
||||
expect(prettifyJSON(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,19 +33,35 @@ export const parsePathParams = (url) => {
|
||||
}
|
||||
|
||||
// Enhanced: also match :param inside parentheses and/or quotes
|
||||
const paramRegex = /[:](\w+)/g;
|
||||
const foundParams = new Set();
|
||||
paths.forEach(segment => {
|
||||
// traditional path parameters
|
||||
if (segment.startsWith(':')) {
|
||||
const name = segment.slice(1);
|
||||
if (name && !foundParams.has(name)) {
|
||||
foundParams.add(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// for OData-style parameters (parameters inside parentheses)
|
||||
// Check if segment matches valid OData syntax:
|
||||
// 1. EntitySet('key') or EntitySet(key)
|
||||
// 2. EntitySet(Key1=value1,Key2=value2)
|
||||
// 3. Function(param=value)
|
||||
if (!/^[A-Za-z0-9_.-]+\([^)]*\)$/.test(segment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paramRegex = /[:](\w+)/g;
|
||||
let match;
|
||||
while ((match = paramRegex.exec(segment))) {
|
||||
if (match[1]) {
|
||||
// Clean up: remove trailing quotes/parentheses if present
|
||||
let name = match[1].replace(/[')"`]+$/, '');
|
||||
// Remove leading quotes/parentheses if present
|
||||
name = name.replace(/^[('"`]+/, '');
|
||||
if (name && !foundParams.has(name)) {
|
||||
foundParams.add(name);
|
||||
}
|
||||
if (!match[1]) continue;
|
||||
|
||||
let name = match[1].replace(/[')"`]+$/, '');
|
||||
name = name.replace(/^[('"`]+/, '');
|
||||
if (name && !foundParams.has(name)) {
|
||||
foundParams.add(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -84,27 +100,41 @@ export const interpolateUrl = ({ url, variables }) => {
|
||||
|
||||
export const interpolateUrlPathParams = (url, params) => {
|
||||
const getInterpolatedBasePath = (pathname, params) => {
|
||||
const regex = /[:](\w+)/g;
|
||||
return pathname
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
// traditional path parameters
|
||||
if (segment.startsWith(':')) {
|
||||
const name = segment.slice(1);
|
||||
const pathParam = params.find((p) => p?.name === name && p?.type === 'path');
|
||||
return pathParam ? pathParam.value : segment;
|
||||
}
|
||||
|
||||
if (!segment.startsWith(':')) return segment;
|
||||
// for OData-style parameters (parameters inside parentheses)
|
||||
// Check if segment matches valid OData syntax:
|
||||
// 1. EntitySet('key') or EntitySet(key)
|
||||
// 2. EntitySet(Key1=value1,Key2=value2)
|
||||
// 3. Function(param=value)
|
||||
if (!/^[A-Za-z0-9_.-]+\([^)]*\)$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
|
||||
const regex = /[:](\w+)/g;
|
||||
let match;
|
||||
let result = segment;
|
||||
while ((match = regex.exec(segment))) {
|
||||
if (match[1]) {
|
||||
// Clean up: remove trailing quotes/parentheses if present
|
||||
let name = match[1].replace(/[')"`]+$/, '');
|
||||
// Remove leading quotes/parentheses if present
|
||||
name = name.replace(/^[('"`]+/, '');
|
||||
if (name) {
|
||||
const pathParam = params.find(p => p?.name === name && p?.type === 'path');
|
||||
return pathParam ? pathParam.value : segment;
|
||||
}
|
||||
if (!match[1]) continue;
|
||||
|
||||
let name = match[1].replace(/[')"`]+$/, '');
|
||||
name = name.replace(/^[('"`]+/, '');
|
||||
if (!name) continue;
|
||||
|
||||
const pathParam = params.find((p) => p?.name === name && p?.type === 'path');
|
||||
if (pathParam) {
|
||||
result = result.replace(':' + match[1], pathParam.value);
|
||||
}
|
||||
}
|
||||
return segment;
|
||||
return result;
|
||||
})
|
||||
.join('/');
|
||||
};
|
||||
|
||||
@@ -80,13 +80,19 @@ describe('Url Utils - parsePathParams', () => {
|
||||
expect(params).toEqual([{ name: 'productId', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle OData parameters with mixed quote types', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products(\':productId\')/Categories(":categoryId")');
|
||||
expect(params).toEqual([{ name: 'productId', value: '' }, { name: 'categoryId', value: '' }]);
|
||||
});
|
||||
|
||||
it('should parse OData entity key with parentheses only', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products(:productId)');
|
||||
expect(params).toEqual([{ name: 'productId', value: '' }]);
|
||||
});
|
||||
|
||||
it('should parse OData composite key with multiple parameters', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Orders(:orderId,ProductId=\':productId\')');
|
||||
it('should parse OData composite key with mixed parameter styles', () => {
|
||||
// Test both positional and named parameter styles in the same key
|
||||
const params = parsePathParams('https://example.com/odata/Orders(:orderId,ProductId=:productId)');
|
||||
expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'productId', value: '' }]);
|
||||
});
|
||||
|
||||
@@ -115,19 +121,24 @@ describe('Url Utils - parsePathParams', () => {
|
||||
expect(params).toEqual([{ name: 'product_id', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle OData parameters with mixed quote types', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products(\':productId\')/Categories(":categoryId")');
|
||||
expect(params).toEqual([{ name: 'productId', value: '' }, { name: 'categoryId', value: '' }]);
|
||||
it('should parse OData composite key with positional parameters', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Orders(:orderId,:productId)');
|
||||
expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'productId', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle OData parameters with nested parentheses', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products((\':productId\'))');
|
||||
expect(params).toEqual([{ name: 'productId', value: '' }]);
|
||||
it('should parse OData composite key with named parameters', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Orders(OrderId=:orderId,ProductId=:productId)');
|
||||
expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'productId', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle OData parameters with complex nested structures', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Orders(:orderId)/Items(\':itemId\')/Properties(\':propName\')');
|
||||
expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'itemId', value: '' }, { name: 'propName', value: '' }]);
|
||||
it('should handle OData navigation properties', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Orders(:orderId)/Items');
|
||||
expect(params).toEqual([{ name: 'orderId', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle OData function parameters', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products/GetProductsByCategory(categoryId=:categoryId)');
|
||||
expect(params).toEqual([{ name: 'categoryId', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle OData parameters with query options in path', () => {
|
||||
@@ -145,11 +156,86 @@ describe('Url Utils - parsePathParams', () => {
|
||||
expect(params).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle OData parameters with function calls in parentheses', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products(GetId(\':productId\'))');
|
||||
expect(params).toEqual([{ name: 'productId', value: '' }]);
|
||||
it('should NOT treat embedded colons as path parameters (regression fix)', () => {
|
||||
// This test case reproduces the bug reported in issue #5805
|
||||
const params = parsePathParams('/start/1:2:AHLS-HASD/form');
|
||||
expect(params).toEqual([]);
|
||||
});
|
||||
|
||||
it('should NOT treat embedded colons as path parameters in full URLs', () => {
|
||||
const params = parsePathParams('https://example.com/start/1:2:AHLS-HASD/form');
|
||||
expect(params).toEqual([]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Url Utils - URN parsing', () => {
|
||||
it('should handle basic URN segments correctly', () => {
|
||||
// Test case from issue #5817 - Don't treat URN segments as path parameters
|
||||
const params = parsePathParams('https://example.com/urn:ard:show:3479462da794e97');
|
||||
expect(params).toEqual([]);
|
||||
|
||||
// Test case for path parameter that starts with urn:
|
||||
const params2 = parsePathParams('https://example.com/:urn_type');
|
||||
expect(params2).toEqual([{ name: 'urn_type', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle URNs with special characters', () => {
|
||||
const params = parsePathParams('https://example.com/urn:isbn:0-330.12345-X');
|
||||
expect(params).toEqual([]);
|
||||
|
||||
// URN with percent-encoded characters
|
||||
const params2 = parsePathParams('https://example.com/urn:uuid:6e8bc430%2D9c3a-11d9-9669-0800200c9a66');
|
||||
expect(params).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle mixed URN and path parameter scenarios', () => {
|
||||
// URN followed by path parameter
|
||||
const params = parsePathParams('https://example.com/urn:nbn:de:bvb/123/:section');
|
||||
expect(params).toEqual([{ name: 'section', value: '' }]);
|
||||
|
||||
// Path parameter followed by URN
|
||||
const params2 = parsePathParams('https://example.com/:type/urn:isbn:123');
|
||||
expect(params2).toEqual([{ name: 'type', value: '' }]);
|
||||
|
||||
// URN-like path parameter (not a real URN)
|
||||
const params3 = parsePathParams('https://example.com/:urn:type');
|
||||
expect(params3).toEqual([{ name: 'urn:type', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle edge cases with URN-like patterns', () => {
|
||||
// URN with uppercase (should be case-insensitive)
|
||||
const params = parsePathParams('https://example.com/URN:isbn:123');
|
||||
expect(params).toEqual([]);
|
||||
|
||||
// Path that looks like URN but isn't
|
||||
const params2 = parsePathParams('https://example.com/noturn:something:here');
|
||||
expect(params2).toEqual([]);
|
||||
|
||||
// Multiple colons in path parameter
|
||||
const params3 = parsePathParams('https://example.com/:urn:isbn:type');
|
||||
expect(params3).toEqual([{ name: 'urn:isbn:type', value: '' }]);
|
||||
});
|
||||
|
||||
it('should handle URNs in complex URLs', () => {
|
||||
// URN with query parameters
|
||||
const params = parsePathParams('https://example.com/urn:isbn:123?format=:format');
|
||||
expect(params).toEqual([]);
|
||||
|
||||
// Multiple URNs and path parameters
|
||||
const params2 = parsePathParams('https://example.com/:category/urn:isbn:123/:subcategory/urn:issn:456');
|
||||
expect(params2).toEqual([
|
||||
{ name: 'category', value: '' },
|
||||
{ name: 'subcategory', value: '' }
|
||||
]);
|
||||
|
||||
// URN with fragment
|
||||
const params3 = parsePathParams('https://example.com/urn:nbn:de:bvb:123#:section');
|
||||
expect(params3).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Url Utils - OData parameters', () => {
|
||||
it('should handle OData parameters with escaped quotes', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products(\'ABC\'\'123\')');
|
||||
expect(params).toEqual([]);
|
||||
@@ -169,6 +255,11 @@ describe('Url Utils - parsePathParams', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products(\'123e4567-e89b-12d3-a456-426614174000\')');
|
||||
expect(params).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle OData with query parameters for variable interpolation', () => {
|
||||
const params = parsePathParams('https://example.com/odata/Products?$filter=Category eq \'{{category}}\'&$orderby={{sortField}}');
|
||||
expect(params).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Url Utils - splitOnFirst', () => {
|
||||
|
||||
@@ -116,22 +116,44 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
throw { message: 'Invalid URL format', originalError: e.message };
|
||||
}
|
||||
|
||||
const paramRegex = /[:](\w+)/g;
|
||||
const interpolatedUrlPath = url.pathname
|
||||
.split('/')
|
||||
.filter((path) => path !== '')
|
||||
.map((path) => {
|
||||
const matches = path.match(paramRegex);
|
||||
if (matches) {
|
||||
const paramName = matches[0].slice(1); // Remove the : prefix
|
||||
// traditional path parameters
|
||||
if (path.startsWith(':')) {
|
||||
const paramName = path.slice(1);
|
||||
const existingPathParam = request.pathParams.find(param => param.name === paramName);
|
||||
if (!existingPathParam) {
|
||||
return '/' + path;
|
||||
}
|
||||
return '/' + path.replace(':' + paramName, existingPathParam.value);
|
||||
} else {
|
||||
return '/' + path;
|
||||
return '/' + existingPathParam.value;
|
||||
}
|
||||
|
||||
// for OData-style parameters (parameters inside parentheses)
|
||||
// Check if path matches valid OData syntax:
|
||||
// 1. EntitySet('key') or EntitySet(key)
|
||||
// 2. EntitySet(Key1=value1,Key2=value2)
|
||||
// 3. Function(param=value)
|
||||
if (/^[A-Za-z0-9_.-]+\([^)]*\)$/.test(path)) {
|
||||
const paramRegex = /[:](\w+)/g;
|
||||
let match;
|
||||
let result = path;
|
||||
while ((match = paramRegex.exec(path))) {
|
||||
if (match[1]) {
|
||||
let name = match[1].replace(/[')"`]+$/, '');
|
||||
name = name.replace(/^[('"`]+/, '');
|
||||
if (name) {
|
||||
const existingPathParam = request.pathParams.find((param) => param.name === name);
|
||||
if (existingPathParam) {
|
||||
result = result.replace(':' + match[1], existingPathParam.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '/' + result;
|
||||
}
|
||||
return '/' + path;
|
||||
})
|
||||
.join('');
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ const { shouldUseProxy, PatchedHttpsProxyAgent, getSystemProxyEnvVariables } = r
|
||||
const path = require('path');
|
||||
const { parseDataFromResponse } = require('../utils/common');
|
||||
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
|
||||
const { createFormData, buildFormUrlEncodedPayload } = require('../utils/form-data');
|
||||
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 { getOAuth2Token } = require('../utils/oauth2');
|
||||
const { encodeUrl } = require('@usebruno/common').utils;
|
||||
const { encodeUrl, buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
|
||||
|
||||
const onConsoleLog = (type, args) => {
|
||||
console[type](...args);
|
||||
@@ -332,8 +332,14 @@ const runSingleRequest = async function (
|
||||
const contentTypeHeader = Object.keys(request.headers).find(
|
||||
name => name.toLowerCase() === 'content-type'
|
||||
);
|
||||
|
||||
if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') {
|
||||
request.data = buildFormUrlEncodedPayload(request.data);
|
||||
if (Array.isArray(request.data)) {
|
||||
request.data = buildFormUrlEncodedPayload(request.data);
|
||||
} else if (typeof request.data !== 'string') {
|
||||
request.data = qs.stringify(request.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
// if `data` is of string type - return as-is (assumes already encoded)
|
||||
}
|
||||
|
||||
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
|
||||
|
||||
@@ -3,19 +3,6 @@ const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @param {Array.<object>} params The request body Array
|
||||
* @returns {string} Returns a order respecting standard compliant string of form encoded values
|
||||
*/
|
||||
const buildFormUrlEncodedPayload = (params) => {
|
||||
const resultParams = new URLSearchParams();
|
||||
for (const param of params) {
|
||||
resultParams.append(param.name, param.value);
|
||||
}
|
||||
return resultParams.toString();
|
||||
};
|
||||
|
||||
|
||||
const createFormData = (data, collectionPath) => {
|
||||
// make axios work in node using form data
|
||||
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
||||
@@ -51,6 +38,5 @@ const createFormData = (data, collectionPath) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildFormUrlEncodedPayload,
|
||||
createFormData
|
||||
}
|
||||
112
packages/bruno-common/src/utils/form-data.spec.ts
Normal file
112
packages/bruno-common/src/utils/form-data.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { buildFormUrlEncodedPayload } from './form-data';
|
||||
|
||||
describe('buildFormUrlEncodedPayload', () => {
|
||||
it('should handle single key-value pair', () => {
|
||||
const requestObj = [{ name: 'item', value: 2 }];
|
||||
const expected = 'item=2';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple key-value pairs with unique keys', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item1', value: 2 },
|
||||
{ name: 'item2', value: 3 }
|
||||
];
|
||||
const expected = 'item1=2&item2=3';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple key-value pairs with the same key', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item', value: 2 },
|
||||
{ name: 'item', value: 3 }
|
||||
];
|
||||
const expected = 'item=2&item=3';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle mixed key-value pairs with unique and duplicate keys', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item1', value: 2 },
|
||||
{ name: 'item2', value: 3 },
|
||||
{ name: 'item1', value: 4 }
|
||||
];
|
||||
const expected = 'item1=2&item2=3&item1=4';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = buildFormUrlEncodedPayload([]);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should handle array with undefined and null values', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item1', value: undefined },
|
||||
{ name: 'item2', value: null as any },
|
||||
{ name: 'item3', value: '' },
|
||||
{ name: 'item4', value: 0 }
|
||||
];
|
||||
const expected = 'item1=&item2=&item3=&item4=0';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle array with special characters in names and values', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item with spaces', value: 'value with spaces' },
|
||||
{ name: 'item&special', value: 'value&special' },
|
||||
{ name: 'item=equals', value: 'value=equals' },
|
||||
{ name: 'item%percent', value: 'value%percent' }
|
||||
];
|
||||
const expected = 'item+with+spaces=value+with+spaces&item%26special=value%26special&item%3Dequals=value%3Dequals&item%25percent=value%25percent';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle array with numeric and boolean values', () => {
|
||||
const requestObj = [
|
||||
{ name: 'number', value: 42 },
|
||||
{ name: 'float', value: 3.14 },
|
||||
{ name: 'boolean_true', value: true },
|
||||
{ name: 'boolean_false', value: false }
|
||||
];
|
||||
const expected = 'number=42&float=3.14&boolean_true=true&boolean_false=false';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should preserve parameter order in array format', () => {
|
||||
const requestObj = [
|
||||
{ name: 'z', value: '1' },
|
||||
{ name: 'a', value: '2' },
|
||||
{ name: 'm', value: '3' }
|
||||
];
|
||||
const expected = 'z=1&a=2&m=3';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should ignore invalid items inside params array', () => {
|
||||
const requestObj: any[] = [
|
||||
{ name: 'item1', value: 'a' },
|
||||
'not-an-object',
|
||||
{ value: 'missingName' },
|
||||
42,
|
||||
{ name: 'item2', value: 'b' },
|
||||
{ name: 'item3' }, // missing value should default to empty string
|
||||
null,
|
||||
undefined,
|
||||
{ name: '', value: 'empty_name' }, // empty name should still work
|
||||
{ name: 'valid', value: 'c' }
|
||||
];
|
||||
const expected = 'item1=a&item2=b&item3=&=empty_name&valid=c';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
33
packages/bruno-common/src/utils/form-data.ts
Normal file
33
packages/bruno-common/src/utils/form-data.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Builds a URL-encoded payload from various data formats
|
||||
*
|
||||
* This function handles multiple input formats:
|
||||
* - Array of objects with 'name' and 'value' properties (preserves order)
|
||||
*
|
||||
* @param data The request body data
|
||||
* @returns URL-encoded string suitable for application/x-www-form-urlencoded content type
|
||||
*
|
||||
* @example
|
||||
* // Array format (preserves order)
|
||||
* buildFormUrlEncodedPayload([{name: 'a', value: '1'}, {name: 'b', value: '2'}])
|
||||
* // Returns: 'a=1&b=2'
|
||||
*/
|
||||
export const buildFormUrlEncodedPayload = (params: Array<{ name: string; value: string | number | boolean | undefined }>): string => {
|
||||
// Ensure params is iterable (array)
|
||||
if (!Array.isArray(params)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const resultParams = new URLSearchParams();
|
||||
|
||||
for (const param of params) {
|
||||
// Invalid items are ignored
|
||||
if (typeof param !== 'object' || param === null) continue;
|
||||
if (!('name' in param)) continue;
|
||||
|
||||
// Append parameter with value (default to empty string if undefined/null)
|
||||
resultParams.append(param.name, String(param.value ?? ''));
|
||||
}
|
||||
|
||||
return resultParams.toString();
|
||||
};
|
||||
@@ -2,4 +2,8 @@ export {
|
||||
encodeUrl,
|
||||
parseQueryParams,
|
||||
buildQueryString,
|
||||
} from './url';
|
||||
} from './url';
|
||||
|
||||
export {
|
||||
buildFormUrlEncodedPayload
|
||||
} from './form-data';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import postmanToBruno from '../../../src/postman/postman-to-bruno';
|
||||
import { invalidVariableCharacterRegex } from '../../../src/constants';
|
||||
|
||||
describe('postman-collection', () => {
|
||||
it('should correctly import a valid Postman collection file', async () => {
|
||||
@@ -7,6 +8,25 @@ describe('postman-collection', () => {
|
||||
expect(brunoCollection).toMatchObject(expectedOutput);
|
||||
});
|
||||
|
||||
it('should replace invalid variable characters with underscores', () => {
|
||||
const variables = [
|
||||
{ key: 'validKey', value: 'value1' },
|
||||
{ key: 'invalid key', value: 'value2' },
|
||||
{ key: 'another@invalid#key$', value: 'value3' }
|
||||
];
|
||||
|
||||
const processedVariables = variables.map((v) => ({
|
||||
name: v.key.replace(invalidVariableCharacterRegex, '_'),
|
||||
value: v.value
|
||||
}));
|
||||
|
||||
expect(processedVariables).toEqual([
|
||||
{ name: 'validKey', value: 'value1' },
|
||||
{ name: 'invalid_key', value: 'value2' },
|
||||
{ name: 'another_invalid_key_', value: 'value3' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle falsy values in collection variables', async () => {
|
||||
const collectionWithFalsyVars = {
|
||||
"info": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const qs = require('qs');
|
||||
const https = require('https');
|
||||
const axios = require('axios');
|
||||
const path = require('path');
|
||||
const qs = require('qs');
|
||||
const decomment = require('decomment');
|
||||
const contentDispositionParser = require('content-disposition');
|
||||
const mime = require('mime-types');
|
||||
@@ -23,7 +23,7 @@ const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../util
|
||||
const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseDataFromRequest } = require('../../utils/common');
|
||||
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
|
||||
const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies');
|
||||
const { createFormData, buildFormUrlEncodedPayload } = require('../../utils/form-data');
|
||||
const { createFormData } = require('../../utils/form-data');
|
||||
const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection');
|
||||
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials } = require('../../utils/oauth2');
|
||||
const { preferencesUtil } = require('../../store/preferences');
|
||||
@@ -35,6 +35,7 @@ const { cookiesStore } = require('../../store/cookies');
|
||||
const registerGrpcEventHandlers = require('./grpc-event-handlers');
|
||||
const { registerWsEventHandlers } = require('./ws-event-handlers');
|
||||
const { getCertsAndProxyConfig } = require('./cert-utils');
|
||||
const { buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
|
||||
|
||||
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
|
||||
|
||||
@@ -276,6 +277,7 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
|
||||
const runtimeVars = collection.runtimeVariables;
|
||||
|
||||
// Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars
|
||||
const processEnvVars = getProcessEnvVars(collection.uid);
|
||||
const resolvedVars = merge(
|
||||
{},
|
||||
globalEnvironmentVars,
|
||||
@@ -283,7 +285,14 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
|
||||
envVars,
|
||||
folderVars,
|
||||
requestVariables,
|
||||
runtimeVars
|
||||
runtimeVars,
|
||||
{
|
||||
process: {
|
||||
env: {
|
||||
...processEnvVars
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const collectionRoot = get(collection, 'root', {});
|
||||
@@ -300,7 +309,6 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
|
||||
}
|
||||
|
||||
const collectionPath = collection.pathname;
|
||||
const processEnvVars = getProcessEnvVars(collection.uid);
|
||||
|
||||
const axiosInstance = await configureRequest(
|
||||
collection.uid,
|
||||
@@ -423,11 +431,18 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
}
|
||||
|
||||
// stringify the request url encoded params
|
||||
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
|
||||
request.data = buildFormUrlEncodedPayload(request.data);
|
||||
const contentTypeHeader = Object.keys(request.headers).find((name) => name.toLowerCase() === 'content-type');
|
||||
|
||||
if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') {
|
||||
if (Array.isArray(request.data)) {
|
||||
request.data = buildFormUrlEncodedPayload(request.data);
|
||||
} else if (typeof request.data !== 'string') {
|
||||
request.data = qs.stringify(request.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
// if `data` is of string type - return as-is (assumes already encoded)
|
||||
}
|
||||
|
||||
if (request.headers['content-type'] === 'multipart/form-data') {
|
||||
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
|
||||
if (!(request.data instanceof FormData)) {
|
||||
request._originalMultipartData = request.data;
|
||||
request.collectionPath = collectionPath;
|
||||
|
||||
@@ -141,22 +141,44 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
throw { message: 'Invalid URL format', originalError: e.message };
|
||||
}
|
||||
|
||||
const paramRegex = /[:](\w+)/g;
|
||||
const urlPathnameInterpolatedWithPathParams = url.pathname
|
||||
.split('/')
|
||||
.filter((path) => path !== '')
|
||||
.map((path) => {
|
||||
const matches = path.match(paramRegex);
|
||||
if (matches) {
|
||||
const paramName = matches[0].slice(1); // Remove the : prefix
|
||||
// traditional path parameters
|
||||
if (path.startsWith(':')) {
|
||||
const paramName = path.slice(1);
|
||||
const existingPathParam = request.pathParams.find(param => param.name === paramName);
|
||||
if (!existingPathParam) {
|
||||
return '/' + path;
|
||||
}
|
||||
return '/' + path.replace(':' + paramName, existingPathParam.value);
|
||||
} else {
|
||||
return '/' + path;
|
||||
return '/' + existingPathParam.value;
|
||||
}
|
||||
|
||||
// for OData-style parameters (parameters inside parentheses)
|
||||
// Check if path matches valid OData syntax:
|
||||
// 1. EntitySet('key') or EntitySet(key)
|
||||
// 2. EntitySet(Key1=value1,Key2=value2)
|
||||
// 3. Function(param=value)
|
||||
if (/^[A-Za-z0-9_.-]+\([^)]*\)$/.test(path)) {
|
||||
const paramRegex = /[:](\w+)/g;
|
||||
let match;
|
||||
let result = path;
|
||||
while ((match = paramRegex.exec(path))) {
|
||||
if (match[1]) {
|
||||
let name = match[1].replace(/[')"`]+$/, '');
|
||||
name = name.replace(/^[('"`]+/, '');
|
||||
if (name) {
|
||||
const existingPathParam = request.pathParams.find((param) => param.name === name);
|
||||
if (existingPathParam) {
|
||||
result = result.replace(':' + match[1], existingPathParam.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '/' + result;
|
||||
}
|
||||
return '/' + path;
|
||||
})
|
||||
.join('');
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ const decomment = require('decomment');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, getFormattedCollectionOauth2Credentials, mergeAuth } = require('../../utils/collection');
|
||||
const { buildFormUrlEncodedPayload } = require('../../utils/form-data');
|
||||
const path = require('node:path');
|
||||
const { isLargeFile } = require('../../utils/filesystem');
|
||||
|
||||
|
||||
@@ -3,19 +3,6 @@ const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @param {Array.<object>} params The request body Array
|
||||
* @returns {string} Returns a order respecting standard compliant string of form encoded values
|
||||
*/
|
||||
const buildFormUrlEncodedPayload = (params) => {
|
||||
const resultParams = new URLSearchParams();
|
||||
for (const param of params) {
|
||||
resultParams.append(param.name, param.value);
|
||||
}
|
||||
return resultParams.toString();
|
||||
};
|
||||
|
||||
|
||||
const createFormData = (data, collectionPath) => {
|
||||
// make axios work in node using form data
|
||||
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
||||
@@ -51,6 +38,5 @@ const createFormData = (data, collectionPath) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildFormUrlEncodedPayload,
|
||||
createFormData
|
||||
};
|
||||
|
||||
@@ -63,4 +63,32 @@ describe('prepareGqlIntrospectionRequest', () => {
|
||||
expect(result.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should handle process.env variables in endpoint URL', () => {
|
||||
const setup = createBasicSetup();
|
||||
setup.endpoint = 'https://{{process.env.API_HOST}}/graphql';
|
||||
const vars = {
|
||||
process: {
|
||||
env: {
|
||||
API_HOST: 'api.example.com'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareGqlIntrospectionRequest(setup.endpoint, vars, setup.request, setup.collectionRoot);
|
||||
|
||||
expect(result.url).toBe('https://api.example.com/graphql');
|
||||
expect(result.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should handle missing process.env variables gracefully', () => {
|
||||
const setup = createBasicSetup();
|
||||
setup.request.headers = [
|
||||
{ name: 'X-API-Key', value: '{{process.env.MISSING_VAR}}', enabled: true }
|
||||
];
|
||||
|
||||
const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot);
|
||||
|
||||
expect(result.headers['X-API-Key']).toBe('{{process.env.MISSING_VAR}}');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
|
||||
const { prepareRequest } = require('../../src/ipc/network/prepare-request');
|
||||
const { buildFormUrlEncodedPayload } = require('../../src/utils/form-data');
|
||||
|
||||
describe('prepare-request: prepareRequest', () => {
|
||||
describe('Decomments request body', () => {
|
||||
@@ -20,44 +19,6 @@ describe('prepare-request: prepareRequest', () => {
|
||||
const result = await prepareRequest({ request: { body }, collection: { pathname: '' } });
|
||||
expect(result.data).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle single key-value pair', () => {
|
||||
const requestObj = [{ name: 'item', value: 2 }];
|
||||
const expected = 'item=2';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple key-value pairs with unique keys', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item1', value: 2 },
|
||||
{ name: 'item2', value: 3 }
|
||||
];
|
||||
const expected = 'item1=2&item2=3';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple key-value pairs with the same key', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item', value: 2 },
|
||||
{ name: 'item', value: 3 }
|
||||
];
|
||||
const expected = 'item=2&item=3';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle mixed key-value pairs with unique and duplicate keys', () => {
|
||||
const requestObj = [
|
||||
{ name: 'item1', value: 2 },
|
||||
{ name: 'item2', value: 3 },
|
||||
{ name: 'item1', value: 4 }
|
||||
];
|
||||
const expected = 'item1=2&item2=3&item1=4';
|
||||
const result = buildFormUrlEncodedPayload(requestObj);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(['POST', 'PUT', 'PATCH'])('POST request with no body', (method) => {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"cheerio": "^1.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"json-query": "^2.2.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "3.3.8",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { cloneDeep } = require('lodash');
|
||||
const xmlFormat = require('xml-formatter');
|
||||
const { interpolate: _interpolate } = require('@usebruno/common');
|
||||
const { sendRequest } = require('@usebruno/requests').scripting;
|
||||
const { jar: createCookieJar } = require('@usebruno/requests').cookies;
|
||||
@@ -74,6 +75,50 @@ class Bru {
|
||||
this.nextRequest = nextRequest;
|
||||
}
|
||||
};
|
||||
|
||||
this.utils = {
|
||||
minifyJson: (json) => {
|
||||
if (json === null || json === undefined) {
|
||||
throw new Error('Failed to minify');
|
||||
}
|
||||
|
||||
if (typeof json === 'object') {
|
||||
try {
|
||||
return JSON.stringify(json);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to minify: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof json === 'string') {
|
||||
const trimmed = json.trim();
|
||||
if (trimmed === '') return trimmed;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to minify: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TypeError('minifyJson expects a string or object');
|
||||
},
|
||||
|
||||
minifyXml: (xml) => {
|
||||
if (xml === null || xml === undefined) {
|
||||
throw new Error('Failed to minify');
|
||||
}
|
||||
|
||||
if (typeof xml === 'string') {
|
||||
try {
|
||||
return xmlFormat(xml, { collapseContent: false, indentation: '', lineSeparator: '' });
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to minify: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TypeError('minifyXml expects a string');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interpolate = (strOrObj) => {
|
||||
|
||||
@@ -33,6 +33,7 @@ const NodeVault = require('node-vault');
|
||||
const xml2js = require('xml2js');
|
||||
const cheerio = require('cheerio');
|
||||
const tv4 = require('tv4');
|
||||
const jsonwebtoken = require('jsonwebtoken');
|
||||
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
|
||||
|
||||
class ScriptRuntime {
|
||||
@@ -185,6 +186,7 @@ class ScriptRuntime {
|
||||
'node-fetch': fetch,
|
||||
'crypto-js': CryptoJS,
|
||||
xml2js: xml2js,
|
||||
jsonwebtoken,
|
||||
cheerio,
|
||||
tv4,
|
||||
...whitelistedModules,
|
||||
@@ -354,6 +356,7 @@ class ScriptRuntime {
|
||||
'node-fetch': fetch,
|
||||
'crypto-js': CryptoJS,
|
||||
'xml2js': xml2js,
|
||||
jsonwebtoken,
|
||||
cheerio,
|
||||
tv4,
|
||||
...whitelistedModules,
|
||||
|
||||
@@ -35,6 +35,7 @@ const NodeVault = require('node-vault');
|
||||
const xml2js = require('xml2js');
|
||||
const cheerio = require('cheerio');
|
||||
const tv4 = require('tv4');
|
||||
const jsonwebtoken = require('jsonwebtoken');
|
||||
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
|
||||
|
||||
class TestRuntime {
|
||||
@@ -103,7 +104,8 @@ class TestRuntime {
|
||||
res,
|
||||
expect: chai.expect,
|
||||
assert: chai.assert,
|
||||
__brunoTestResults: __brunoTestResults
|
||||
__brunoTestResults: __brunoTestResults,
|
||||
jwt: jsonwebtoken
|
||||
};
|
||||
|
||||
if (onConsoleLog && typeof onConsoleLog === 'function') {
|
||||
@@ -176,6 +178,7 @@ class TestRuntime {
|
||||
'xml2js': xml2js,
|
||||
cheerio,
|
||||
tv4,
|
||||
'jsonwebtoken': jsonwebtoken,
|
||||
...whitelistedModules,
|
||||
fs: allowScriptFilesystemAccess ? fs : undefined,
|
||||
'node-vault': NodeVault
|
||||
|
||||
@@ -87,9 +87,10 @@ const addBrunoRequestShimToContext = (vm, req) => {
|
||||
vm.setProp(reqObject, 'setHeader', setHeader);
|
||||
setHeader.dispose();
|
||||
|
||||
let getBody = vm.newFunction('getBody', function () {
|
||||
return marshallToVm(req.getBody(), vm);
|
||||
let getBody = vm.newFunction('getBody', function (options) {
|
||||
return marshallToVm(req.getBody(vm.dump(options)), vm);
|
||||
});
|
||||
|
||||
vm.setProp(reqObject, 'getBody', getBody);
|
||||
getBody.dispose();
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ const addAxiosShimToContext = require('./axios');
|
||||
const addNanoidShimToContext = require('./nanoid');
|
||||
const addPathShimToContext = require('./path');
|
||||
const addUuidShimToContext = require('./uuid');
|
||||
const addJwtShimToContext = require('./jwt');
|
||||
|
||||
const addLibraryShimsToContext = async (vm) => {
|
||||
await addNanoidShimToContext(vm);
|
||||
await addAxiosShimToContext(vm);
|
||||
await addUuidShimToContext(vm);
|
||||
await addPathShimToContext(vm);
|
||||
await addJwtShimToContext(vm);
|
||||
};
|
||||
|
||||
module.exports = addLibraryShimsToContext;
|
||||
|
||||
181
packages/bruno-js/src/sandbox/quickjs/shims/lib/jwt.js
Normal file
181
packages/bruno-js/src/sandbox/quickjs/shims/lib/jwt.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { marshallToVm, invokeFunction } = require('../../utils');
|
||||
|
||||
const addJwtShimToContext = async (vm) => {
|
||||
// --- sign ---
|
||||
const _jwtSign = vm.newFunction('sign', function (payload, secret, options, callback) {
|
||||
const nativePayload = vm.dump(payload);
|
||||
const nativeSecret = vm.dump(secret);
|
||||
|
||||
let nativeOptions;
|
||||
let callbackHandle = callback;
|
||||
const optionsType = options === undefined ? 'undefined' : vm.typeof(options);
|
||||
if (optionsType === 'function') {
|
||||
callbackHandle = options;
|
||||
nativeOptions = undefined;
|
||||
} else if (optionsType === 'object' && options !== null) {
|
||||
nativeOptions = vm.dump(options);
|
||||
}
|
||||
|
||||
// If a callback is provided
|
||||
if (callbackHandle && vm.typeof(callbackHandle) === 'function') {
|
||||
let tokenResult;
|
||||
let hostError;
|
||||
try {
|
||||
tokenResult = nativeOptions
|
||||
? jwt.sign(nativePayload, nativeSecret, nativeOptions)
|
||||
: jwt.sign(nativePayload, nativeSecret);
|
||||
} catch (err) {
|
||||
hostError = err;
|
||||
}
|
||||
|
||||
try {
|
||||
if (hostError) {
|
||||
const errVm = vm.newError(hostError.message || String(hostError));
|
||||
invokeFunction(vm, callbackHandle, [errVm, vm.undefined])
|
||||
.catch((e) => {
|
||||
console.warn('[JWT SHIM][sign.cb] callback invocation error:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
errVm.dispose();
|
||||
callbackHandle.dispose();
|
||||
});
|
||||
} else {
|
||||
const tokenVm = marshallToVm(String(tokenResult), vm);
|
||||
invokeFunction(vm, callbackHandle, [vm.null, tokenVm])
|
||||
.catch((e) => {
|
||||
console.warn('[JWT SHIM][sign.cb] callback invocation error:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
tokenVm.dispose();
|
||||
callbackHandle.dispose();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[JWT SHIM][sign.cb] unexpected error:', e);
|
||||
callbackHandle.dispose();
|
||||
}
|
||||
|
||||
return vm.undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = nativeOptions
|
||||
? jwt.sign(nativePayload, nativeSecret, nativeOptions)
|
||||
: jwt.sign(nativePayload, nativeSecret);
|
||||
return marshallToVm(token, vm);
|
||||
} catch (err) {
|
||||
throw vm.newError(err.message || String(err));
|
||||
}
|
||||
});
|
||||
|
||||
vm.setProp(vm.global, '__bruno__jwt__sign', _jwtSign);
|
||||
_jwtSign.dispose();
|
||||
|
||||
// --- verify ---
|
||||
const _jwtVerify = vm.newFunction('verify', function (token, secret, options, callback) {
|
||||
const nativeToken = vm.dump(token);
|
||||
const nativeSecret = vm.dump(secret);
|
||||
|
||||
let nativeOptions;
|
||||
let actualCallback = callback;
|
||||
|
||||
const optionsType = options === undefined ? 'undefined' : vm.typeof(options);
|
||||
if (optionsType === 'function') {
|
||||
actualCallback = options;
|
||||
nativeOptions = undefined;
|
||||
} else if (optionsType === 'object' && options !== null) {
|
||||
nativeOptions = vm.dump(options);
|
||||
}
|
||||
|
||||
if (actualCallback && vm.typeof(actualCallback) === 'function') {
|
||||
let decodedResult;
|
||||
let hostError;
|
||||
try {
|
||||
decodedResult = nativeOptions
|
||||
? jwt.verify(nativeToken, nativeSecret, nativeOptions)
|
||||
: jwt.verify(nativeToken, nativeSecret);
|
||||
} catch (err) {
|
||||
hostError = err;
|
||||
}
|
||||
|
||||
try {
|
||||
if (hostError) {
|
||||
const vmErr = vm.newError(hostError.message || String(hostError));
|
||||
invokeFunction(vm, actualCallback, [vmErr, vm.undefined])
|
||||
.catch((e) => {
|
||||
console.warn('[JWT SHIM][verify.cb] callback invocation error:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
vmErr.dispose();
|
||||
actualCallback.dispose();
|
||||
});
|
||||
} else {
|
||||
const vmNull = vm.null;
|
||||
const vmDecoded = marshallToVm(decodedResult, vm);
|
||||
invokeFunction(vm, actualCallback, [vmNull, vmDecoded])
|
||||
.catch((e) => {
|
||||
console.warn('[JWT SHIM][verify.cb] callback invocation error:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
vmDecoded.dispose();
|
||||
actualCallback.dispose();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[JWT SHIM][verify.cb] unexpected error:', e);
|
||||
actualCallback.dispose();
|
||||
}
|
||||
|
||||
return vm.undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = nativeOptions
|
||||
? jwt.verify(nativeToken, nativeSecret, nativeOptions)
|
||||
: jwt.verify(nativeToken, nativeSecret);
|
||||
return marshallToVm(decoded, vm);
|
||||
} catch (err) {
|
||||
throw vm.newError(err.message || String(err));
|
||||
}
|
||||
});
|
||||
|
||||
vm.setProp(vm.global, '__bruno__jwt__verify', _jwtVerify);
|
||||
_jwtVerify.dispose();
|
||||
|
||||
// --- decode ---
|
||||
const _jwtDecode = vm.newFunction('decode', function (token, options) {
|
||||
const nativeToken = vm.dump(token);
|
||||
|
||||
let nativeOptions;
|
||||
const optionsType = options === undefined ? 'undefined' : vm.typeof(options);
|
||||
if (optionsType === 'object' && options !== null) {
|
||||
nativeOptions = vm.dump(options);
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = nativeOptions
|
||||
? jwt.decode(nativeToken, nativeOptions)
|
||||
: jwt.decode(nativeToken);
|
||||
return marshallToVm(decoded, vm);
|
||||
} catch (err) {
|
||||
throw vm.newError(err.message || String(err));
|
||||
}
|
||||
});
|
||||
|
||||
vm.setProp(vm.global, '__bruno__jwt__decode', _jwtDecode);
|
||||
_jwtDecode.dispose();
|
||||
|
||||
vm.evalCode(`
|
||||
globalThis.jwt = {};
|
||||
globalThis.jwt.sign = globalThis.__bruno__jwt__sign;
|
||||
globalThis.jwt.verify = globalThis.__bruno__jwt__verify;
|
||||
globalThis.jwt.decode = globalThis.__bruno__jwt__decode;
|
||||
globalThis.requireObject = {
|
||||
...globalThis.requireObject,
|
||||
'jsonwebtoken': globalThis.jwt,
|
||||
};
|
||||
`);
|
||||
};
|
||||
|
||||
module.exports = addJwtShimToContext;
|
||||
@@ -30,6 +30,54 @@ const marshallToVm = (value, vm) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Invokes a QuickJS function handle.
|
||||
* - Returns a Promise
|
||||
*
|
||||
* @param {Object} vm - QuickJS VM instance
|
||||
* @param {QuickJSHandle} quickFn - A QuickJS function handle
|
||||
* @param {Array} args - Arguments to pass to the function
|
||||
* @returns {Promise<any>} - The result as a Promise
|
||||
*/
|
||||
async function invokeFunction(vm, quickFn, args = []) {
|
||||
if (vm.typeof(quickFn) !== 'function') {
|
||||
throw new TypeError('Target is not a QuickJS function');
|
||||
}
|
||||
|
||||
const result = vm.callFunction(quickFn, vm.global, ...args);
|
||||
|
||||
if (result.error) {
|
||||
const error = vm.dump(result.error);
|
||||
result.error.dispose();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if the result is a QuickJS Promise handle (async functions)
|
||||
if (vm.typeof(result.value) === 'object' && result.value.constructor && vm.typeof(result.value.constructor) === 'function') {
|
||||
try {
|
||||
const promiseHandle = vm.unwrapResult(result);
|
||||
const resolvedResult = await vm.resolvePromise(promiseHandle);
|
||||
promiseHandle.dispose();
|
||||
const resolvedHandle = vm.unwrapResult(resolvedResult);
|
||||
const value = vm.dump(resolvedHandle);
|
||||
resolvedHandle.dispose();
|
||||
return Promise.resolve(value);
|
||||
} catch (promiseError) {
|
||||
// If it's not a valid Promise, throw an error
|
||||
result.value.dispose();
|
||||
throw new Error(`Invalid Promise handle: ${promiseError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const value = vm.dump(result.value);
|
||||
result.value.dispose();
|
||||
|
||||
return (value && typeof value.then === 'function')
|
||||
? value
|
||||
: Promise.resolve(value);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
marshallToVm
|
||||
marshallToVm,
|
||||
invokeFunction
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ws from 'ws';
|
||||
import { hexy as hexdump } from 'hexy';
|
||||
import { getParsedWsUrlObject } from './ws-url';
|
||||
|
||||
/**
|
||||
* Safely parse JSON string with error handling
|
||||
@@ -21,45 +22,6 @@ const safeParseJSON = (jsonString, context = 'JSON string') => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get parsed WebSocket URL object
|
||||
* @param {string} url - The WebSocket URL
|
||||
* @returns {Object} Parsed URL object with protocol, host, path
|
||||
*/
|
||||
const getParsedWsUrlObject = (url) => {
|
||||
const addProtocolIfMissing = (str) => {
|
||||
if (str.includes('://')) return str;
|
||||
|
||||
// For localhost, default to insecure (grpc://) for local development
|
||||
if (str.includes('localhost') || str.includes('127.0.0.1')) {
|
||||
return `ws://${str}`;
|
||||
}
|
||||
|
||||
// For other hosts, default to secure
|
||||
return `wss://${str}`;
|
||||
};
|
||||
|
||||
const removeTrailingSlash = (str) => (str.endsWith('/') ? str.slice(0, -1) : str);
|
||||
|
||||
if (!url) return { host: '', path: '' };
|
||||
|
||||
try {
|
||||
const urlObj = new URL(addProtocolIfMissing(url.toLowerCase()));
|
||||
return {
|
||||
protocol: urlObj.protocol,
|
||||
host: urlObj.host,
|
||||
path: removeTrailingSlash(urlObj.pathname),
|
||||
search: urlObj.search,
|
||||
fullUrl: urlObj.href
|
||||
};
|
||||
} catch (err) {
|
||||
console.error({ err });
|
||||
return {
|
||||
host: '',
|
||||
path: ''
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
class WsClient {
|
||||
messageQueues = {};
|
||||
|
||||
39
packages/bruno-requests/src/ws/ws-url.js
Normal file
39
packages/bruno-requests/src/ws/ws-url.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Get parsed WebSocket URL object
|
||||
* @param {string} url - The WebSocket URL
|
||||
* @returns {Object} Parsed URL object with protocol, host, path
|
||||
*/
|
||||
export const getParsedWsUrlObject = (url) => {
|
||||
const addProtocolIfMissing = (str) => {
|
||||
if (str.includes('://')) return str;
|
||||
|
||||
// For localhost, default to insecure (grpc://) for local development
|
||||
if (str.includes('localhost') || str.includes('127.0.0.1')) {
|
||||
return `ws://${str}`;
|
||||
}
|
||||
|
||||
// For other hosts, default to secure
|
||||
return `wss://${str}`;
|
||||
};
|
||||
|
||||
const removeTrailingSlash = (str) => (str.endsWith('/') ? str.slice(0, -1) : str);
|
||||
|
||||
if (!url) return { host: '', path: '' };
|
||||
|
||||
try {
|
||||
const urlObj = new URL(addProtocolIfMissing(url));
|
||||
return {
|
||||
protocol: urlObj.protocol,
|
||||
host: urlObj.host,
|
||||
path: removeTrailingSlash(urlObj.pathname),
|
||||
search: urlObj.search,
|
||||
fullUrl: urlObj.href
|
||||
};
|
||||
} catch (err) {
|
||||
console.error({ err });
|
||||
return {
|
||||
host: '',
|
||||
path: ''
|
||||
};
|
||||
}
|
||||
};
|
||||
36
packages/bruno-requests/src/ws/ws-url.spec.ts
Normal file
36
packages/bruno-requests/src/ws/ws-url.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getParsedWsUrlObject } from './ws-url';
|
||||
|
||||
describe('getParsedWsUrlObject', () => {
|
||||
it('returns empty host and path for empty input', () => {
|
||||
expect(getParsedWsUrlObject('')).toEqual({ host: '', path: '' });
|
||||
});
|
||||
|
||||
it('defaults to ws:// for localhost without protocol', () => {
|
||||
const parsed: any = getParsedWsUrlObject('localhost:8080/some/path');
|
||||
expect(parsed.protocol).toBe('ws:');
|
||||
expect(parsed.host).toBe('localhost:8080');
|
||||
expect(parsed.path).toBe('/some/path');
|
||||
expect(parsed.fullUrl.startsWith('ws://')).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to wss:// for external hosts without protocol', () => {
|
||||
const parsed: any = getParsedWsUrlObject('example.com/s');
|
||||
expect(parsed.protocol).toBe('wss:');
|
||||
expect(parsed.host).toBe('example.com');
|
||||
expect(parsed.path).toBe('/s');
|
||||
expect(parsed.fullUrl.startsWith('wss://')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves provided protocol and parses query/search', () => {
|
||||
const parsed: any = getParsedWsUrlObject('wss://example.com/path/With/cAses/?a=1&b=2');
|
||||
expect(parsed.protocol).toBe('wss:');
|
||||
expect(parsed.host).toBe('example.com');
|
||||
expect(parsed.path).toBe('/path/With/cAses');
|
||||
expect(parsed.search).toBe('?a=1&b=2');
|
||||
});
|
||||
|
||||
it('removes trailing slash from path', () => {
|
||||
const parsed: any = getParsedWsUrlObject('ws://127.0.0.1:9000/endpoint/');
|
||||
expect(parsed.path).toBe('/endpoint');
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,10 @@ post {
|
||||
body:form-urlencoded {
|
||||
form-data-key: {{form-data-key}}
|
||||
form-data-stringified-object: {{form-data-stringified-object}}
|
||||
}
|
||||
|
||||
assert {
|
||||
res.body: eq form-data-key=form-data-value&form-data-stringified-object=%7B%22foo%22%3A123%7D
|
||||
key_1: value_1
|
||||
key_2: value_2
|
||||
key_1: value_3
|
||||
key_2: value_4
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
@@ -24,3 +24,18 @@ script:pre-request {
|
||||
bru.setVar('form-data-key', 'form-data-value');
|
||||
bru.setVar('form-data-stringified-object', obj);
|
||||
}
|
||||
|
||||
tests {
|
||||
test("form-urlencoded body with variables and duplicate keys", function() {
|
||||
const expected = [
|
||||
"form-data-key=form-data-value",
|
||||
"form-data-stringified-object=%7B%22foo%22%3A123%7D", // {"foo":123} URL encoded
|
||||
"key_1=value_1",
|
||||
"key_2=value_2",
|
||||
"key_1=value_3", // duplicate key with different value
|
||||
"key_2=value_4" // duplicate key with different value
|
||||
].join("&");
|
||||
|
||||
expect(res.getBody()).to.eql(expected);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
meta {
|
||||
name: array body
|
||||
type: http
|
||||
seq: 8
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{echo-host}}
|
||||
body: formUrlEncoded
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
req.setBody([
|
||||
{name: "empty", value: ""},
|
||||
{name: "null", value: null},
|
||||
{name: "undefined", value: undefined},
|
||||
{name: "zero", value: 0},
|
||||
{name: "false", value: false},
|
||||
{name: "", value: "empty_key"},
|
||||
{name: "key", value: "value1"},
|
||||
{name: "name", value: "bruno"},
|
||||
{name: "key", value: "value2"},
|
||||
]);
|
||||
}
|
||||
|
||||
tests {
|
||||
test("req.setBody() with edge cases - request body", function() {
|
||||
const data = req.getBody();
|
||||
const expected = [
|
||||
"empty=",
|
||||
"null=",
|
||||
"undefined=",
|
||||
"zero=0",
|
||||
"false=false",
|
||||
"=empty_key",
|
||||
"key=value1",
|
||||
"name=bruno",
|
||||
"key=value2"
|
||||
].join("&");
|
||||
|
||||
expect(data).to.eql(expected);
|
||||
});
|
||||
|
||||
test("req.setBody() with edge cases - response body", function() {
|
||||
const data = res.getBody();
|
||||
const expected = [
|
||||
"empty=",
|
||||
"null=",
|
||||
"undefined=",
|
||||
"zero=0",
|
||||
"false=false",
|
||||
"=empty_key",
|
||||
"key=value1",
|
||||
"name=bruno",
|
||||
"key=value2"
|
||||
].join("&");
|
||||
|
||||
expect(data).to.eql(expected);
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
meta {
|
||||
name: content-type via setHeader
|
||||
type: http
|
||||
seq: 7
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{echo-host}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
req.setHeader('content-type', 'application/x-www-form-urlencoded');
|
||||
req.setBody([
|
||||
{name: "key", value: "value"},
|
||||
{name: "name", value: "bruno"}
|
||||
]);
|
||||
}
|
||||
|
||||
tests {
|
||||
test("req.setBody() - request body", function() {
|
||||
const data = req.getBody();
|
||||
expect(data).to.eql("key=value&name=bruno");
|
||||
});
|
||||
|
||||
test("req.setBody() - response body", function() {
|
||||
const data = res.getBody();
|
||||
expect(data).to.eql("key=value&name=bruno");
|
||||
});
|
||||
|
||||
test("Content-Type header is set correctly", function() {
|
||||
const contentType = req.getHeader('content-type');
|
||||
expect(contentType).to.eql('application/x-www-form-urlencoded');
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: form-urlencoded
|
||||
seq: 1
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
meta {
|
||||
name: object body
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{echo-host}}
|
||||
body: formUrlEncoded
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
req.setBody({
|
||||
"key": "value with spaces",
|
||||
"name": "bruno",
|
||||
"array": ["test", "value"],
|
||||
});
|
||||
}
|
||||
|
||||
tests {
|
||||
// https://github.com/usebruno/bruno/issues/5813
|
||||
test("req.setBody() with object - request body", function() {
|
||||
const data = req.getBody();
|
||||
expect(data).to.eql("key=value%20with%20spaces&name=bruno&array=test&array=value");
|
||||
});
|
||||
|
||||
test("req.setBody() with object - response body", function() {
|
||||
const data = res.getBody();
|
||||
expect(data).to.eql("key=value%20with%20spaces&name=bruno&array=test&array=value");
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
meta {
|
||||
name: string body
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{echo-host}}
|
||||
body: formUrlEncoded
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
req.setBody("key=value&name=bruno");
|
||||
}
|
||||
|
||||
tests {
|
||||
test("req.setBody() with string format - request body", function() {
|
||||
const data = req.getBody();
|
||||
expect(data).to.eql("key=value&name=bruno");
|
||||
});
|
||||
|
||||
test("req.setBody() with string format - response body", function() {
|
||||
const data = res.getBody();
|
||||
expect(data).to.eql("key=value&name=bruno");
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -92,6 +92,9 @@ snap install bruno
|
||||
# On Linux via Flatpak
|
||||
flatpak install com.usebruno.Bruno
|
||||
|
||||
# On Arch Linux via AUR
|
||||
yay -S bruno
|
||||
|
||||
# On Linux via Apt
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
sudo apt update && sudo apt install gpg curl
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Global Environment Update",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
meta {
|
||||
name: Global Environment Update
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: none
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
//create a new global env variable.
|
||||
bru.setGlobalEnvVar('newEnv', "newEnvValue");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: Test Request
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/users
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
//update already existing enabled env variable
|
||||
bru.setGlobalEnvVar("existingEnvEnabled", "newExistingEnvEnabledValue");
|
||||
|
||||
//update already existing disabled env variable
|
||||
bru.setGlobalEnvVar("existingEnvDisabled", "newExistingEnvDisabledValue");
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('Global Environment Variable Update via Script', () => {
|
||||
test.afterEach(async ({ pageWithUserData: page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should update global environment values via script and verify the changes', async ({
|
||||
pageWithUserData: page
|
||||
}) => {
|
||||
await test.step('Open the collection from sidebar', async () => {
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'Global Environment Update' }).click();
|
||||
});
|
||||
|
||||
await test.step('Open the test request that has a pre-request script', async () => {
|
||||
await page.locator('.collection-name', { hasText: 'Global Environment Update' }).click();
|
||||
await page.locator('.collection-item-name', { hasText: 'Test Request' }).click();
|
||||
});
|
||||
|
||||
await test.step('Run the request', async () => {
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
});
|
||||
|
||||
await test.step('Open the Global Environment Config modal', async () => {
|
||||
await page.getByTestId('environment-selector-trigger').click();
|
||||
await page.getByTestId('env-tab-global').click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
});
|
||||
|
||||
const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
|
||||
|
||||
await test.step('Verify that the value of "existingEnvEnabled" is updated by the pre-request script', async () => {
|
||||
const updatedExistingEnvEnabledInputDiv = await globalEnvModal.getByTestId('env-var-value-1');
|
||||
const updatedExistingEnvEnabledValue = await updatedExistingEnvEnabledInputDiv.locator('.CodeMirror-line').textContent();
|
||||
await expect(updatedExistingEnvEnabledValue).toContain('newExistingEnvEnabledValue');
|
||||
});
|
||||
|
||||
await test.step('Verify that the value of "existingEnvDisabled" is updated by the pre-request script', async () => {
|
||||
const updatedExistingEnvDisabledInputDiv = await globalEnvModal.getByTestId('env-var-value-2');
|
||||
const updatedExistingEnvDisabledValue = await updatedExistingEnvDisabledInputDiv.locator('.CodeMirror-line').textContent();
|
||||
await expect(updatedExistingEnvDisabledValue).toContain('newExistingEnvDisabledValue');
|
||||
});
|
||||
|
||||
await test.step('Verify that a new env variable "newEnv" is added by the pre-request script to the global environment', async () => {
|
||||
const newEnvInputDiv = await globalEnvModal.getByTestId('env-var-value-3');
|
||||
const newEnvValue = await newEnvInputDiv.locator('.CodeMirror-line').textContent();
|
||||
await expect(newEnvValue).toContain('newEnvValue');
|
||||
});
|
||||
|
||||
await test.step('Verify that the value of "baseUrl" is unchanged.', async () => {
|
||||
const currentBaseUrlInputDiv = await globalEnvModal.getByTestId('env-var-value-0');
|
||||
const currentBaseUrlValue = await currentBaseUrlInputDiv.locator('.CodeMirror-line').textContent();
|
||||
await expect(currentBaseUrlValue).toContain('https://echo.usebruno.com');
|
||||
});
|
||||
|
||||
await test.step('Close the global environment config modal.', async () => {
|
||||
await page.getByTestId('modal-close-button').click();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/environments/update-global-environment-via-script/fixtures/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"environments": [
|
||||
{
|
||||
"uid": "RrPsTcwRnHMv3yljQO3ex",
|
||||
"name": "global",
|
||||
"variables": [
|
||||
{
|
||||
"uid": "VXKOZdkYw0DyI4mlhn6Wr",
|
||||
"name": "baseUrl",
|
||||
"value": "https://echo.usebruno.com",
|
||||
"type": "text",
|
||||
"secret": false,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"uid": "NTwrSscXsaeh4uee6ocJN",
|
||||
"name": "existingEnvEnabled",
|
||||
"value": "existingEnvEnabledValue",
|
||||
"type": "text",
|
||||
"secret": false,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"uid": "PCsUccFm4pktVowXEKRvw",
|
||||
"name": "existingEnvDisabled",
|
||||
"value": "existingEnvDisabledValue",
|
||||
"type": "text",
|
||||
"secret": false,
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"activeGlobalEnvironmentUid": "RrPsTcwRnHMv3yljQO3ex"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/environments/update-global-environment-via-script/fixtures/collection"
|
||||
]
|
||||
}
|
||||
@@ -22,4 +22,6 @@ test('Should verify all support links with correct URL in preference > Support t
|
||||
|
||||
const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true });
|
||||
expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com');
|
||||
});
|
||||
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
|
||||
100
tests/request/encoding/curl-encoding.spec.ts
Normal file
100
tests/request/encoding/curl-encoding.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('Code Generation URL Encoding', () => {
|
||||
test.afterEach(async ({ pageWithUserData: page }) => {
|
||||
try {
|
||||
const modalCloseButton = page.locator('[data-test-id="modal-close-button"]');
|
||||
if (await modalCloseButton.isVisible()) {
|
||||
await modalCloseButton.click();
|
||||
await modalCloseButton.waitFor({ state: 'hidden' });
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Should generate code with proper URL encoding for unencoded input', async ({
|
||||
pageWithUserData: page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('unencoded-test-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('unencoded-test-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'unencoded-test-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'unencoded-test-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('unencoded-request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://base.source?name=John Doe');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'unencoded-request' })).toBeVisible();
|
||||
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'unencoded-request' }).click();
|
||||
|
||||
await page.locator('#send-request .infotip').first().click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('dialog').locator('.bruno-modal-header-title')).toContainText('Generate Code');
|
||||
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
|
||||
expect(generatedCode).toContain('http://base.source/?name=John%20Doe');
|
||||
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
|
||||
await page.locator('[data-test-id="modal-close-button"]').waitFor({ state: 'hidden' });
|
||||
});
|
||||
|
||||
test('Should generate code with proper URL encoding for encoded input', async ({
|
||||
pageWithUserData: page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('encoded-test-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('encoded-test-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'encoded-test-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'encoded-test-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('encoded-request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://base.source?name=John%20Doe');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'encoded-request' })).toBeVisible();
|
||||
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'encoded-request' }).click();
|
||||
|
||||
await page.locator('#send-request .infotip').first().click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('dialog').locator('.bruno-modal-header-title')).toContainText('Generate Code');
|
||||
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
|
||||
expect(generatedCode).toContain('http://base.source/?name=John%20Doe');
|
||||
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
|
||||
await page.locator('[data-test-id="modal-close-button"]').waitFor({ state: 'hidden' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "jsonwebtoken",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
meta {
|
||||
name: decode
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const testPayload = {
|
||||
userId: 456,
|
||||
username: 'decodeuser',
|
||||
role: 'user',
|
||||
iat: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const secret = bru.getEnvVar('secret') || 'test-secret-key';
|
||||
const testToken = jwt.sign(testPayload, secret, { algorithm: 'HS256', expiresIn: '1h' });
|
||||
|
||||
try {
|
||||
console.log('Testing JWT decoding...');
|
||||
console.log('Test token:', testToken);
|
||||
|
||||
const decoded = jwt.decode(testToken);
|
||||
|
||||
bru.setEnvVar('decoded_payload', JSON.stringify(decoded));
|
||||
|
||||
} catch (error) {
|
||||
console.error('JWT decoding failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("Decoded payload should exist", function() {
|
||||
const decodedPayload = bru.getEnvVar('decoded_payload');
|
||||
expect(decodedPayload).to.exist;
|
||||
});
|
||||
|
||||
test("Decoded payload should contain correct user data", function() {
|
||||
const decodedPayload = JSON.parse(bru.getEnvVar('decoded_payload'));
|
||||
|
||||
expect(decodedPayload.userId).to.equal(456);
|
||||
expect(decodedPayload.username).to.equal('decodeuser');
|
||||
expect(decodedPayload.role).to.equal('user');
|
||||
});
|
||||
|
||||
test("Decoded payload should have timestamp fields", function() {
|
||||
const decodedPayload = JSON.parse(bru.getEnvVar('decoded_payload'));
|
||||
|
||||
expect(decodedPayload.iat).to.exist;
|
||||
expect(decodedPayload.exp).to.exist;
|
||||
expect(typeof decodedPayload.iat).to.equal('number');
|
||||
expect(typeof decodedPayload.exp).to.equal('number');
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: decode
|
||||
seq: 3
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
vars {
|
||||
host: http://httpfaker.org
|
||||
secret: my-secret-key
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: sign
|
||||
seq: 1
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
meta {
|
||||
name: sign with callback err
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const HS_SECRET = 'supersecret';
|
||||
|
||||
/**
|
||||
* Helper that calls jwt.sign **with a callback** and resolves/rejects
|
||||
* based on the callback's (err, token) — so tests can `await` it.
|
||||
*/
|
||||
function signViaCallback(payload, secret, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.sign(payload, secret, options, (err, token) => {
|
||||
if (err) return reject(err);
|
||||
resolve(token);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ERROR TESTS — jwt.sign should call callback with `err`
|
||||
============================================================ */
|
||||
|
||||
test('ERROR (callback) — missing secret for HS256', async function () {
|
||||
try {
|
||||
await signViaCallback({ sub: 'no_secret' }, undefined, { algorithm: 'HS256' });
|
||||
throw new Error('Expected jwt.sign to error via callback');
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(Error);
|
||||
expect(String(err.message)).to.match(/secret|private key must have a value/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('ERROR (callback) — invalid expiresIn format', async function () {
|
||||
try {
|
||||
await signViaCallback({ sub: 'bad_exp' }, HS_SECRET, { expiresIn: 'not-a-time' });
|
||||
throw new Error('Expected jwt.sign to error via callback');
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(Error);
|
||||
expect(String(err.message)).to.match(/expiresIn/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('ERROR (callback) — unsupported/invalid algorithm', async function () {
|
||||
try {
|
||||
await signViaCallback({ sub: 'bad_alg' }, HS_SECRET, { algorithm: 'FOO256' });
|
||||
throw new Error('Expected jwt.sign to error via callback');
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(Error);
|
||||
expect(String(err.message)).to.match(/algorithm/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('CONTROL (callback) — succeeds when options are valid', async function () {
|
||||
const token = await jwt.sign({ sub: 'ok' }, HS_SECRET, { algorithm: 'HS256', expiresIn: '10m' });
|
||||
expect(token).to.be.a('string');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
meta {
|
||||
name: sign with callback token
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const HS_SECRET = 'supersecret';
|
||||
|
||||
const payload = { sub: 'user123' };
|
||||
|
||||
function once(fn) {
|
||||
let called = false;
|
||||
return (...args) => {
|
||||
if (!called) {
|
||||
called = true;
|
||||
fn(...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function signAsync(payload, secret, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.sign(payload, secret, options, (err, token) => {
|
||||
if (err) reject(err);
|
||||
else resolve(token);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 1. Named Normal Callback
|
||||
// ------------------------------------------------------------
|
||||
test('sign — named normal callback', function () {
|
||||
function signCallback(err, token) {
|
||||
expect(err).to.be.null;
|
||||
expect(token).to.be.a('string');
|
||||
|
||||
// Verify token to ensure correctness
|
||||
const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
|
||||
console.log('Named callback signed token:', token);
|
||||
}
|
||||
|
||||
jwt.sign(payload, HS_SECRET, { algorithm: 'HS256', expiresIn: '15m' }, signCallback);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 2. Anonymous Callback
|
||||
// ------------------------------------------------------------
|
||||
test('sign — anonymous callback', function () {
|
||||
jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, function (err, token) {
|
||||
expect(err).to.be.null;
|
||||
expect(token).to.be.a('string');
|
||||
|
||||
const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
|
||||
console.log('Anonymous callback signed token:', token);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3. Arrow Function Callback
|
||||
// ------------------------------------------------------------
|
||||
test('sign — arrow function callback', function () {
|
||||
jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, (err, token) => {
|
||||
expect(err).to.be.null;
|
||||
expect(token).to.be.a('string');
|
||||
|
||||
const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
|
||||
console.log('Arrow callback signed token:', token);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 4. Bound Method Callback
|
||||
// ------------------------------------------------------------
|
||||
test('sign — bound method callback', function () {
|
||||
const signer = {
|
||||
prefix: '[SIGN]',
|
||||
done(err, token) {
|
||||
expect(err).to.be.null;
|
||||
expect(token).to.be.a('string');
|
||||
|
||||
const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
|
||||
console.log(this.prefix, 'Bound callback signed token:', token);
|
||||
},
|
||||
};
|
||||
|
||||
jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, signer.done.bind(signer));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 5. Higher-Order Callback
|
||||
// ------------------------------------------------------------
|
||||
function makeSignCallback(label) {
|
||||
return (err, token) => {
|
||||
expect(err).to.be.null;
|
||||
expect(token).to.be.a('string');
|
||||
|
||||
const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
|
||||
console.log(label, 'Higher-order callback signed token:', token);
|
||||
};
|
||||
}
|
||||
|
||||
test('sign — higher-order callback', function () {
|
||||
const cb = makeSignCallback('[CUSTOM LABEL]');
|
||||
jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, cb);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 6. Once-Wrapped Callback
|
||||
// ------------------------------------------------------------
|
||||
test('sign — once-wrapped callback', function () {
|
||||
const cb = once((err, token) => {
|
||||
expect(err).to.be.null;
|
||||
expect(token).to.be.a('string');
|
||||
|
||||
const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
|
||||
console.log('Once callback executed and signed token:', token);
|
||||
});
|
||||
|
||||
jwt.sign(payload, HS_SECRET, { algorithm: 'HS256' }, cb);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 7. Promise / Async-Await
|
||||
// ------------------------------------------------------------
|
||||
test('sign — promise wrapper with async/await', async function () {
|
||||
const token = await signAsync(payload, HS_SECRET, { algorithm: 'HS256', expiresIn: '15m' });
|
||||
expect(token).to.be.a('string');
|
||||
|
||||
const decoded = jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
|
||||
console.log('Promise/async signed token:', token);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
meta {
|
||||
name: sign
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const payload = {
|
||||
userId: 123,
|
||||
username: 'testuser',
|
||||
role: 'admin',
|
||||
iat: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const secret = bru.getEnvVar('secret');
|
||||
|
||||
const options = {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '1h'
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Testing JWT encoding...');
|
||||
const token = jwt.sign(payload, secret, options);
|
||||
|
||||
console.log('JWT Token encoded successfully:', token);
|
||||
|
||||
bru.setEnvVar('jwt_token', token);
|
||||
|
||||
bru.setEnvVar('original_payload', JSON.stringify(payload));
|
||||
|
||||
console.log('JWT encoding test passed!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('JWT encoding failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
const atob = require('atob')
|
||||
|
||||
test("JWT token should be generated", function() {
|
||||
const jwtToken = bru.getEnvVar('jwt_token');
|
||||
expect(jwtToken).to.exist;
|
||||
});
|
||||
|
||||
test("JWT token should be a string", function() {
|
||||
const jwtToken = bru.getEnvVar('jwt_token');
|
||||
expect(typeof jwtToken).to.equal('string');
|
||||
});
|
||||
|
||||
test("JWT token should have 3 parts (header.payload.signature)", function() {
|
||||
const jwtToken = bru.getEnvVar('jwt_token');
|
||||
const parts = jwtToken.split('.');
|
||||
expect(parts.length).to.equal(3);
|
||||
});
|
||||
|
||||
test("JWT token should be valid base64", function() {
|
||||
const jwtToken = bru.getEnvVar('jwt_token');
|
||||
const parts = jwtToken.split('.');
|
||||
|
||||
// Test that each part is valid base64
|
||||
parts.forEach((part, index) => {
|
||||
try {
|
||||
atob(part);
|
||||
} catch (e) {
|
||||
throw new Error(`JWT part ${index + 1} is not valid base64`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("JWT token should contain expected payload data", function() {
|
||||
const jwtToken = bru.getEnvVar('jwt_token');
|
||||
const originalPayload = JSON.parse(bru.getEnvVar('original_payload'));
|
||||
|
||||
// Decode the payload part (second part of JWT)
|
||||
const parts = jwtToken.split('.');
|
||||
const payloadPart = parts[1];
|
||||
const decodedPayload = JSON.parse(atob(payloadPart));
|
||||
console.log(decodedPayload)
|
||||
|
||||
expect(decodedPayload.userId).to.equal(originalPayload.userId);
|
||||
expect(decodedPayload.username).to.equal(originalPayload.username);
|
||||
expect(decodedPayload.role).to.equal(originalPayload.role);
|
||||
});
|
||||
|
||||
test("JWT token should have proper header", function() {
|
||||
const jwtToken = bru.getEnvVar('jwt_token');
|
||||
const parts = jwtToken.split('.');
|
||||
const headerPart = parts[0];
|
||||
const header = JSON.parse(atob(headerPart));
|
||||
|
||||
expect(header.alg).to.equal('HS256');
|
||||
expect(header.typ).to.equal('JWT');
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: verify
|
||||
seq: 2
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
meta {
|
||||
name: verify with callback err
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const HS_SECRET = 'supersecret';
|
||||
|
||||
function verifyViaCallback(token, secret, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, secret, options, (err, decoded) => {
|
||||
if (err) return reject(err);
|
||||
resolve(decoded);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createValidToken(payload = { sub: 'user123' }, secret = HS_SECRET) {
|
||||
return jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '1h' });
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ERROR TESTS — jwt.verify should call callback with `err`
|
||||
============================================================ */
|
||||
|
||||
test('ERROR (callback) — malformed token', async function () {
|
||||
const malformedToken = 'abc.def'; // not a valid JWT
|
||||
try {
|
||||
await verifyViaCallback(malformedToken, HS_SECRET, { algorithms: ['HS256'] });
|
||||
throw new Error('Expected jwt.verify to error via callback');
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(Error);
|
||||
expect(String(err.message)).to.match(/jwt malformed|invalid token/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('ERROR (callback) — invalid signature (wrong secret)', async function () {
|
||||
const token = createValidToken(); // signed with HS_SECRET
|
||||
try {
|
||||
await verifyViaCallback(token, 'wrong_secret', { algorithms: ['HS256'] });
|
||||
throw new Error('Expected jwt.verify to error via callback');
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(Error);
|
||||
expect(String(err.message)).to.match(/invalid signature/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('ERROR (callback) — invalid algorithm', async function () {
|
||||
const token = createValidToken();
|
||||
try {
|
||||
// Pass unsupported algorithm intentionally
|
||||
await verifyViaCallback(token, HS_SECRET, { algorithms: ['RS256'] });
|
||||
throw new Error('Expected jwt.verify to error due to invalid algorithm');
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(Error);
|
||||
expect(String(err.message)).to.match(/invalid algorithm/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('ERROR (callback) — missing secret', async function () {
|
||||
const token = createValidToken();
|
||||
try {
|
||||
await verifyViaCallback(token, undefined, { algorithms: ['HS256'] });
|
||||
throw new Error('Expected jwt.verify to error due to missing secret');
|
||||
} catch (err) {
|
||||
expect(err).to.be.instanceOf(Error);
|
||||
expect(String(err.message)).to.match(/secret|key must be provided/i);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
meta {
|
||||
name: verify with callback token
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const HS_SECRET = 'supersecret';
|
||||
|
||||
const token = jwt.sign({ sub: 'user123' }, HS_SECRET, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
});
|
||||
|
||||
function once(fn) {
|
||||
let called = false;
|
||||
return (...args) => {
|
||||
if (!called) {
|
||||
called = true;
|
||||
fn(...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function verifyAsync(token, secret, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, secret, options, (err, decoded) => {
|
||||
if (err) reject(err);
|
||||
else resolve(decoded);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('verify — named normal callback', function () {
|
||||
function verifyCallback(err, decoded) {
|
||||
expect(err).to.be.null;
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
console.log('Named callback verified user:', decoded.sub);
|
||||
}
|
||||
|
||||
jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, verifyCallback);
|
||||
});
|
||||
|
||||
test('verify — anonymous callback', function () {
|
||||
jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, function (err, decoded) {
|
||||
expect(err).to.be.null;
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
console.log('Anonymous callback verified user:', decoded.sub);
|
||||
});
|
||||
});
|
||||
|
||||
test('verify — arrow function callback', function () {
|
||||
jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, (err, decoded) => {
|
||||
expect(err).to.be.null;
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
console.log('Arrow callback verified user:', decoded.sub);
|
||||
});
|
||||
});
|
||||
|
||||
test('verify — bound method callback', function () {
|
||||
const handler = {
|
||||
prefix: '[VERIFY]',
|
||||
done(err, decoded) {
|
||||
expect(err).to.be.null;
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
console.log(this.prefix, 'Bound callback verified user:', decoded.sub);
|
||||
},
|
||||
};
|
||||
|
||||
jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, handler.done.bind(handler));
|
||||
});
|
||||
|
||||
function makeVerifyCallback(label) {
|
||||
return (err, decoded) => {
|
||||
expect(err).to.be.null;
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
console.log(label, 'Higher-order callback verified user:', decoded.sub);
|
||||
};
|
||||
}
|
||||
|
||||
test('verify — higher-order callback', function () {
|
||||
const cb = makeVerifyCallback('[CUSTOM LABEL]');
|
||||
jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, cb);
|
||||
});
|
||||
|
||||
test('verify — once-wrapped callback', function () {
|
||||
const cb = once((err, decoded) => {
|
||||
expect(err).to.be.null;
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
console.log('Once callback executed and verified user:', decoded.sub);
|
||||
});
|
||||
|
||||
jwt.verify(token, HS_SECRET, { algorithms: ['HS256'] }, cb);
|
||||
});
|
||||
|
||||
test('verify — promise wrapper with async/await', async function () {
|
||||
const decoded = await verifyAsync(token, HS_SECRET, { algorithms: ['HS256'] });
|
||||
expect(decoded.sub).to.equal('user123');
|
||||
console.log('Promise/async verified user:', decoded.sub);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
meta {
|
||||
name: verify
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const validPayload = {
|
||||
userId: 789,
|
||||
username: 'verifyuser',
|
||||
role: 'admin',
|
||||
iat: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const secret = bru.getEnvVar('secret') || 'test-secret-key';
|
||||
const wrongSecret = 'wrong-secret-key';
|
||||
|
||||
const validToken = jwt.sign(validPayload, secret, { algorithm: 'HS256', expiresIn: '1h' });
|
||||
const invalidToken = jwt.sign(validPayload, wrongSecret, { algorithm: 'HS256', expiresIn: '1h' });
|
||||
|
||||
|
||||
bru.setEnvVar('valid_token', validToken);
|
||||
bru.setEnvVar('invalid_token', invalidToken);
|
||||
|
||||
try {
|
||||
console.log('Testing JWT verification...');
|
||||
console.log('Valid token:', validToken);
|
||||
|
||||
const verified = jwt.verify(validToken, secret);
|
||||
|
||||
const verifiedWithOptions = jwt.verify(validToken, secret, {
|
||||
algorithms: ['HS256'],
|
||||
ignoreExpiration: false
|
||||
});
|
||||
if (!verifiedWithOptions) {
|
||||
throw new Error('Verification with options should work');
|
||||
}
|
||||
|
||||
console.log('JWT verification test passed!');
|
||||
|
||||
bru.setEnvVar('verified_payload', JSON.stringify(verified));
|
||||
|
||||
} catch (error) {
|
||||
console.error('JWT verification failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("Verified payload should exist", function() {
|
||||
const verifiedPayload = bru.getEnvVar('verified_payload');
|
||||
expect(verifiedPayload).to.exist;
|
||||
});
|
||||
|
||||
test("Verified payload should be valid JSON", function() {
|
||||
const verifiedPayload = bru.getEnvVar('verified_payload');
|
||||
const parsed = JSON.parse(verifiedPayload);
|
||||
expect(typeof parsed).to.equal('object');
|
||||
});
|
||||
|
||||
test("Verified payload should contain correct user data", function() {
|
||||
const verifiedPayload = JSON.parse(bru.getEnvVar('verified_payload'));
|
||||
|
||||
expect(verifiedPayload.userId).to.equal(789);
|
||||
expect(verifiedPayload.username).to.equal('verifyuser');
|
||||
expect(verifiedPayload.role).to.equal('admin');
|
||||
});
|
||||
|
||||
test("Verified payload should have timestamp fields", function() {
|
||||
const verifiedPayload = JSON.parse(bru.getEnvVar('verified_payload'));
|
||||
|
||||
expect(verifiedPayload.iat).to.exist;
|
||||
expect(verifiedPayload.exp).to.exist;
|
||||
expect(typeof verifiedPayload.iat).to.equal('number');
|
||||
expect(typeof verifiedPayload.exp).to.equal('number');
|
||||
});
|
||||
|
||||
test("Invalid token with wrong secret should throw error", function() {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const invalidToken = bru.getEnvVar('invalid_token');
|
||||
const secret = bru.getEnvVar('secret') || 'test-secret-key';
|
||||
|
||||
try {
|
||||
jwt.verify(invalidToken, secret);
|
||||
expect.fail('Expected JWT verification to throw an error for invalid token');
|
||||
} catch (error) {
|
||||
expect(error).to.exist;
|
||||
expect(error.message).to.equal('invalid signature');
|
||||
console.log('Invalid token correctly threw error:', error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": ["{{projectRoot}}/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection"],
|
||||
"preferences": {
|
||||
"request": {
|
||||
"sslVerification": true,
|
||||
"customCaCertificate": {
|
||||
"enabled": false,
|
||||
"filePath": ""
|
||||
},
|
||||
"keepDefaultCaCertificates": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"pathname": "{{projectRoot}}/tests/scripting/inbuilt-libraries/jsonwebtoken/fixtures/collection",
|
||||
"selectedEnvironment": "Prod"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from '../../../../playwright';
|
||||
|
||||
test.describe.serial('jwt collection success', () => {
|
||||
test('developer mode', async ({ pageWithUserData: page }) => {
|
||||
// init dev mode
|
||||
await page.getByTitle('jsonwebtoken').click();
|
||||
await page.getByLabel('Developer Mode(use only if').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
test.setTimeout(2 * 60 * 1000);
|
||||
|
||||
// Run the collection
|
||||
await page.locator('.collection-actions').hover();
|
||||
await page.locator('.collection-actions .icon').click();
|
||||
await page.getByText('Run', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run Collection' }).click();
|
||||
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
|
||||
|
||||
// Parse and validate test results
|
||||
const result = await page.getByText('Total Requests: ').innerText();
|
||||
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
|
||||
if (!matches) {
|
||||
throw new Error('Could not parse test results');
|
||||
}
|
||||
const [totalRequests, passed, failed, skipped] = matches.slice(1);
|
||||
|
||||
await expect(parseInt(totalRequests)).toBe(7);
|
||||
await expect(parseInt(passed)).toBe(7);
|
||||
await expect(parseInt(failed)).toBe(0);
|
||||
await expect(parseInt(skipped)).toBe(0);
|
||||
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
|
||||
});
|
||||
|
||||
test('safe mode', async ({ pageWithUserData: page }) => {
|
||||
// init safe mode
|
||||
await page.getByTitle('jsonwebtoken').click();
|
||||
await page.getByText('Developer Mode').click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
test.setTimeout(2 * 60 * 1000);
|
||||
|
||||
// Run the collection
|
||||
await page.locator('.collection-actions').hover();
|
||||
await page.locator('.collection-actions .icon').click();
|
||||
await page.getByText('Run', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run Collection' }).click();
|
||||
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
|
||||
|
||||
// Parse and validate test results
|
||||
const result = await page.getByText('Total Requests: ').innerText();
|
||||
const matches = result.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/);
|
||||
if (!matches) {
|
||||
throw new Error('Could not parse test results');
|
||||
}
|
||||
const [totalRequests, passed, failed, skipped] = matches.slice(1);
|
||||
|
||||
await expect(parseInt(totalRequests)).toBe(7);
|
||||
await expect(parseInt(passed)).toBe(7);
|
||||
await expect(parseInt(failed)).toBe(0);
|
||||
await expect(parseInt(skipped)).toBe(0);
|
||||
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user