diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 38ca44be3..10a488875 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -22,6 +22,7 @@ import NewRequest from 'components/Sidebar/NewRequest/index'; import GradientCloseButton from './GradientCloseButton'; import { flattenItems } from 'utils/collections/index'; import { closeWsConnection } from 'utils/network/index'; +import { getInvalidVariableNames } from 'utils/common/variables'; import ExampleTab from '../ExampleTab'; import toast from 'react-hot-toast'; @@ -356,6 +357,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi window.addEventListener('dotenv-save-failed', onFailed, { once: true }); window.dispatchEvent(new Event('dotenv-save')); } else if (draft?.environmentUid && draft?.variables) { + const invalidNames = getInvalidVariableNames(draft.variables); + if (invalidNames.length > 0) { + toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`); + return; + } dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid)) .then(() => { dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); @@ -402,6 +408,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi window.addEventListener('dotenv-save-failed', onFailed, { once: true }); window.dispatchEvent(new Event('dotenv-save')); } else if (draft?.environmentUid && draft?.variables) { + const invalidNames = getInvalidVariableNames(draft.variables); + if (invalidNames.length > 0) { + toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`); + return; + } dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })) .then(() => { dispatch(clearGlobalEnvironmentDraft()); diff --git a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js index 7030c5fcf..799b7f197 100644 --- a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js +++ b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js @@ -6,6 +6,7 @@ import { useSelector } from 'react-redux'; import { useDispatch } from 'react-redux'; import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections'; import { pluralizeWord } from 'utils/common'; +import { getInvalidVariableNames } from 'utils/common/variables'; import { completeQuitFlow } from 'providers/ReduxStore/slices/app'; import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; import { saveGlobalEnvironment, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; @@ -13,6 +14,7 @@ import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvi import { IconAlertTriangle } from '@tabler/icons'; import Modal from 'components/Modal'; import Button from 'ui/Button'; +import toast from 'react-hot-toast'; const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [] }) => { const MAX_UNSAVED_ITEMS_TO_SHOW = 5; @@ -166,14 +168,27 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [ await dispatch(saveMultipleRequests(requestDrafts)); } - // Save all collection environment drafts - for (const draft of collectionEnvironmentDrafts) { - await dispatch(saveEnvironment(draft.variables, draft.environmentUid, draft.collectionUid)); + // Save environment drafts, skipping any with invalid variable names + const allEnvironmentDrafts = [...collectionEnvironmentDrafts, ...globalEnvironmentDrafts]; + let hasSkippedEnvs = false; + + for (const draft of allEnvironmentDrafts) { + const invalidNames = getInvalidVariableNames(draft.variables); + if (invalidNames.length > 0) { + hasSkippedEnvs = true; + toast.error(`Cannot save "${draft.name}": invalid variable name(s) — ${invalidNames.join(', ')}`); + continue; + } + + if (draft.type === 'collection-environment') { + await dispatch(saveEnvironment(draft.variables, draft.environmentUid, draft.collectionUid)); + } else { + await dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })); + } } - // Save all global environment drafts - for (const draft of globalEnvironmentDrafts) { - await dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })); + if (hasSkippedEnvs) { + return; } if (forceCloseTabs) { diff --git a/packages/bruno-app/src/utils/common/regex.spec.js b/packages/bruno-app/src/utils/common/regex.spec.js index a4ce6eecf..3d8562d87 100644 --- a/packages/bruno-app/src/utils/common/regex.spec.js +++ b/packages/bruno-app/src/utils/common/regex.spec.js @@ -1,6 +1,7 @@ const { describe, it, expect } = require('@jest/globals'); import { sanitizeName, validateName } from './regex'; +import { hasInvalidVariableNames } from './variables'; describe('regex validators', () => { describe('sanitize name', () => { @@ -163,3 +164,85 @@ describe('sanitizeName and validateName', () => { }); }); }); + +describe('hasInvalidVariableNames', () => { + describe('valid variable names', () => { + it('should return false for alphanumeric names with underscores, hyphens, and dots', () => { + const validVariables = [ + { name: 'valid_name', value: 'test' }, + { name: 'valid-name', value: 'test' }, + { name: 'valid.name', value: 'test' }, + { name: 'validName123', value: 'test' } + ]; + expect(hasInvalidVariableNames(validVariables)).toBe(false); + }); + + it('should return false for empty array (no variables to validate)', () => { + expect(hasInvalidVariableNames([])).toBe(false); + }); + }); + + describe('invalid variable names', () => { + it('should return true for variable names with unicode letters (not in \\w)', () => { + const variables = [{ name: 'válid_ñame', value: 'test' }]; + expect(hasInvalidVariableNames(variables)).toBe(true); + }); + + it('should return true for variable names containing spaces', () => { + const variables = [{ name: 'invalid name', value: 'test' }]; + expect(hasInvalidVariableNames(variables)).toBe(true); + }); + + it('should return true for variable names with special characters', () => { + const invalidVariables = [ + { name: 'invalid@name', value: 'test' }, + { name: 'invalid!name', value: 'test' }, + { name: 'invalid#name', value: 'test' }, + { name: 'invalid$name', value: 'test' } + ]; + invalidVariables.forEach((variable) => { + expect(hasInvalidVariableNames([variable])).toBe(true); + }); + }); + + it('should return true if any variable in array has invalid name', () => { + const mixedVariables = [ + { name: 'valid_name', value: 'test' }, + { name: 'invalid name', value: 'test' }, + { name: 'another_valid', value: 'test' } + ]; + expect(hasInvalidVariableNames(mixedVariables)).toBe(true); + }); + }); + + describe('empty/placeholder variable names (skipped)', () => { + it('should skip variables with empty names (placeholder rows in UI)', () => { + const variables = [ + { name: '', value: 'test' }, + { name: ' ', value: 'test' } + ]; + expect(hasInvalidVariableNames(variables)).toBe(false); + }); + + it('should skip variables without name property', () => { + const variables = [ + { value: 'test' }, + { name: null, value: 'test' } + ]; + expect(hasInvalidVariableNames(variables)).toBe(false); + }); + }); + + describe('defensive handling of invalid inputs', () => { + it('should return false for null or undefined (no variables to validate)', () => { + expect(hasInvalidVariableNames(null)).toBe(false); + expect(hasInvalidVariableNames(undefined)).toBe(false); + }); + + it('should return false for non-array inputs (defensive check)', () => { + expect(hasInvalidVariableNames('string')).toBe(false); + expect(hasInvalidVariableNames(123)).toBe(false); + expect(hasInvalidVariableNames({})).toBe(false); + }); + }); +}); diff --git a/packages/bruno-app/src/utils/common/variables.js b/packages/bruno-app/src/utils/common/variables.js new file mode 100644 index 000000000..a0aa7881d --- /dev/null +++ b/packages/bruno-app/src/utils/common/variables.js @@ -0,0 +1,21 @@ +import { variableNameRegex } from './regex'; + +/** + * Returns the list of invalid variable names from a variables array. + * Skips empty/placeholder names (empty string or whitespace-only). + */ +export const getInvalidVariableNames = (variables) => { + if (!variables || !Array.isArray(variables)) return []; + return variables + .filter((variable) => variable.name && variable.name.trim() !== '' && !variableNameRegex.test(variable.name)) + .map((variable) => variable.name); +}; + +/** + * Checks whether any variable in the array has an invalid name. + * Uses variableNameRegex from regex.js — names may only contain + * word characters (\\w), hyphens, and dots. + */ +export const hasInvalidVariableNames = (variables) => { + return getInvalidVariableNames(variables).length > 0; +};