From 03327a8c2140ab16c74ee7d3bb3da39cfb670c7b Mon Sep 17 00:00:00 2001 From: Ian Yenien Serrano <63758389+yenienserrano@users.noreply.github.com> Date: Mon, 2 Dec 2024 16:37:33 +0100 Subject: [PATCH] Support for CDB lists beginning with quotes (#7171) * CDB list (list-editor.tsx): Change split to match * Changelog: Entry added * Add validation to cdb list inputs * Test(list-editor): Add some test * Validation(list-editor.tsx): start and end with quotes * Test(list-editor): Add case with ipv6 * Request change(list-editor): Applying requested changes * Test(error cases): Add test to error cases to key or value validation * Validation(list-editor): Add colon validation. * Validation (list-editor): Apply comments * Validation(list-editor): Fix error found on test cration * Test jest(list-editor): Add test to add and remove * Test jest(list-editor): Add test to save CDB list * Test jest(list-editor): Add test to edit and cancel edit * Validations(list-editor): Refactor validations functions * Validation(list-editor): Fix warning in console * Test jest:(list-editor): Add new cases * Test jest:(list-editor): Add new tests * Test jest(list-editor): Add test name and path exist --- CHANGELOG.md | 2 +- .../cdblists/views/list-editor.test.tsx | 413 ++++++++++++++++++ .../management/cdblists/views/list-editor.tsx | 232 ++++++++-- 3 files changed, 614 insertions(+), 33 deletions(-) create mode 100644 plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cf9a42f8..63e802cb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to the Wazuh app project will be documented in this file. ### Fixed -- Added ending quotes to CDB Lists keys [#7159](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7159) +- Fixed rendering of rows in CDB list table when it starts with quotes. [#7171](https://github.com/wazuh/wazuh-dashboard-plugins/issues/7171) ## Wazuh v4.10.1 - OpenSearch Dashboards 2.16.0 - Revision 00 diff --git a/plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.test.tsx b/plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.test.tsx new file mode 100644 index 0000000000..ceb9fe77e5 --- /dev/null +++ b/plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.test.tsx @@ -0,0 +1,413 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import WzListEditor from './list-editor'; + +jest.mock('../../../../../../react-services/common-services', () => ({ + getErrorOrchestrator: () => ({ + handleError: () => {}, + }), +})); + +jest.mock('../../common/resources-handler', () => ({ + ResourcesHandler: jest.fn().mockImplementation(() => ({ + updateFile: jest.fn().mockResolvedValue({ + data: { + error: 0, + data: 'Success', + }, + }), + })), + ResourcesConstants: { + LISTS: 'lists', + }, + resourceDictionary: { + lists: { + resourcePath: '/lists', + permissionResource: value => `list:file:${value}`, + }, + }, +})); + +const mockAdd = jest.fn(); +jest.mock('../../../../../../kibana-services', () => ({ + getToasts: () => ({ + add: mockAdd, + }), +})); + +const mockStore = configureMockStore(); +const store = mockStore({ + appStateReducers: { + userAccount: { + administrator: true, + }, + withUserLogged: true, + userPermissions: { + 'lists:read': { '*:*:*': 'allow' }, + }, + }, +}); + +describe('WzListEditor', () => { + describe('Without data in the list', () => { + it('should handle empty list content correctly', async () => { + const emptyListContent = { + content: '', + name: false, + path: '/lists/startTest', + }; + + render( + + + , + ); + + expect(screen.getByText('No results...')).toBeInTheDocument(); + + const saveButton = screen.getByText('Save'); + + expect(saveButton.closest('button')).toBeDisabled(); + + const addNewEntryButton = screen.getByText('Add new entry'); + + fireEvent.click(addNewEntryButton); + + const keyInput = screen.getByPlaceholderText('Key'); + const valueInput = screen.getByPlaceholderText('Value'); + + fireEvent.change(keyInput, { target: { value: 'testAddKey' } }); + fireEvent.change(valueInput, { target: { value: 'testAddValue' } }); + + const addButton = screen.getByText('Add'); + + expect(addButton.closest('button')).not.toBeDisabled(); + + fireEvent.click(addButton); + + expect(screen.getByText('testAddKey')).toBeInTheDocument(); + expect(screen.getByText('testAddValue')).toBeInTheDocument(); + + expect(saveButton.closest('button')).not.toBeDisabled(); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockAdd).toHaveBeenCalledWith({ + title: 'Invalid name', + color: 'warning', + text: 'CDB list name cannot be empty', + toastLifeTimeMs: 3000, + }); + }); + + const nameInput = screen.getByPlaceholderText('New CDB list name'); + + fireEvent.change(nameInput, { target: { value: 'test' } }); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockAdd).toHaveBeenCalledWith({ + title: 'Success', + color: 'success', + text: 'CBD List successfully created', + toastLifeTimeMs: 3000, + }); + }); + }); + }); + + describe('With data in the list', () => { + const cdblist = [ + { + key: 'test', + value: 'testValue', + }, + { + key: '":test"', + value: '":testValue"', + }, + { + key: 'test1', + value: '":key"', + }, + { + key: '":test1"', + value: 'value1', + }, + { + key: '"test2"', + value: '"value2"', + }, + { + key: 'test3', + value: '', + }, + { + key: '"a0:a0:a0:a0:a0:a0"', + value: '', + }, + ]; + + const messagesError = { + quotesError: 'Must start and end with quotes or have no quotes at all', + colonError: 'Must start and end with quotes when using colon', + simbolsError: + 'Must not contain simbols when using quotes(only letters, numbers and colon)', + }; + + beforeEach(() => { + const cdblistMap = cdblist.map(item => { + return `${item.key}:${item.value}`; + }); + + const listContent = { + name: 'testName', + content: `${cdblistMap.join('\n')}`, + path: '/lists/test', + }; + + render( + + + , + ); + }); + + afterEach(() => { + mockAdd.mockClear(); + }); + + it('should render the component', () => { + expect(screen.getByText('testName')).toBeInTheDocument(); + expect(screen.getByText('/lists/test')).toBeInTheDocument(); + + cdblist.forEach(item => { + expect(screen.getByText(item.key)).toBeInTheDocument(); + if (!(item.value === '')) { + expect(screen.getByText(item.value)).toBeInTheDocument(); + } + expect(screen.queryByText(`${item.key}:${item.value}`)).toBeFalsy(); + }); + }); + + it('should delete the item correctly', async () => { + const deleteButton = screen.queryAllByTestId('deleteButton'); + fireEvent.click(deleteButton[0].closest('button')); + + expect(screen.queryByText(cdblist[0].key)).toBeFalsy(); + }); + + it('should edit the value correctly or cancel the edit', async () => { + expect(screen.queryByText('newValue')).toBeFalsy(); + + const editButton = screen.queryAllByTestId('editButton'); + + fireEvent.click(editButton[0]); + + const valueInput = screen.getByPlaceholderText('New value'); + + fireEvent.change(valueInput, { target: { value: 'newValue' } }); + + const saveEditButton = screen.getByTestId('saveEditButton'); + + fireEvent.click(saveEditButton.closest('button')); + + expect(screen.getByText('newValue')).toBeInTheDocument(); + + fireEvent.click(editButton[1]); + + fireEvent.change(valueInput, { target: { value: 'newValue2' } }); + + const cancelButton = screen.getByTestId('cancelEditButton'); + + fireEvent.click(cancelButton.closest('button')); + + expect(screen.queryByText('newValue2')).toBeFalsy(); + }); + + it('should update file correctly', async () => { + const button = screen.getByText('Add new entry'); + + fireEvent.click(button); + + const keyInput = screen.getByPlaceholderText('Key'); + const valueInput = screen.getByPlaceholderText('Value'); + + fireEvent.change(keyInput, { target: { value: 'newKey' } }); + fireEvent.change(valueInput, { target: { value: 'newValue' } }); + + const addButton = screen.getByText('Add'); + + expect(addButton.closest('button')).not.toBeDisabled(); + + fireEvent.click(addButton); + + const saveButton = screen.getByText('Save'); + + expect(saveButton.closest('button')).not.toBeDisabled(); + + fireEvent.click(saveButton); + + expect(screen.getByText('newKey')).toBeInTheDocument(); + expect(screen.getByText('newValue')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockAdd).toHaveBeenCalledWith({ + title: 'Success', + color: 'success', + text: 'CBD List updated', + toastLifeTimeMs: 3000, + }); + }); + }); + + it('should render the message when try to add duplicated key', async () => { + expect(screen.queryAllByText('test')).toHaveLength(1); + + const button = screen.getByText('Add new entry'); + fireEvent.click(button); + + const keyInput = screen.getByPlaceholderText('Key'); + const valueInput = screen.getByPlaceholderText('Value'); + + fireEvent.change(keyInput, { target: { value: 'test' } }); + fireEvent.change(valueInput, { target: { value: 'testValue' } }); + + const addButton = screen.getByText('Add'); + fireEvent.click(addButton); + + expect(mockAdd).toHaveBeenCalledWith({ + title: 'Error', + color: 'danger', + text: ( + + test key already exists + + ), + toastLifeTimeMs: 3000, + }); + + expect(screen.queryAllByText('test')).toHaveLength(1); + }); + + it.each` + key | value + ${'key'} | ${'value'} + ${'"key"'} | ${'value'} + ${'"key"'} | ${'"value"'} + ${'"key:key"'} | ${'value'} + ${'"key:key"'} | ${'"value:value"'} + ${'"key"'} | ${'"value:value"'} + ${'key'} | ${'"value:value"'} + ${'key'} | ${''} + ${'"key"'} | ${''} + ${'"key:key"'} | ${''} + ${'"b1:b1:b1:b1:b1:b1"'} | ${'test6'} + `( + 'should add keys($key) and values($value) correctly', + async ({ key, value }: { key: string; value: string }) => { + const button = screen.getByText('Add new entry'); + fireEvent.click(button); + + const keyInput = screen.getByPlaceholderText('Key'); + const valueInput = screen.getByPlaceholderText('Value'); + + fireEvent.change(keyInput, { target: { value: key } }); + fireEvent.change(valueInput, { target: { value: value } }); + + const addButton = screen.getByText('Add'); + + expect(addButton.closest('button')).not.toBeDisabled(); + + fireEvent.click(addButton); + + expect(screen.getByText(key)).toBeInTheDocument(); + if (!(value === '')) { + expect(screen.getByText(value)).toBeInTheDocument(); + } + + const saveButton = screen.getByText('Save'); + + expect(saveButton.closest('button')).not.toBeDisabled(); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockAdd).toHaveBeenCalledWith({ + title: 'Success', + color: 'success', + text: 'CBD List updated', + toastLifeTimeMs: 3000, + }); + }); + }, + ); + + it.each` + key | value | quotesError | colonError | simbolsError + ${'":key'} | ${'value'} | ${messagesError.quotesError} | ${messagesError.colonError} | ${''} + ${'".key"'} | ${'":value'} | ${messagesError.quotesError} | ${messagesError.colonError} | ${messagesError.simbolsError} + ${'key"key'} | ${'"value(*&"'} | ${messagesError.quotesError} | ${''} | ${messagesError.simbolsError} + ${'key'} | ${'value"value'} | ${messagesError.quotesError} | ${''} | ${''} + ${'"key!@#"'} | ${'value"'} | ${messagesError.quotesError} | ${''} | ${messagesError.simbolsError} + ${'"key."'} | ${'"value'} | ${messagesError.quotesError} | ${''} | ${messagesError.simbolsError} + ${'"key"key"'} | ${'value'} | ${messagesError.quotesError} | ${''} | ${messagesError.simbolsError} + ${'key'} | ${'"value"value"'} | ${messagesError.quotesError} | ${''} | ${messagesError.simbolsError} + ${'key:key'} | ${'"value;"'} | ${''} | ${messagesError.colonError} | ${messagesError.simbolsError} + ${'key:"key"'} | ${'value'} | ${messagesError.quotesError} | ${messagesError.colonError} | ${''} + ${'key'} | ${':value'} | ${''} | ${messagesError.colonError} | ${''} + ${'"key:key"'} | ${'"value":'} | ${messagesError.quotesError} | ${messagesError.colonError} | ${''} + `( + 'should render the message when try to add invalid key($key) or value($value)', + ({ + key, + value, + quotesError, + colonError, + simbolsError, + }: { + key: string; + value: string; + quotesError: string; + colonError: string; + simbolsError: string; + }) => { + const button = screen.getByText('Add new entry'); + fireEvent.click(button); + + const keyInput = screen.getByPlaceholderText('Key'); + const valueInput = screen.getByPlaceholderText('Value'); + + fireEvent.change(keyInput, { target: { value: key } }); + fireEvent.change(valueInput, { target: { value: value } }); + + const addButton = screen.getByText('Add'); + + expect(addButton.closest('button')).toBeDisabled(); + + if (quotesError) { + expect(screen.getByText(quotesError)).toBeInTheDocument(); + } else { + expect(screen.queryByText(messagesError.quotesError)).toBeFalsy(); + } + + if (colonError) { + expect(screen.getByText(colonError)).toBeInTheDocument(); + } else { + expect(screen.queryByText(messagesError.colonError)).toBeFalsy(); + } + + if (simbolsError) { + expect(screen.getByText(simbolsError)).toBeInTheDocument(); + } else { + expect(screen.queryByText(messagesError.simbolsError)).toBeFalsy(); + } + }, + ); + }); +}); diff --git a/plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.tsx b/plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.tsx index 5900b37483..924773119b 100644 --- a/plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.tsx +++ b/plugins/main/public/controllers/management/components/management/cdblists/views/list-editor.tsx @@ -23,6 +23,7 @@ import { EuiFieldText, EuiSpacer, EuiPanel, + EuiFormRow, } from '@elastic/eui'; import { connect } from 'react-redux'; @@ -45,7 +46,22 @@ import { UI_ERROR_SEVERITIES } from '../../../../../../react-services/error-orch import { UI_LOGGER_LEVELS } from '../../../../../../../common/constants'; import { getErrorOrchestrator } from '../../../../../../react-services/common-services'; +type FieldTypes = 'key' | 'value' | 'edit'; +type FieldStateTypes = 'addingKey' | 'addingValue' | 'editingValue'; +type CheckingsTypes = 'quotes' | 'colon' | 'simbols'; +interface ValidationInterface { + condition: boolean; + checking: CheckingsTypes; +} + class WzListEditor extends Component { + private readonly messagesError = { + quotes: 'Must start and end with quotes or have no quotes at all', + colon: 'Must start and end with quotes when using colon', + simbols: + 'Must not contain simbols when using quotes(only letters, numbers and colon)', + }; + constructor(props) { super(props); this.state = { @@ -58,6 +74,7 @@ class WzListEditor extends Component { editingValue: '', newListName: '', showWarningRestart: false, + isInvalid: [], }; this.items = {}; @@ -91,16 +108,29 @@ class WzListEditor extends Component { * Save in the state as object the items for an easy modification by key-value * @param {String} content */ - contentToObject(content) { - const items = {}; + contentToObject(content: string) { + const items: { + [key: string]: string; + } = {}; const lines = content.split('\n'); + + const regex = /^((?:[^:"]*|"[^"]*")*):(.*)$/; + lines.forEach(line => { - const split = line.startsWith('"') ? line.split('":') : line.split(':'); - // All keys with multiple colons (:) should end with a quotation mark (") - const key = split[0].startsWith('"') ? split[0] + '"' : split[0]; - const value = split[1] || ''; - if (key) items[key] = value; // Prevent add empty keys + // Regex splitting the first : and ignoring the ones inside quotes + const match = line.match(regex); + + if (match) { + const [, key, value] = match; + const trimmedKey = key.trim(); + const trimmedValue = value.trim(); + + if (trimmedKey) { + items[trimmedKey] = trimmedValue; + } + } }); + return items; } @@ -149,7 +179,6 @@ class WzListEditor extends Component { await this.resourcesHandler.updateFile(name, raw, overwrite); if (!addingNew) { const file = { name: name, content: raw, path: path }; - this.props.updateListContent(file); this.setState({ showWarningRestart: true }); this.showToast( 'success', @@ -157,6 +186,7 @@ class WzListEditor extends Component { 'CBD List successfully created', 3000, ); + this.props.updateListContent(file); } else { this.setState({ showWarningRestart: true }); this.showToast('success', 'Success', 'CBD List updated', 3000); @@ -178,7 +208,7 @@ class WzListEditor extends Component { this.setState({ isSaving: false }); } - showToast = (color, title, text, time) => { + showToast = (color: string, title: string, text: string, time: number) => { getToasts().add({ color: color, title: title, @@ -200,18 +230,24 @@ class WzListEditor extends Component { }; onChangeKey = e => { + this.executeValidation(e.target.value, 'key'); + this.setState({ addingKey: e.target.value, }); }; onChangeValue = e => { + this.executeValidation(e.target.value, 'value'); + this.setState({ addingValue: e.target.value, }); }; onChangeEditingValue = e => { + this.executeValidation(e.target.value, 'edit'); + this.setState({ editingValue: e.target.value, }); @@ -243,11 +279,101 @@ class WzListEditor extends Component { ]; }; + private executeValidation = (value: string, field: FieldTypes) => { + const startsWithQuote = value.startsWith('"'); + const endsWithQuote = value.endsWith('"'); + const valueWithoutQuotes = value.slice(1, -1); + // For the simbols validation + const hasNotSimbols = valueWithoutQuotes.match(/^[a-zA-Z0-9:]+$/); + // For the colon validation outside quotes + const hasColon = value.includes(':'); + // If the value has a colon it starts and ends with quotes + const hasMiddleQuotes = valueWithoutQuotes.includes('"'); + const valueLength = value.length !== 1; + + const quotesCondition = + !hasMiddleQuotes && + ((startsWithQuote && endsWithQuote && valueLength) || + (!startsWithQuote && !endsWithQuote)); + + const colonCondition = !(startsWithQuote && endsWithQuote) && hasColon; + + const simbolsCondition = + !hasNotSimbols && startsWithQuote && endsWithQuote && valueLength; + + const validationArray: ValidationInterface[] = [ + { + checking: 'quotes', + condition: !quotesCondition, + }, + { + checking: 'colon', + condition: colonCondition, + }, + { + checking: 'simbols', + condition: simbolsCondition, + }, + ]; + + validationArray.forEach(({ checking, condition }) => { + this.genericValidation({ + field, + condition, + cheking: checking, + }); + }); + }; + + genericValidation = ({ + field, + condition, + cheking, + }: { + field: FieldTypes; + condition: boolean; + cheking: CheckingsTypes; + }) => { + if (condition) { + const existsError = this.state.isInvalid.some( + error => + error.field === field && + error.message === this.messagesError[cheking], + ); + + // Avoid adding the same error multiple times + if (existsError) { + return; + } + + this.setState(prevState => ({ + isInvalid: [ + ...prevState.isInvalid, + { + field, + message: this.messagesError[cheking], + }, + ], + })); + return; + } + this.setState(prevState => ({ + isInvalid: prevState.isInvalid.filter( + error => + !( + error.field === field && + error.message === this.messagesError[cheking] + ), + ), + })); + }; + /** * Append a key value to this.items and after that if everything works ok re-create the array for the table */ addItem() { const { addingKey, addingValue } = this.state; + const hasinvalidState = this.state.isInvalid.length > 0; if (!addingKey || Object.keys(this.items).includes(addingKey)) { this.showToast( 'danger', @@ -259,6 +385,15 @@ class WzListEditor extends Component { ); return; } + if (hasinvalidState) { + this.showToast( + 'danger', + 'Error', + `Key and value ${this.messages.quotesError.toLowerCase()}`, + 3000, + ); + return; + } this.items[addingKey] = addingValue; const itemsArr = this.contentToArray(this.items); this.setState({ @@ -368,9 +503,42 @@ class WzListEditor extends Component { ); } + inputValidation({ + field, + value, + onChange, + placeholder, + }: { + field: FieldTypes; + value: FieldStateTypes; + onChange: (e: any) => void; + placeholder: string; + }) { + const isInvalid = this.state.isInvalid.some(error => error.field === field); + const errorMessages: string[] = []; + this.state.isInvalid.forEach(error => { + error.field === field && errorMessages.push(error.message); + }); + + return ( + + + + ); + } + renderAdd() { const { addingKey, addingValue } = this.state; + const hasInvalidState = this.state.isInvalid.length > 0; + return ( {this.state.isPopoverOpen && ( @@ -378,23 +546,21 @@ class WzListEditor extends Component { - + {this.inputValidation({ + field: 'key', + value: addingKey, + onChange: this.onChangeKey, + placeholder: 'Key', + })} - + {this.inputValidation({ + field: 'value', + value: addingValue, + onChange: this.onChangeValue, + placeholder: 'Value', + })} @@ -402,8 +568,8 @@ class WzListEditor extends Component { this.addItem()} + disabled={hasInvalidState} > Add @@ -470,14 +636,12 @@ class WzListEditor extends Component { sortable: true, render: (value, item) => { if (this.state.editing === item.key) { - return ( - - ); + return this.inputValidation({ + field: 'edit', + value: this.state.editingValue, + onChange: this.onChangeEditingValue, + placeholder: 'New value', + }); } else { return {value}; } @@ -498,6 +662,7 @@ class WzListEditor extends Component { this.setEditedValue(); }} color='primary' + data-testid='saveEditButton' /> @@ -506,6 +671,7 @@ class WzListEditor extends Component { iconType='cross' onClick={() => this.setState({ editing: false })} color='danger' + data-testid='cancelEditButton' /> @@ -526,6 +692,7 @@ class WzListEditor extends Component { }); }} color='primary' + data-testid='editButton' /> this.deleteItem(item.key)} color='danger' + data-testid='deleteButton' /> );