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:
Pooja
2026-01-27 22:06:17 +05:30
committed by GitHub
parent 51276beaf1
commit 21673f46de
7 changed files with 192 additions and 28 deletions

View File

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

View File

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

View File

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

View File

@@ -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}
/>

View File

@@ -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

View 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');
});
});
});

View File

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