mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-30 08:04:09 +00:00
feat: add header validation (#6859)
* feat: add header validation * fix: test stability * fix: scope the locator --------- Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -32,6 +33,22 @@ const Headers = ({ collection }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key === 'name') {
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!headerNameRegex.test(row.name)) {
|
||||
return 'Header name cannot contain spaces or newlines';
|
||||
}
|
||||
}
|
||||
if (key === 'value') {
|
||||
if (!row.value) return null;
|
||||
if (!headerValueRegex.test(row.value)) {
|
||||
return 'Header value cannot contain newlines';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -101,6 +118,7 @@ const Headers = ({ collection }) => {
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -179,15 +179,34 @@ const EditableTable = ({
|
||||
const value = column.getValue ? column.getValue(row) : row[column.key];
|
||||
const error = getRowError?.(row, rowIndex, column.key);
|
||||
|
||||
const errorIcon = error && !isEmpty ? (
|
||||
<span>
|
||||
<IconAlertCircle
|
||||
data-tooltip-id={`error-${row.uid}-${column.key}`}
|
||||
className="text-red-600 cursor-pointer ml-1"
|
||||
size={20}
|
||||
/>
|
||||
<Tooltip
|
||||
className="tooltip-mod"
|
||||
id={`error-${row.uid}-${column.key}`}
|
||||
html={error}
|
||||
/>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (column.render) {
|
||||
return column.render({
|
||||
row,
|
||||
value,
|
||||
rowIndex,
|
||||
isLastEmptyRow: isEmpty,
|
||||
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue),
|
||||
error
|
||||
});
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{column.render({
|
||||
row,
|
||||
value,
|
||||
rowIndex,
|
||||
isLastEmptyRow: isEmpty,
|
||||
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue)
|
||||
})}
|
||||
{errorIcon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -204,20 +223,7 @@ const EditableTable = ({
|
||||
placeholder={isEmpty ? column.placeholder || column.name : ''}
|
||||
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
|
||||
/>
|
||||
{error && !isEmpty && (
|
||||
<span>
|
||||
<IconAlertCircle
|
||||
data-tooltip-id={`error-${row.uid}-${column.key}`}
|
||||
className="text-red-600 cursor-pointer"
|
||||
size={20}
|
||||
/>
|
||||
<Tooltip
|
||||
className="tooltip-mod"
|
||||
id={`error-${row.uid}-${column.key}`}
|
||||
html={error}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{errorIcon}
|
||||
</div>
|
||||
);
|
||||
}, [isLastEmptyRow, getRowError, handleValueChange]);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -36,6 +37,22 @@ const Headers = ({ collection, folder }) => {
|
||||
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key === 'name') {
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!headerNameRegex.test(row.name)) {
|
||||
return 'Header name cannot contain spaces or newlines';
|
||||
}
|
||||
}
|
||||
if (key === 'value') {
|
||||
if (!row.value) return null;
|
||||
if (!headerValueRegex.test(row.value)) {
|
||||
return 'Header value cannot contain newlines';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -106,6 +123,7 @@ const Headers = ({ collection, folder }) => {
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -38,6 +39,22 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const getRowError = useCallback((row, index, key) => {
|
||||
if (key === 'name') {
|
||||
if (!row.name || row.name.trim() === '') return null;
|
||||
if (!headerNameRegex.test(row.name)) {
|
||||
return 'Header name cannot contain spaces or newlines';
|
||||
}
|
||||
}
|
||||
if (key === 'value') {
|
||||
if (!row.value) return null;
|
||||
if (!headerValueRegex.test(row.value)) {
|
||||
return 'Header value cannot contain newlines';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
@@ -49,7 +66,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -67,7 +84,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
key: 'value',
|
||||
name: 'Value',
|
||||
placeholder: 'Value',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
render: ({ value, onChange, isLastEmptyRow }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
theme={storedTheme}
|
||||
@@ -110,6 +127,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
rows={headers || []}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
reorderable={true}
|
||||
onReorder={handleHeaderDrag}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,12 @@ const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invali
|
||||
|
||||
export const variableNameRegex = /^[\w-.]*$/;
|
||||
|
||||
// HTTP header name should not contain spaces, newlines, or control characters
|
||||
export const headerNameRegex = /^[^\s\r\n]*$/;
|
||||
|
||||
// HTTP header value should not contain newlines
|
||||
export const headerValueRegex = /^[^\r\n]*$/;
|
||||
|
||||
export const sanitizeName = (name) => {
|
||||
name = name
|
||||
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
|
||||
|
||||
93
tests/request/headers/header-validation.spec.ts
Normal file
93
tests/request/headers/header-validation.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, createCollection, createRequest, openCollection, openRequest, saveRequest, selectRequestPaneTab } from '../../utils/page';
|
||||
import { getTableCell } from '../../utils/page/locators';
|
||||
|
||||
test.describe.serial('Header Validation', () => {
|
||||
test.afterAll(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test.beforeAll(async ({ page, createTmpDir }) => {
|
||||
await test.step('Create collection and request', async () => {
|
||||
await createCollection(page, 'header-validation', await createTmpDir('header-validation'));
|
||||
await createRequest(page, 'test-headers', '', {
|
||||
url: 'https://httpbin.org/get',
|
||||
inFolder: false
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open the request', async () => {
|
||||
await openCollection(page, 'header-validation');
|
||||
await openRequest(page, 'header-validation', 'test-headers', { persist: true });
|
||||
});
|
||||
});
|
||||
|
||||
test('should show error icon when header name contains spaces', async ({ page, createTmpDir }) => {
|
||||
await test.step('Navigate to Headers tab', async () => {
|
||||
await selectRequestPaneTab(page, 'Headers');
|
||||
});
|
||||
|
||||
await test.step('Enter header name with space and verify error icon', async () => {
|
||||
const headerRow = page.locator('table tbody tr').first();
|
||||
const nameCell = getTableCell(headerRow, 0);
|
||||
|
||||
// Click on the CodeMirror editor and type a header name with space
|
||||
await nameCell.locator('.CodeMirror').click();
|
||||
await nameCell.locator('textarea').fill('invalid header');
|
||||
|
||||
// Verify the error icon is visible
|
||||
const errorIcon = headerRow.locator('.text-red-600');
|
||||
await expect(errorIcon).toBeVisible();
|
||||
|
||||
// Hover over the error icon to show the tooltip
|
||||
await errorIcon.hover();
|
||||
|
||||
// Verify the tooltip message
|
||||
const tooltip = page.locator('.tooltip-mod');
|
||||
await expect(tooltip).toContainText('Header name cannot contain spaces or newlines');
|
||||
});
|
||||
|
||||
await test.step('Enter valid header name and verify no error icon', async () => {
|
||||
const headerRow = page.locator('table tbody tr').first();
|
||||
const nameCell = getTableCell(headerRow, 0);
|
||||
|
||||
// Clear and enter a valid header name
|
||||
await nameCell.locator('.CodeMirror').click();
|
||||
await page.keyboard.press('Meta+a');
|
||||
await nameCell.locator('textarea').fill('Valid-Header');
|
||||
|
||||
// Verify the error icon is not visible
|
||||
const errorIcon = headerRow.locator('.text-red-600');
|
||||
await expect(errorIcon).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show error icon when header value contains newlines', async ({ page }) => {
|
||||
await test.step('Navigate to Headers tab', async () => {
|
||||
await selectRequestPaneTab(page, 'Headers');
|
||||
});
|
||||
|
||||
await test.step('Enter header value with newline and verify error icon', async () => {
|
||||
const headerRow = page.locator('table tbody tr').first();
|
||||
const valueCell = getTableCell(headerRow, 1);
|
||||
|
||||
// Click on the value CodeMirror editor and type a value with newline
|
||||
await valueCell.locator('.CodeMirror').click();
|
||||
await valueCell.locator('textarea').fill('header\nValue');
|
||||
|
||||
// Verify the error icon is visible
|
||||
const errorIcon = headerRow.locator('.text-red-600');
|
||||
await expect(errorIcon).toBeVisible();
|
||||
|
||||
// Hover over the error icon to show the tooltip
|
||||
await errorIcon.hover();
|
||||
|
||||
// Verify the tooltip message
|
||||
const tooltip = page.locator('.tooltip-mod');
|
||||
await expect(tooltip).toContainText('Header value cannot contain newlines');
|
||||
|
||||
// Save the request
|
||||
await page.keyboard.press('Control+s');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -549,13 +549,17 @@ const sendRequest = async (
|
||||
* @param collectionName - The name of the collection
|
||||
* @param requestName - The name of the request
|
||||
*/
|
||||
const openRequest = async (page: Page, collectionName: string, requestName: string) => {
|
||||
const openRequest = async (page: Page, collectionName: string, requestName: string, { persist = false } = {}) => {
|
||||
await test.step(`Navigate to collection "${collectionName}" and open request "${requestName}"`, async () => {
|
||||
const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName });
|
||||
await collectionContainer.click();
|
||||
const collectionWrapper = collectionContainer.locator('..');
|
||||
const request = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName });
|
||||
await request.click();
|
||||
if (!persist) {
|
||||
await request.click();
|
||||
} else {
|
||||
await request.dblclick();
|
||||
}
|
||||
});
|
||||
};
|
||||
/**
|
||||
@@ -642,8 +646,9 @@ const getResponseBody = async (page: Page): Promise<string> => {
|
||||
|
||||
const selectRequestPaneTab = async (page: Page, tabName: string) => {
|
||||
await test.step(`Wait for request to open up "${tabName}"`, async () => {
|
||||
await expect(page.locator('.request-pane > .px-4')).toBeVisible();
|
||||
await expect(page.locator('.tabs')).toBeVisible();
|
||||
const requestPane = page.locator('.request-pane > .px-4');
|
||||
await expect(requestPane).toBeVisible();
|
||||
await expect(requestPane.locator('.tabs')).toBeVisible();
|
||||
});
|
||||
await test.step(`Select request pane tab "${tabName}"`, async () => {
|
||||
const visibleTab = page.locator('.tabs').getByRole('tab', { name: tabName });
|
||||
|
||||
Reference in New Issue
Block a user