From db72f431fb8c4149e4091b02f8f2411614004834 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Mon, 29 May 2023 18:40:05 +0200 Subject: [PATCH 01/22] [FEATURE] Improve "list" text area UX #589 Use expression builder instead of code editor Signed-off-by: Jovan Cvetkovic --- public/app.scss | 1 + .../RuleEditor/DetectionVisualEditor.tsx | 58 +++-- .../components/SelectionExpField.scss | 25 ++ .../components/SelectionExpField.tsx | 227 ++++++++++++++++++ .../containers/CreateRule/CreateRule.tsx | 7 +- .../Rules/containers/EditRule/EditRule.tsx | 7 +- 6 files changed, 304 insertions(+), 21 deletions(-) create mode 100644 public/pages/Rules/components/RuleEditor/components/SelectionExpField.scss create mode 100644 public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx diff --git a/public/app.scss b/public/app.scss index 01c242a5d..b6bfd3796 100644 --- a/public/app.scss +++ b/public/app.scss @@ -18,6 +18,7 @@ $euiTextColor: $euiColorDarkestShade !default; @import "./pages/Correlations/Correlations.scss"; @import "./pages/Correlations/components/FindingCard.scss"; @import "./pages/Findings/components/CorrelationsTable/CorrelationsTable.scss"; +@import "./pages/Rules/components/RuleEditor/components/SelectionExpField.scss"; .selected-radio-panel { background-color: tintOrShade($euiColorPrimary, 90%, 70%); diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index 644b845de..6b66b4cf3 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -29,9 +29,11 @@ import { EuiModalFooter, EuiFilePicker, EuiCodeEditor, + EuiCallOut, } from '@elastic/eui'; import _ from 'lodash'; import { validateCondition, validateDetectionFieldName } from '../../../../utils/validation'; +import { SelectionExpField } from './components/SelectionExpField'; export interface DetectionVisualEditorProps { detectionYml: string; @@ -61,7 +63,7 @@ interface SelectionData { selectedRadioId?: string; } -interface Selection { +export interface Selection { name: string; data: SelectionData[]; } @@ -88,7 +90,7 @@ const defaultDetectionObj: DetectionObject = { condition: '', selections: [ { - name: '', + name: 'Selection_1', data: [ { field: '', @@ -275,6 +277,10 @@ export class DetectionVisualEditor extends React.Component< const { errors } = this.state; const selection = selections[selectionIdx]; + if (!selection.name) { + selection.name = `Selection_${selectionIdx + 1}`; + } + delete errors.fields['name']; if (!selection.name) { errors.fields['name'] = 'Selection name is required'; @@ -320,7 +326,6 @@ export class DetectionVisualEditor extends React.Component< detectionObj: { selections }, } = this.state; value = value.trim(); - delete errors.fields['condition']; if (!value) { errors.fields['condition'] = 'Condition is required'; @@ -415,6 +420,10 @@ export class DetectionVisualEditor extends React.Component< ]; }; + private getTextareaHeight = (rowNo: number = 0) => { + return `${rowNo * 25 + 40}px`; + }; + render() { const { detectionObj: { condition, selections }, @@ -425,11 +434,15 @@ export class DetectionVisualEditor extends React.Component< }, } = this.state; + console.log('XXX', detectionYml); return ( {selections.map((selection, selectionIdx) => { return ( -
+
@@ -490,6 +503,7 @@ export class DetectionVisualEditor extends React.Component< return ( { const values = e.target.value.split('\n'); - console.log(values); this.updateDatumInState(selectionIdx, idx, { values, }); }} onBlur={(e) => { const values = e.target.value.split('\n'); - console.log(values); this.updateDatumInState(selectionIdx, idx, { values, }); @@ -674,6 +691,7 @@ export class DetectionVisualEditor extends React.Component< ...selections, { ...defaultDetectionObj.selections[0], + name: `Selection_${selections.length + 1}`, }, ], }, @@ -701,16 +719,11 @@ export class DetectionVisualEditor extends React.Component< } > - this.updateCondition(value)} - onBlur={(e) => { - this.updateCondition(this.state.detectionObj.condition); - }} - data-test-subj={'rule_detection_field'} + onChange={this.updateCondition} + dataTestSubj={'rule_detection_field'} /> @@ -721,8 +734,19 @@ export class DetectionVisualEditor extends React.Component<

Upload a file

- + {selections[fileUploadModalState.selectionIdx].data[fileUploadModalState.dataIdx] + .values[0] && ( + <> + + + + )} {this.state.invalidFile && (

Invalid file.

diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.scss b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.scss new file mode 100644 index 000000000..f35813775 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.scss @@ -0,0 +1,25 @@ +.selection-exp-field-item { + position: relative; + + .euiExpression__value { + padding: 2px; + border: 1px solid; + border-radius: 4px; + } +} + +.selection-exp-field-item-with-remove { + position: relative; + + .euiExpression__value { + padding: 2px 25px 2px 2px; + border: 1px solid; + border-radius: 4px; + } + + .selection-exp-field-item-remove { + position: absolute; + right: 0; + top: 1px; + } +} diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx new file mode 100644 index 000000000..2a65c66fd --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -0,0 +1,227 @@ +import React, { useEffect, useState } from 'react'; +import { + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, + EuiButtonIcon, + EuiExpression, +} from '@elastic/eui'; +import * as _ from 'lodash'; +import { Selection } from '../DetectionVisualEditor'; + +interface SelectionExpFieldProps { + selections: Selection[]; + dataTestSubj: string; + onChange: (value: string) => void; + value: string; +} + +interface UsedSelection { + isOpen: boolean; + name: string; + description: string; +} + +export const SelectionExpField: React.FC = ({ + selections, + dataTestSubj, + onChange, + value, +}) => { + const [usedExpressions, setUsedExpressions] = useState([]); + + useEffect(() => { + let expressions: UsedSelection[] = []; + if (value?.length) { + const values = value.split(' '); + let counter = 0; + values.map((val, idx) => { + if (idx === 0) { + expressions.push({ + description: 'SELECTION', + isOpen: false, + name: val, + }); + } else { + if (idx % 2 !== 0) { + expressions.push({ + description: val, + isOpen: false, + name: '', + }); + counter++; + } else { + const currentIndex = idx - counter; + expressions[currentIndex] = { ...expressions[currentIndex], name: val }; + } + } + }); + } else { + expressions = [ + { + description: 'SELECTION', + isOpen: false, + name: selections[0]?.name || 'Selection_1', + }, + ]; + } + setUsedExpressions(expressions); + }, [value]); + + useEffect(() => { + console.log('XXX', usedExpressions, getValue()); + onChange(getValue()); + }, [usedExpressions]); + + const getValue = () => { + const expressions = usedExpressions.map((exp) => [_.toLower(exp.description), exp.name]); + let newExpressions = _.flattenDeep(expressions); + newExpressions.shift(); + return newExpressions.join(' '); + }; + + const changeExtValue = ( + event: React.ChangeEvent, + exp: UsedSelection, + idx: number + ) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], name: event.target.value }; + setUsedExpressions(usedExp); + }; + + const changeExtDescription = ( + event: React.ChangeEvent, + exp: UsedSelection, + idx: number + ) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], description: event.target.value }; + setUsedExpressions(usedExp); + }; + + const openPopover = (idx: number) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], isOpen: !usedExp[idx].isOpen }; + setUsedExpressions(usedExp); + }; + + const closePopover = (idx: number) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], isOpen: false }; + setUsedExpressions(usedExp); + }; + + const renderOptions = (exp: UsedSelection, idx: number) => ( +
+ + + Selection + changeExtDescription(e, exp, idx)} + options={[ + { value: 'and', text: 'AND' }, + { value: 'or', text: 'OR' }, + { value: 'not', text: 'NOT' }, + ]} + /> + + {selections.length > usedExpressions.length && ( + {renderSelections(exp, idx)} + )} + +
+ ); + + const renderSelections = (exp: UsedSelection, idx: number) => ( +
+ Selections + changeExtValue(e, exp, idx)} + value={exp.name} + options={(() => { + const differences = _.differenceBy(selections, usedExpressions, 'name'); + return [ + { + value: exp.name, + text: exp.name, + }, + ...differences.map((sel) => ({ + value: sel.name, + text: sel.name, + })), + ]; + })()} + /> +
+ ); + + return ( + + {usedExpressions.map((exp, idx) => ( + 0 ? 'selection-exp-field-item-with-remove' : 'selection-exp-field-item'} + > + openPopover(idx)} + /> + } + isOpen={exp.isOpen} + closePopover={() => closePopover(idx)} + panelPaddingSize="s" + anchorPosition="rightDown" + > + {exp.description === 'SELECTION' ? renderSelections(exp, idx) : renderOptions(exp, idx)} + + {idx ? ( + { + const usedExp = _.cloneDeep(usedExpressions); + usedExp.splice(idx, 1); + setUsedExpressions([...usedExp]); + }} + color={'danger'} + iconType="cross" + aria-label={'Remove condition'} + /> + ) : null} + + ))} + {selections.length > usedExpressions.length && ( + + { + const usedExp = _.cloneDeep(usedExpressions); + const differences = _.differenceBy(selections, usedExp, 'name'); + setUsedExpressions([ + ...usedExp, + { + description: 'AND', + isOpen: false, + name: differences[0]?.name, + }, + ]); + }} + iconType="plusInCircle" + aria-label={'Add one more condition'} + /> + + )} + + ); +}; diff --git a/public/pages/Rules/containers/CreateRule/CreateRule.tsx b/public/pages/Rules/containers/CreateRule/CreateRule.tsx index c44fd2504..118b15b5b 100644 --- a/public/pages/Rules/containers/CreateRule/CreateRule.tsx +++ b/public/pages/Rules/containers/CreateRule/CreateRule.tsx @@ -5,7 +5,7 @@ import { BrowserServices } from '../../../../models/interfaces'; import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer'; -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { BREADCRUMBS } from '../../../../utils/constants'; import { CoreServicesContext } from '../../../../components/core_services'; @@ -20,7 +20,10 @@ export interface CreateRuleProps { export const CreateRule: React.FC = ({ history, services, notifications }) => { const context = useContext(CoreServicesContext); - setBreadCrumb(BREADCRUMBS.RULES_CREATE, context?.chrome.setBreadcrumbs); + + useEffect(() => { + setBreadCrumb(BREADCRUMBS.RULES_CREATE, context?.chrome.setBreadcrumbs); + }); return ( = ({ notifications, }) => { const context = useContext(CoreServicesContext); - setBreadCrumb(BREADCRUMBS.RULES_EDIT, context?.chrome.setBreadcrumbs); + + useEffect(() => { + setBreadCrumb(BREADCRUMBS.RULES_EDIT, context?.chrome.setBreadcrumbs); + }); return ( Date: Mon, 29 May 2023 18:54:05 +0200 Subject: [PATCH 02/22] [FEATURE] Improve "list" text area UX #589 Use expression builder instead of code editor Signed-off-by: Jovan Cvetkovic --- .../RuleEditor/DetectionVisualEditor.tsx | 8 ++++---- .../components/RuleEditor/RuleEditorForm.tsx | 1 + .../RuleEditor/components/SelectionExpField.tsx | 15 ++++++++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index 6b66b4cf3..8c22a4a15 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -28,7 +28,6 @@ import { EuiModalBody, EuiModalFooter, EuiFilePicker, - EuiCodeEditor, EuiCallOut, } from '@elastic/eui'; import _ from 'lodash'; @@ -39,6 +38,7 @@ export interface DetectionVisualEditorProps { detectionYml: string; onChange: (value: string) => void; setIsDetectionInvalid: (isInvalid: boolean) => void; + mode?: string; } interface Errors { @@ -190,7 +190,7 @@ export class DetectionVisualEditor extends React.Component< condition, }; - selections.forEach((selection, idx) => { + selections.forEach((selection) => { const selectionMaps: any = {}; selection.data.forEach((datum) => { @@ -434,7 +434,6 @@ export class DetectionVisualEditor extends React.Component< }, } = this.state; - console.log('XXX', detectionYml); return ( {selections.map((selection, selectionIdx) => { @@ -562,7 +561,7 @@ export class DetectionVisualEditor extends React.Component< modifier: e[0].value, }); }} - onBlur={(e) => {}} + onBlur={() => {}} selectedOptions={ datum.modifier ? [{ value: datum.modifier, label: datum.modifier }] @@ -720,6 +719,7 @@ export class DetectionVisualEditor extends React.Component< } > = ({ { if (isInvalid) { diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx index 2a65c66fd..e6c188259 100644 --- a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -16,6 +16,7 @@ interface SelectionExpFieldProps { dataTestSubj: string; onChange: (value: string) => void; value: string; + mode: string; } interface UsedSelection { @@ -29,7 +30,9 @@ export const SelectionExpField: React.FC = ({ dataTestSubj, onChange, value, + mode, }) => { + const [isLoaded, setIsLoaded] = useState(false); const [usedExpressions, setUsedExpressions] = useState([]); useEffect(() => { @@ -67,12 +70,15 @@ export const SelectionExpField: React.FC = ({ }, ]; } + setUsedExpressions(expressions); }, [value]); useEffect(() => { - console.log('XXX', usedExpressions, getValue()); - onChange(getValue()); + if (mode !== 'edit' || isLoaded) { + onChange(getValue()); + } + setIsLoaded(true); }, [usedExpressions]); const getValue = () => { @@ -177,7 +183,10 @@ export const SelectionExpField: React.FC = ({ description={exp.description} value={exp.name} isActive={exp.isOpen} - onClick={() => openPopover(idx)} + onClick={(e) => { + e.preventDefault(); + openPopover(idx); + }} /> } isOpen={exp.isOpen} From 7ffa63652ac27240dcb17a51630efe7abf6f9624 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Tue, 30 May 2023 10:52:19 +0200 Subject: [PATCH 03/22] [FEATURE] Improve "list" text area UX #589 Use expression builder instead of code editor Signed-off-by: Jovan Cvetkovic --- .../RuleEditor/DetectionVisualEditor.tsx | 1 - .../components/SelectionExpField.tsx | 73 +++++++++---------- public/utils/validation.ts | 3 +- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index 8c22a4a15..7eccd3cc2 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -719,7 +719,6 @@ export class DetectionVisualEditor extends React.Component< } > void; value: string; - mode: string; } interface UsedSelection { @@ -30,41 +29,35 @@ export const SelectionExpField: React.FC = ({ dataTestSubj, onChange, value, - mode, }) => { - const [isLoaded, setIsLoaded] = useState(false); + const DEFAULT_DESCRIPTION = 'CONDITION: '; + const OPERATORS = ['and', 'or', 'not']; const [usedExpressions, setUsedExpressions] = useState([]); useEffect(() => { let expressions: UsedSelection[] = []; if (value?.length) { - const values = value.split(' '); + let values = value.split(' '); + if (OPERATORS.indexOf(values[0]) === -1) values = ['', ...values]; + let counter = 0; values.map((val, idx) => { - if (idx === 0) { + if (idx % 2 === 0) { expressions.push({ - description: 'SELECTION', + description: val, isOpen: false, - name: val, + name: '', }); + counter++; } else { - if (idx % 2 !== 0) { - expressions.push({ - description: val, - isOpen: false, - name: '', - }); - counter++; - } else { - const currentIndex = idx - counter; - expressions[currentIndex] = { ...expressions[currentIndex], name: val }; - } + const currentIndex = idx - counter; + expressions[currentIndex] = { ...expressions[currentIndex], name: val }; } }); } else { expressions = [ { - description: 'SELECTION', + description: '', isOpen: false, name: selections[0]?.name || 'Selection_1', }, @@ -74,18 +67,9 @@ export const SelectionExpField: React.FC = ({ setUsedExpressions(expressions); }, [value]); - useEffect(() => { - if (mode !== 'edit' || isLoaded) { - onChange(getValue()); - } - setIsLoaded(true); - }, [usedExpressions]); - - const getValue = () => { - const expressions = usedExpressions.map((exp) => [_.toLower(exp.description), exp.name]); - let newExpressions = _.flattenDeep(expressions); - newExpressions.shift(); - return newExpressions.join(' '); + const getValue = (usedExp: UsedSelection[]) => { + const expressions = usedExp.map((exp) => [_.toLower(exp.description), exp.name]); + return _.flattenDeep(expressions).join(' '); }; const changeExtValue = ( @@ -96,6 +80,7 @@ export const SelectionExpField: React.FC = ({ const usedExp = _.cloneDeep(usedExpressions); usedExp[idx] = { ...usedExp[idx], name: event.target.value }; setUsedExpressions(usedExp); + onChange(getValue(usedExp)); }; const changeExtDescription = ( @@ -106,6 +91,7 @@ export const SelectionExpField: React.FC = ({ const usedExp = _.cloneDeep(usedExpressions); usedExp[idx] = { ...usedExp[idx], description: event.target.value }; setUsedExpressions(usedExp); + onChange(getValue(usedExp)); }; const openPopover = (idx: number) => { @@ -130,6 +116,7 @@ export const SelectionExpField: React.FC = ({ value={exp.description} onChange={(e) => changeExtDescription(e, exp, idx)} options={[ + { value: '', text: '' }, { value: 'and', text: 'AND' }, { value: 'or', text: 'OR' }, { value: 'not', text: 'NOT' }, @@ -169,11 +156,20 @@ export const SelectionExpField: React.FC = ({ return ( + + } + isOpen={false} + panelPaddingSize="s" + anchorPosition="rightDown" + /> + {usedExpressions.map((exp, idx) => ( 0 ? 'selection-exp-field-item-with-remove' : 'selection-exp-field-item'} + className={'selection-exp-field-item-with-remove'} > = ({ description={exp.description} value={exp.name} isActive={exp.isOpen} - onClick={(e) => { + onClick={(e: any) => { e.preventDefault(); openPopover(idx); }} @@ -194,15 +190,16 @@ export const SelectionExpField: React.FC = ({ panelPaddingSize="s" anchorPosition="rightDown" > - {exp.description === 'SELECTION' ? renderSelections(exp, idx) : renderOptions(exp, idx)} + {renderOptions(exp, idx)} - {idx ? ( + {usedExpressions.length > 1 ? ( { const usedExp = _.cloneDeep(usedExpressions); usedExp.splice(idx, 1); setUsedExpressions([...usedExp]); + onChange(getValue(usedExp)); }} color={'danger'} iconType="cross" @@ -217,14 +214,16 @@ export const SelectionExpField: React.FC = ({ onClick={() => { const usedExp = _.cloneDeep(usedExpressions); const differences = _.differenceBy(selections, usedExp, 'name'); - setUsedExpressions([ + const exp = [ ...usedExp, { description: 'AND', isOpen: false, name: differences[0]?.name, }, - ]); + ]; + setUsedExpressions(exp); + onChange(getValue(exp)); }} iconType="plusInCircle" aria-label={'Add one more condition'} diff --git a/public/utils/validation.ts b/public/utils/validation.ts index 07828964d..43e01923d 100644 --- a/public/utils/validation.ts +++ b/public/utils/validation.ts @@ -15,7 +15,7 @@ export const NAME_REGEX = new RegExp(/^[a-zA-Z0-9 _-]{5,50}$/); export const DETECTION_NAME_REGEX = new RegExp(/^[a-zA-Z0-9_.-]{5,50}$/); export const CONDITION_REGEX = new RegExp( - /^([a-zA-Z0-9_]+)?( (and|or|not) ?([a-zA-Z0-9_]+))*(? Date: Tue, 30 May 2023 12:32:40 +0200 Subject: [PATCH 04/22] [FEATURE] Improve "list" text area UX #589 Use expression builder instead of code editor Signed-off-by: Jovan Cvetkovic --- cypress/integration/2_rules.spec.js | 19 +++++++------------ .../components/SelectionExpField.tsx | 7 ++++++- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index 4adae0242..45aad7441 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -10,11 +10,9 @@ const SAMPLE_RULE = { name: `Cypress test rule ${uniqueId}`, logType: 'windows', description: 'This is a rule used to test the rule creation workflow.', - detection: - "condition: selection\nselection:\nProvider_Name|contains:\n- Service Control Manager\nEventID|contains:\n- '7045'\nServiceName|contains:\n- ZzNetSvc", detectionLine: [ - 'condition: selection', - 'selection:', + 'condition: Selection_1', + 'Selection_1:', 'Provider_Name|contains:', '- Service Control Manager', 'EventID|contains:', @@ -48,7 +46,7 @@ const YAML_RULE_LINES = [ `- '${SAMPLE_RULE.references}'`, `author: ${SAMPLE_RULE.author}`, `detection:`, - ...SAMPLE_RULE.detection.replaceAll(' ', '').replaceAll('{backspace}', '').split('\n'), + ...SAMPLE_RULE.detectionLine, ]; const checkRulesFlyout = () => { @@ -180,11 +178,8 @@ describe('Rules', () => { `${SAMPLE_RULE.falsePositive}{enter}` ); - // Enter the author - cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}{enter}`); - cy.get('[data-test-subj="detection-visual-editor-0"]').within(() => { - cy.getFieldByLabel('Name').type('selection'); + cy.getFieldByLabel('Name').type('{selectall}{backspace}').type('Selection_1'); cy.getFieldByLabel('Key').type('Provider_Name'); cy.getInputByPlaceholder('Value').type('Service Control Manager'); @@ -200,9 +195,9 @@ describe('Rules', () => { cy.getInputByPlaceholder('Value').type('ZzNetSvc'); }); }); - cy.get('[data-test-subj="rule_detection_field"] textarea').type('selection', { - force: true, - }); + + // Enter the author + cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}`); // Switch to YAML editor cy.get('[data-test-subj="change-editor-type"] label:nth-child(2)').click({ diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx index dbdd37e67..e07d17c77 100644 --- a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -65,6 +65,7 @@ export const SelectionExpField: React.FC = ({ } setUsedExpressions(expressions); + onChange(getValue(expressions)); }, [value]); const getValue = (usedExp: UsedSelection[]) => { @@ -169,7 +170,11 @@ export const SelectionExpField: React.FC = ({ 1 + ? 'selection-exp-field-item-with-remove' + : 'selection-exp-field-item' + } > Date: Tue, 30 May 2023 16:41:46 +0200 Subject: [PATCH 05/22] Change the order of the sections in the "Create detection rule" page #586 Signed-off-by: Jovan Cvetkovic --- cypress/integration/2_rules.spec.js | 1 - public/app.scss | 1 + .../components/RuleEditor/FieldTextArray.tsx | 18 +- .../RuleEditor/RuleEditorContainer.tsx | 2 +- .../components/RuleEditor/RuleEditorForm.scss | 40 ++ .../components/RuleEditor/RuleEditorForm.tsx | 411 ++++++++++-------- .../RuleEditor/RuleEditorFormModel.ts | 2 +- .../RuleTagsComboBox.tsx | 21 +- .../containers/CreateRule/CreateRule.tsx | 18 +- 9 files changed, 324 insertions(+), 190 deletions(-) create mode 100644 public/pages/Rules/components/RuleEditor/RuleEditorForm.scss diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index 4adae0242..6ef0aefe9 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -172,7 +172,6 @@ describe('Rules', () => { ); // Enter the reference - cy.contains('Add another URL').click(); cy.get('[data-test-subj="rule_references_field_0"]').type(SAMPLE_RULE.references); // Enter the false positive cases diff --git a/public/app.scss b/public/app.scss index 01c242a5d..753d26dca 100644 --- a/public/app.scss +++ b/public/app.scss @@ -18,6 +18,7 @@ $euiTextColor: $euiColorDarkestShade !default; @import "./pages/Correlations/Correlations.scss"; @import "./pages/Correlations/components/FindingCard.scss"; @import "./pages/Findings/components/CorrelationsTable/CorrelationsTable.scss"; +@import "./pages/Rules/components/RuleEditor/RuleEditorForm.scss"; .selected-radio-panel { background-color: tintOrShade($euiColorPrimary, 90%, 70%); diff --git a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx index 65d3aeb48..9c3331217 100644 --- a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx +++ b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx @@ -5,11 +5,13 @@ import { EuiButton, + EuiButtonIcon, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer, + EuiToolTip, } from '@elastic/eui'; import React, { ChangeEvent } from 'react'; @@ -21,6 +23,7 @@ export interface FieldTextArrayProps { onFieldEdit: (value: string, fieldIndex: number) => void; onFieldRemove: (fieldIndex: number) => void; onFieldAdd: () => void; + placeholder?: string; } export const FieldTextArray: React.FC = ({ @@ -31,6 +34,7 @@ export const FieldTextArray: React.FC = ({ onFieldEdit, onFieldRemove, onFieldAdd, + placeholder = '', }) => { return ( <> @@ -42,6 +46,7 @@ export const FieldTextArray: React.FC = ({ ) => { onFieldEdit(e.target.value, index); }} @@ -51,14 +56,21 @@ export const FieldTextArray: React.FC = ({ /> {index > 0 ? ( - - onFieldRemove(index)}>Remove + + + onFieldRemove(index)} + /> + ) : null} ); })} - + void; cancel: () => void; mode: 'create' | 'edit'; - title: string; + title: string | JSX.Element; } const editorTypes = [ @@ -119,7 +120,7 @@ export const RuleEditorForm: React.FC = ({ > {(props) => (
- + = ({ idSelected={selectedEditorType} onChange={(id) => onEditorTypeChange(id)} /> + {selectedEditorType === 'yaml' && ( @@ -145,62 +147,38 @@ export const RuleEditorForm: React.FC = ({ {selectedEditorType === 'visual' && ( <> - - - - Rule name - - } - isInvalid={props.touched.name && !!props.errors?.name} - error={props.errors.name} - helpText="Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores." - > - { - props.handleChange('name')(e); - }} - onBlur={props.handleBlur('name')} - value={props.values.name} - /> - - - - - Log type - - } - isInvalid={props.touched.logType && !!props.errors?.logType} - error={props.errors.logType} - > - ({ value, label }))} - singleSelection={{ asPlainText: true }} - onChange={(e) => { - props.handleChange('logType')(e[0]?.value ? e[0].value : ''); - }} - onBlur={props.handleBlur('logType')} - selectedOptions={ - props.values.logType - ? [{ value: props.values.logType, label: props.values.logType }] - : [] - } - /> - - - + + +

Rule overview

+
+
+ + Rule name + + } + isInvalid={props.touched.name && !!props.errors?.name} + error={props.errors.name} + helpText="Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores." + > + { + props.handleChange('name')(e); + }} + onBlur={props.handleBlur('name')} + value={props.values.name} + /> + + + + @@ -208,51 +186,87 @@ export const RuleEditorForm: React.FC = ({ - optional } - helpText="Description must contain 5-500 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, dots, commas, and underscores." isInvalid={!!props.errors?.description} error={props.errors.description} > - { props.handleChange('description')(e.target.value); }} onBlur={props.handleBlur('description')} value={props.values.description} + placeholder={'Detects ...'} /> - - - Detection - - -

Define the detection criteria for the rule

-
+ + + + Author + + } + helpText="Combine miltiple authors separated with a comma" + isInvalid={props.touched.author && !!props.errors?.author} + error={props.errors.author} + > + { + props.handleChange('author')(e); + }} + onBlur={props.handleBlur('author')} + value={props.values.author} + /> + + + + + + +

Details

+
+
+ - { - if (isInvalid) { - props.errors.detection = 'Invalid detection entries'; - } else { - delete props.errors.detection; + + Log type + + } + isInvalid={props.touched.logType && !!props.errors?.logType} + error={props.errors.logType} + > + ({ value, label }))} + singleSelection={{ asPlainText: true }} + onChange={(e) => { + props.handleChange('logType')(e[0]?.value ? e[0].value : ''); + }} + onBlur={props.handleBlur('logType')} + selectedOptions={ + props.values.logType + ? [{ value: props.values.logType, label: props.values.logType }] + : [] } + /> + - setIsDetectionInvalid(isInvalid); - }} - onChange={(detection: string) => { - props.handleChange('detection')(detection); - }} - /> - - + - Rule level + Rule level (severity) } isInvalid={props.touched.level && !!props.errors?.level} @@ -283,101 +297,6 @@ export const RuleEditorForm: React.FC = ({ - { - const tags = value.map((option) => ({ label: option.label })); - props.setFieldValue('tags', tags); - }} - onCreateOption={(newTag) => { - props.setFieldValue('tags', [...props.values.tags, { label: newTag }]); - }} - onBlur={props.handleBlur('tags')} - /> - - - - References - - optional - - } - addButtonName="Add another URL" - fields={props.values.references} - onFieldAdd={() => { - props.setFieldValue('references', [...props.values.references, '']); - }} - onFieldEdit={(value: string, index: number) => { - props.setFieldValue('references', [ - ...props.values.references.slice(0, index), - value, - ...props.values.references.slice(index + 1), - ]); - }} - onFieldRemove={(index: number) => { - const newRefs = [...props.values.references]; - newRefs.splice(index, 1); - - props.setFieldValue('references', newRefs); - }} - data-test-subj={'rule_references_field'} - /> - - - False positive cases - - optional - - } - addButtonName="Add another case" - fields={props.values.falsePositives} - onFieldAdd={() => { - props.setFieldValue('falsePositives', [...props.values.falsePositives, '']); - }} - onFieldEdit={(value: string, index: number) => { - props.setFieldValue('falsePositives', [ - ...props.values.falsePositives.slice(0, index), - value, - ...props.values.falsePositives.slice(index + 1), - ]); - }} - onFieldRemove={(index: number) => { - const newCases = [...props.values.falsePositives]; - newCases.splice(index, 1); - - props.setFieldValue('falsePositives', newCases); - }} - data-test-subj={'rule_falsePositives_field'} - /> - - - Author - - } - helpText="Author must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, commas, and underscores." - isInvalid={props.touched.author && !!props.errors?.author} - error={props.errors.author} - > - { - props.handleChange('author')(e); - }} - onBlur={props.handleBlur('author')} - value={props.values.author} - /> - - - - @@ -404,6 +323,146 @@ export const RuleEditorForm: React.FC = ({ } /> + + + + + + + +

Detection

+
+
+ +

Define the detection criteria for the rule

+
+ + + + { + if (isInvalid) { + props.errors.detection = 'Invalid detection entries'; + } else { + delete props.errors.detection; + } + + setIsDetectionInvalid(isInvalid); + }} + onChange={(detection: string) => { + props.handleChange('detection')(detection); + }} + /> + + + +
+ + Additional details - optional + + } + > +
+ { + const tags = value.map((option) => ({ label: option.label })); + props.setFieldValue('tags', tags); + }} + onCreateOption={(newTag) => { + props.setFieldValue('tags', [...props.values.tags, { label: newTag }]); + }} + onBlur={props.handleBlur('tags')} + /> + + + + + + Referencess + - optional + + + + + + URL + + + } + addButtonName="Add URL" + fields={props.values.references} + onFieldAdd={() => { + props.setFieldValue('references', [...props.values.references, '']); + }} + onFieldEdit={(value: string, index: number) => { + props.setFieldValue('references', [ + ...props.values.references.slice(0, index), + value, + ...props.values.references.slice(index + 1), + ]); + }} + onFieldRemove={(index: number) => { + const newRefs = [...props.values.references]; + newRefs.splice(index, 1); + + props.setFieldValue('references', newRefs); + }} + data-test-subj={'rule_references_field'} + /> + + + + False positive cases + - optional + + + + + + Description + + + } + addButtonName="Add false positive" + fields={props.values.falsePositives} + onFieldAdd={() => { + props.setFieldValue('falsePositives', [ + ...props.values.falsePositives, + '', + ]); + }} + onFieldEdit={(value: string, index: number) => { + props.setFieldValue('falsePositives', [ + ...props.values.falsePositives.slice(0, index), + value, + ...props.values.falsePositives.slice(index + 1), + ]); + }} + onFieldRemove={(index: number) => { + const newCases = [...props.values.falsePositives]; + newCases.splice(index, 1); + + props.setFieldValue('falsePositives', newCases); + }} + data-test-subj={'rule_falsePositives_field'} + /> +
+
+
)}
diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts index 4e136fe93..a46a199bb 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts +++ b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts @@ -28,7 +28,7 @@ export const ruleEditorStateDefaultValue: RuleEditorFormModel = { description: '', status: '', author: '', - references: [], + references: [''], tags: [], detection: '', level: '', diff --git a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx index d0e1e207c..0ae070e1f 100644 --- a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx +++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx @@ -4,7 +4,7 @@ */ import React, { useState } from 'react'; -import { EuiFormRow, EuiText, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiFormRow, EuiText, EuiComboBox, EuiComboBoxOptionOption, EuiSpacer } from '@elastic/eui'; export interface RuleTagsComboBoxProps { onCreateOption: ( @@ -39,19 +39,26 @@ export const RuleTagsComboBox: React.FC = ({ <> - Tags - - optional - + <> + + Tags + - optional + + + + + + Tag + + } isInvalid={isCurrentlyTypingValueInvalid} error={isCurrentlyTypingValueInvalid ? 'Invalid tag' : ''} - helpText={`Tags must start with '${STARTS_WITH}'`} > isValid(searchValue) && onCreateOption(searchValue, options) diff --git a/public/pages/Rules/containers/CreateRule/CreateRule.tsx b/public/pages/Rules/containers/CreateRule/CreateRule.tsx index c44fd2504..427ed8ab7 100644 --- a/public/pages/Rules/containers/CreateRule/CreateRule.tsx +++ b/public/pages/Rules/containers/CreateRule/CreateRule.tsx @@ -11,6 +11,7 @@ import { BREADCRUMBS } from '../../../../utils/constants'; import { CoreServicesContext } from '../../../../components/core_services'; import { NotificationsStart } from 'opensearch-dashboards/public'; import { setBreadCrumb } from '../../utils/helpers'; +import { EuiTitle, EuiText, EuiLink } from '@elastic/eui'; export interface CreateRuleProps { services: BrowserServices; @@ -24,7 +25,22 @@ export const CreateRule: React.FC = ({ history, services, notif return ( + +

Create detection rule

+
+ + Create a rule for detectors to identify threat scenarios for different log sources.{' '} + + Learn more in the Sigma rules specification + + + + } history={history} notifications={notifications} mode={'create'} From 7b67598bb1716d5f57c22189b95ece3c17c54f7f Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Wed, 31 May 2023 00:23:45 +0200 Subject: [PATCH 06/22] Code review Signed-off-by: Jovan Cvetkovic --- .../RuleEditor/DetectionVisualEditor.tsx | 2 +- .../components/RuleEditor/FieldTextArray.tsx | 15 +++- .../components/RuleEditor/RuleEditorForm.tsx | 69 +++++++++++++++---- .../RuleEditor/RuleEditorFormModel.ts | 8 +-- .../RuleTagsComboBox.tsx | 39 +++++++++-- .../Rules/components/RuleEditor/mappers.ts | 6 +- 6 files changed, 108 insertions(+), 31 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index f3b21f420..25723e64f 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -89,7 +89,7 @@ const defaultDetectionObj: DetectionObject = { condition: '', selections: [ { - name: '', + name: 'Selection_1', data: [ { field: '', diff --git a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx index 9c3331217..a9a791f5a 100644 --- a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx +++ b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx @@ -13,7 +13,8 @@ import { EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import React, { ChangeEvent } from 'react'; +import React, { ChangeEvent, useState } from 'react'; +import * as _ from 'lodash'; export interface FieldTextArrayProps { label: string | React.ReactNode; @@ -23,7 +24,9 @@ export interface FieldTextArrayProps { onFieldEdit: (value: string, fieldIndex: number) => void; onFieldRemove: (fieldIndex: number) => void; onFieldAdd: () => void; + onValidate?: (value: string) => boolean; placeholder?: string; + errorMessage?: string; } export const FieldTextArray: React.FC = ({ @@ -35,10 +38,14 @@ export const FieldTextArray: React.FC = ({ onFieldRemove, onFieldAdd, placeholder = '', + errorMessage = '', + onValidate = () => true, }) => { + const [isValid, setIsValid] = useState(true); + return ( <> - + <> {fields.map((ref: string, index: number) => { return ( @@ -47,8 +54,12 @@ export const FieldTextArray: React.FC = ({ ) => { onFieldEdit(e.target.value, index); + let fieldsToValidate = _.cloneDeep(fields); + fieldsToValidate[index] = e.target.value; + setIsValid(onValidate(fieldsToValidate)); }} data-test-subj={`rule_${name .toLowerCase() diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index c4f2c2780..baa42da54 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -18,6 +18,7 @@ import { EuiButtonGroup, EuiText, EuiTitle, + EuiPanel, } from '@elastic/eui'; import { ContentPanel } from '../../../../components/ContentPanel'; import { FieldTextArray } from './FieldTextArray'; @@ -27,7 +28,6 @@ import { RuleEditorFormModel } from './RuleEditorFormModel'; import { FormSubmissionErrorToastNotification } from './FormSubmitionErrorToastNotification'; import { YamlRuleEditorComponent } from './components/YamlRuleEditorComponent/YamlRuleEditorComponent'; import { mapFormToRule, mapRuleToForm } from './mappers'; -import { RuleTagsComboBox } from './components/YamlRuleEditorComponent/RuleTagsComboBox'; import { DetectionVisualEditor } from './DetectionVisualEditor'; export interface VisualRuleEditorProps { @@ -357,7 +357,7 @@ export const RuleEditorForm: React.FC = ({ -
+ = ({ } >
- { - const tags = value.map((option) => ({ label: option.label })); - props.setFieldValue('tags', tags); + + + + + Tags + - optional + + + + + + Tag + + + } + addButtonName="Add tag" + fields={props.values.tags} + errorMessage={'Invalid tag'} + onValidate={(fields) => { + const STARTS_WITH = 'attack.'; + let isValid = true; + let tag; + for (let i = 0; i < fields.length; i++) { + tag = fields[i]; + if (!(tag.startsWith(STARTS_WITH) && tag.length > STARTS_WITH.length)) { + isValid = false; + break; + } + } + + return isValid; }} - onCreateOption={(newTag) => { - props.setFieldValue('tags', [...props.values.tags, { label: newTag }]); + onFieldAdd={() => { + props.setFieldValue('tags', [...props.values.tags, '']); }} - onBlur={props.handleBlur('tags')} - /> + onFieldEdit={(value: string, index: number) => { + props.setFieldValue('tags', [ + ...props.values.tags.slice(0, index), + value, + ...props.values.tags.slice(index + 1), + ]); + }} + onFieldRemove={(index: number) => { + const newRefs = [...props.values.tags]; + newRefs.splice(index, 1); - + props.setFieldValue('tags', newRefs); + }} + data-test-subj={'rule_tags_field'} + /> = ({ label={ <> - Referencess + References - optional @@ -462,7 +503,7 @@ export const RuleEditorForm: React.FC = ({ />
-
+ )}
diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts index a46a199bb..b12a98e40 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts +++ b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { ruleStatus } from '../../utils/constants'; export interface RuleEditorFormModel { id: string; @@ -14,7 +14,7 @@ export interface RuleEditorFormModel { status: string; author: string; references: string[]; - tags: EuiComboBoxOptionOption[]; + tags: string[]; detection: string; level: string; falsePositives: string[]; @@ -26,10 +26,10 @@ export const ruleEditorStateDefaultValue: RuleEditorFormModel = { logType: '', name: '', description: '', - status: '', + status: ruleStatus[0], author: '', references: [''], - tags: [], + tags: [''], detection: '', level: '', falsePositives: [''], diff --git a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx index f617dcee5..0ae070e1f 100644 --- a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx +++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx @@ -16,30 +16,57 @@ export interface RuleTagsComboBoxProps { selectedOptions: EuiComboBoxOptionOption[]; } +const STARTS_WITH = 'attack.'; + +const isValid = (value: string) => { + if (value === '') return true; + return value.startsWith(STARTS_WITH) && value.length > STARTS_WITH.length; +}; + export const RuleTagsComboBox: React.FC = ({ onCreateOption, onBlur, onChange, selectedOptions, }) => { + const [isCurrentlyTypingValueInvalid, setIsCurrentlyTypingValueInvalid] = useState(false); + + const onSearchChange = (searchValue: string) => { + setIsCurrentlyTypingValueInvalid(!isValid(searchValue)); + }; + return ( <> - Tags - - optional - + <> + + Tags + - optional + + + + + + Tag + + } + isInvalid={isCurrentlyTypingValueInvalid} + error={isCurrentlyTypingValueInvalid ? 'Invalid tag' : ''} > onCreateOption(searchValue, options)} + onCreateOption={(searchValue, options) => + isValid(searchValue) && onCreateOption(searchValue, options) + } onBlur={onBlur} data-test-subj={'rule_tags_dropdown'} selectedOptions={selectedOptions} + isInvalid={isCurrentlyTypingValueInvalid} /> diff --git a/public/pages/Rules/components/RuleEditor/mappers.ts b/public/pages/Rules/components/RuleEditor/mappers.ts index 029853367..260157132 100644 --- a/public/pages/Rules/components/RuleEditor/mappers.ts +++ b/public/pages/Rules/components/RuleEditor/mappers.ts @@ -14,7 +14,7 @@ export const mapFormToRule = (formState: RuleEditorFormModel): Rule => { status: formState.status, author: formState.author, references: formState.references.map((ref) => ({ value: ref })), - tags: formState.tags.map((tag) => ({ value: tag.label })), + tags: formState.tags.map((tag) => ({ value: tag })), log_source: formState.log_source, detection: formState.detection, level: formState.level, @@ -36,9 +36,7 @@ export const mapRuleToForm = (rule: Rule): RuleEditorFormModel => { references: rule.references ? rule.references.map((ref) => ref.value) : ruleEditorStateDefaultValue.references, - tags: rule.tags - ? rule.tags.map((tag) => ({ label: tag.value })) - : ruleEditorStateDefaultValue.tags, + tags: rule.tags ? rule.tags.map((tag) => tag.value) : ruleEditorStateDefaultValue.tags, detection: rule.detection, level: rule.level, falsePositives: rule.false_positives From ef216d8402d83d441f7e12d896faed459724ae06 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Wed, 31 May 2023 00:39:35 +0200 Subject: [PATCH 07/22] Code review Signed-off-by: Jovan Cvetkovic --- cypress/integration/2_rules.spec.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index e045f3cba..81e8de59a 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -165,9 +165,11 @@ describe('Rules', () => { cy.get('[data-test-subj="rule_severity_dropdown"]').type(SAMPLE_RULE.severity); // Enter the tags - SAMPLE_RULE.tags.forEach((tag) => - cy.get('[data-test-subj="rule_tags_dropdown"]').type(`${tag}{enter}`) - ); + SAMPLE_RULE.tags.forEach((tag, index) => { + cy.get(`[data-test-subj="rule_tags_field_${index}"]`).type(`${tag}{enter}`); + index < SAMPLE_RULE.tags.length - 1 && + cy.get('.euiButton').contains('Add tag').click({ force: true }); + }); // Enter the reference cy.get('[data-test-subj="rule_references_field_0"]').type(SAMPLE_RULE.references); From 0d41bd71ef028c6ca2136fc9b6b45a37124e4e66 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Wed, 31 May 2023 20:18:25 +0200 Subject: [PATCH 08/22] bugfix for tags validation Signed-off-by: Jovan Cvetkovic --- .../RuleTagsComboBox.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx index ad0a9570e..d0e1e207c 100644 --- a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx +++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiFormRow, EuiText, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; export interface RuleTagsComboBoxProps { @@ -16,12 +16,25 @@ export interface RuleTagsComboBoxProps { selectedOptions: EuiComboBoxOptionOption[]; } +const STARTS_WITH = 'attack.'; + +const isValid = (value: string) => { + if (value === '') return true; + return value.startsWith(STARTS_WITH) && value.length > STARTS_WITH.length; +}; + export const RuleTagsComboBox: React.FC = ({ onCreateOption, onBlur, onChange, selectedOptions, }) => { + const [isCurrentlyTypingValueInvalid, setIsCurrentlyTypingValueInvalid] = useState(false); + + const onSearchChange = (searchValue: string) => { + setIsCurrentlyTypingValueInvalid(!isValid(searchValue)); + }; + return ( <> = ({ - optional } + isInvalid={isCurrentlyTypingValueInvalid} + error={isCurrentlyTypingValueInvalid ? 'Invalid tag' : ''} + helpText={`Tags must start with '${STARTS_WITH}'`} > onCreateOption(searchValue, options)} + onCreateOption={(searchValue, options) => + isValid(searchValue) && onCreateOption(searchValue, options) + } onBlur={onBlur} data-test-subj={'rule_tags_dropdown'} selectedOptions={selectedOptions} + isInvalid={isCurrentlyTypingValueInvalid} /> From 89d09a0fc1496ba5e5ce89af4fe77bb7b2727cdd Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Thu, 1 Jun 2023 13:35:57 +0200 Subject: [PATCH 09/22] [FEATURE] Change the order of the sections in the "Create detection rule" page #586 [FEATURE] Improve the Create detection rules - selection panel fields error notifications #601 [FEATURE] Improve the Create detection rules - selection panel condition field is not marked as invalid after submission #613 Signed-off-by: Jovan Cvetkovic --- .../RuleEditor/DetectionVisualEditor.tsx | 112 +++++++++++------- .../components/RuleEditor/FieldTextArray.tsx | 57 +++++---- .../components/RuleEditor/RuleEditorForm.tsx | 99 ++++++---------- .../RuleEditor/RuleEditorFormModel.ts | 6 +- 4 files changed, 140 insertions(+), 134 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index 25723e64f..d66173f82 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -38,6 +38,7 @@ export interface DetectionVisualEditorProps { detectionYml: string; onChange: (value: string) => void; setIsDetectionInvalid: (isInvalid: boolean) => void; + isInvalid?: boolean; } interface Errors { @@ -128,10 +129,12 @@ export class DetectionVisualEditor extends React.Component< this.props.onChange(this.createDetectionYml()); } - if (Object.keys(this.state.errors.fields).length || !this.validateValuesExist()) { - this.props.setIsDetectionInvalid(true); - } else { - this.props.setIsDetectionInvalid(false); + const isValid = !!Object.keys(this.state.errors.fields).length || !this.validateValuesExist(); + this.props.setIsDetectionInvalid(isValid); + + if (this.props.isInvalid != prevProps.isInvalid) { + this.validateCondition(this.state.detectionObj.condition); + this.validateDatum(this.state.detectionObj.selections); } } @@ -189,7 +192,7 @@ export class DetectionVisualEditor extends React.Component< condition, }; - selections.forEach((selection, idx) => { + selections.forEach((selection) => { const selectionMaps: any = {}; selection.data.forEach((datum) => { @@ -203,36 +206,14 @@ export class DetectionVisualEditor extends React.Component< return dump(compiledDetection); }; - private updateDatumInState = ( - selectionIdx: number, - dataIdx: number, - newDatum: Partial - ) => { + private validateDatum = (selections: Selection[]) => { const { errors } = this.state; - const { condition, selections } = this.state.detectionObj; - const selection = selections[selectionIdx]; - const datum = selection.data[dataIdx]; - const newSelections = [ - ...selections.slice(0, selectionIdx), - { - ...selection, - data: [ - ...selection.data.slice(0, dataIdx), - { - ...datum, - ...newDatum, - }, - ...selection.data.slice(dataIdx + 1), - ], - }, - ...selections.slice(selectionIdx + 1), - ]; - newSelections.map((selection, selIdx) => { + selections.map((selection, selIdx) => { const fieldNames = new Set(); selection.data.map((data, idx) => { - if ('field' in newDatum) { + if ('field' in data) { const fieldName = `field_${selIdx}_${idx}`; delete errors.fields[fieldName]; @@ -250,7 +231,7 @@ export class DetectionVisualEditor extends React.Component< errors.touched[fieldName] = true; } - if ('values' in newDatum) { + if ('values' in data) { const valueId = `value_${selIdx}_${idx}`; delete errors.fields[valueId]; if (data.values.length === 1 && !data.values[0]) { @@ -263,23 +244,52 @@ export class DetectionVisualEditor extends React.Component< }); this.setState({ - detectionObj: { - condition, - selections: newSelections, - }, errors, }); }; + private updateDatumInState = ( + selectionIdx: number, + dataIdx: number, + newDatum: Partial + ) => { + const { condition, selections } = this.state.detectionObj; + const selection = selections[selectionIdx]; + const datum = selection.data[dataIdx]; + const newSelections = [ + ...selections.slice(0, selectionIdx), + { + ...selection, + data: [ + ...selection.data.slice(0, dataIdx), + { + ...datum, + ...newDatum, + }, + ...selection.data.slice(dataIdx + 1), + ], + }, + ...selections.slice(selectionIdx + 1), + ]; + + this.setState( + { + detectionObj: { + condition, + selections: newSelections, + }, + }, + () => { + this.validateDatum(newSelections); + } + ); + }; + private updateSelection = (selectionIdx: number, newSelection: Partial) => { const { condition, selections } = this.state.detectionObj; const { errors } = this.state; const selection = selections[selectionIdx]; - if (!selection.name) { - selection.name = `Selection_${selectionIdx + 1}`; - } - delete errors.fields['name']; if (!selection.name) { errors.fields['name'] = 'Selection name is required'; @@ -319,7 +329,7 @@ export class DetectionVisualEditor extends React.Component< ); }; - private updateCondition = (value: string) => { + private validateCondition = (value: string) => { const { errors, detectionObj: { selections }, @@ -348,13 +358,25 @@ export class DetectionVisualEditor extends React.Component< } errors.touched['condition'] = true; - const detectionObj = { ...this.state.detectionObj, condition: value } as DetectionObject; this.setState({ - detectionObj, errors, }); }; + private updateCondition = (value: string) => { + value = value.trim(); + + const detectionObj = { ...this.state.detectionObj, condition: value } as DetectionObject; + this.setState( + { + detectionObj, + }, + () => { + this.validateCondition(value); + } + ); + }; + private csvStringToArray = ( csvString: string, delimiter: string = ',', @@ -451,7 +473,7 @@ export class DetectionVisualEditor extends React.Component< data-test-subj={'selection_name'} onChange={(e) => this.updateSelection(selectionIdx, { name: e.target.value })} onBlur={(e) => this.updateSelection(selectionIdx, { name: e.target.value })} - value={selection.name || `Selection_${selectionIdx + 1}`} + value={selection.name} /> @@ -553,7 +575,7 @@ export class DetectionVisualEditor extends React.Component< modifier: e[0].value, }); }} - onBlur={(e) => {}} + onBlur={() => {}} selectedOptions={ datum.modifier ? [{ value: datum.modifier, label: datum.modifier }] @@ -739,7 +761,7 @@ export class DetectionVisualEditor extends React.Component< height="50px" value={this.state.detectionObj.condition} onChange={(value) => this.updateCondition(value)} - onBlur={(e) => { + onBlur={() => { this.updateCondition(this.state.detectionObj.condition); }} data-test-subj={'rule_detection_field'} diff --git a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx index a9a791f5a..fe106ab0a 100644 --- a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx +++ b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx @@ -13,20 +13,17 @@ import { EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import React, { ChangeEvent, useState } from 'react'; -import * as _ from 'lodash'; +import React, { ChangeEvent, useEffect, useState } from 'react'; export interface FieldTextArrayProps { label: string | React.ReactNode; name: string; fields: string[]; addButtonName: string; - onFieldEdit: (value: string, fieldIndex: number) => void; - onFieldRemove: (fieldIndex: number) => void; - onFieldAdd: () => void; - onValidate?: (value: string) => boolean; + onChange: (values: string[]) => void; + isInvalid?: boolean; placeholder?: string; - errorMessage?: string; + error?: string | string[]; } export const FieldTextArray: React.FC = ({ @@ -34,46 +31,60 @@ export const FieldTextArray: React.FC = ({ label, name, fields, - onFieldEdit, - onFieldRemove, - onFieldAdd, + onChange, placeholder = '', - errorMessage = '', - onValidate = () => true, + error = '', + isInvalid, }) => { - const [isValid, setIsValid] = useState(true); + const [values, setValues] = useState([]); + + useEffect(() => { + let newValues = fields.length ? [...fields] : ['']; + setValues(newValues); + }, []); + + const updateValues = (values: string[]) => { + setValues(values); + + let eventValue = values.filter((val: string) => val); + onChange(eventValue); + }; return ( <> - + <> - {fields.map((ref: string, index: number) => { + {values.map((ref: string, index: number) => { return ( ) => { - onFieldEdit(e.target.value, index); - let fieldsToValidate = _.cloneDeep(fields); - fieldsToValidate[index] = e.target.value; - setIsValid(onValidate(fieldsToValidate)); + let newValues = [...values]; + newValues[index] = e.target.value; + updateValues(newValues); }} data-test-subj={`rule_${name .toLowerCase() .replaceAll(' ', '_')}_field_${index}`} /> - {index > 0 ? ( + {values.length > 1 ? ( onFieldRemove(index)} + onClick={() => { + let newValues = [...values]; + newValues.splice(index, 1); + updateValues(newValues); + }} /> @@ -86,7 +97,7 @@ export const FieldTextArray: React.FC = ({ type="button" className="secondary" onClick={() => { - onFieldAdd(); + setValues([...values, '']); }} > {addButtonName} diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index baa42da54..dc62d6a73 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -50,6 +50,8 @@ const editorTypes = [ }, ]; +export const TAGS_PREFIX = 'attack.'; + export const RuleEditorForm: React.FC = ({ initialValue, notifications, @@ -65,6 +67,20 @@ export const RuleEditorForm: React.FC = ({ setSelectedEditorType(optionId); }; + const validateTags = (fields: string[]) => { + let isValid = true; + let tag; + for (let i = 0; i < fields.length; i++) { + tag = fields[i]; + if (tag.length && !(tag.startsWith(TAGS_PREFIX) && tag.length > TAGS_PREFIX.length)) { + isValid = false; + break; + } + } + + return isValid; + }; + return ( = ({ errors.status = 'Rule status is required'; } + if (!validateTags(values.tags)) { + errors.tags = `Tags must start with '${TAGS_PREFIX}'`; + } + return errors; }} onSubmit={(values, { setSubmitting }) => { @@ -340,6 +360,7 @@ export const RuleEditorForm: React.FC = ({ { if (isInvalid) { @@ -389,36 +410,11 @@ export const RuleEditorForm: React.FC = ({ } addButtonName="Add tag" fields={props.values.tags} - errorMessage={'Invalid tag'} - onValidate={(fields) => { - const STARTS_WITH = 'attack.'; - let isValid = true; - let tag; - for (let i = 0; i < fields.length; i++) { - tag = fields[i]; - if (!(tag.startsWith(STARTS_WITH) && tag.length > STARTS_WITH.length)) { - isValid = false; - break; - } - } - - return isValid; - }} - onFieldAdd={() => { - props.setFieldValue('tags', [...props.values.tags, '']); - }} - onFieldEdit={(value: string, index: number) => { - props.setFieldValue('tags', [ - ...props.values.tags.slice(0, index), - value, - ...props.values.tags.slice(index + 1), - ]); - }} - onFieldRemove={(index: number) => { - const newRefs = [...props.values.tags]; - newRefs.splice(index, 1); - - props.setFieldValue('tags', newRefs); + error={props.errors.tags} + isInvalid={props.touched.tags && !!props.errors.tags} + onChange={(tags) => { + props.touched.tags = true; + props.setFieldValue('tags', tags); }} data-test-subj={'rule_tags_field'} /> @@ -442,21 +438,11 @@ export const RuleEditorForm: React.FC = ({ } addButtonName="Add URL" fields={props.values.references} - onFieldAdd={() => { - props.setFieldValue('references', [...props.values.references, '']); - }} - onFieldEdit={(value: string, index: number) => { - props.setFieldValue('references', [ - ...props.values.references.slice(0, index), - value, - ...props.values.references.slice(index + 1), - ]); - }} - onFieldRemove={(index: number) => { - const newRefs = [...props.values.references]; - newRefs.splice(index, 1); - - props.setFieldValue('references', newRefs); + error={props.errors.references} + isInvalid={props.touched.references && !!props.errors.references} + onChange={(references) => { + props.touched.references = true; + props.setFieldValue('references', references); }} data-test-subj={'rule_references_field'} /> @@ -480,24 +466,11 @@ export const RuleEditorForm: React.FC = ({ } addButtonName="Add false positive" fields={props.values.falsePositives} - onFieldAdd={() => { - props.setFieldValue('falsePositives', [ - ...props.values.falsePositives, - '', - ]); - }} - onFieldEdit={(value: string, index: number) => { - props.setFieldValue('falsePositives', [ - ...props.values.falsePositives.slice(0, index), - value, - ...props.values.falsePositives.slice(index + 1), - ]); - }} - onFieldRemove={(index: number) => { - const newCases = [...props.values.falsePositives]; - newCases.splice(index, 1); - - props.setFieldValue('falsePositives', newCases); + error={props.errors.falsePositives} + isInvalid={props.touched.falsePositives && !!props.errors.falsePositives} + onChange={(falsePositives) => { + props.touched.falsePositives = true; + props.setFieldValue('falsePositives', falsePositives); }} data-test-subj={'rule_falsePositives_field'} /> diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts index b12a98e40..5c7d89b68 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts +++ b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts @@ -28,9 +28,9 @@ export const ruleEditorStateDefaultValue: RuleEditorFormModel = { description: '', status: ruleStatus[0], author: '', - references: [''], - tags: [''], + references: [], + tags: [], detection: '', level: '', - falsePositives: [''], + falsePositives: [], }; From bf517210cbce2620b0d3cbd628af70b6381df10a Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Thu, 1 Jun 2023 14:01:38 +0200 Subject: [PATCH 10/22] [FEATURE] Change the order of the sections in the "Create detection rule" page #586 [FEATURE] Improve the Create detection rules - selection panel fields error notifications #601 [FEATURE] Improve the Create detection rules - selection panel condition field is not marked as invalid after submission #613 Signed-off-by: Jovan Cvetkovic --- public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index dc62d6a73..3a66597c5 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -183,7 +183,7 @@ export const RuleEditorForm: React.FC = ({ } isInvalid={props.touched.name && !!props.errors?.name} error={props.errors.name} - helpText="Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores." + helpText="Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores" > Date: Thu, 1 Jun 2023 14:29:32 +0200 Subject: [PATCH 11/22] [FEATURE] Replace code editor with expression editor #602 Signed-off-by: Jovan Cvetkovic --- .../components/SelectionExpField.tsx | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx index e07d17c77..be3291c80 100644 --- a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -30,7 +30,7 @@ export const SelectionExpField: React.FC = ({ onChange, value, }) => { - const DEFAULT_DESCRIPTION = 'CONDITION: '; + const DEFAULT_DESCRIPTION = 'Select'; const OPERATORS = ['and', 'or', 'not']; const [usedExpressions, setUsedExpressions] = useState([]); @@ -55,13 +55,7 @@ export const SelectionExpField: React.FC = ({ } }); } else { - expressions = [ - { - description: '', - isOpen: false, - name: selections[0]?.name || 'Selection_1', - }, - ]; + expressions = []; } setUsedExpressions(expressions); @@ -157,15 +151,24 @@ export const SelectionExpField: React.FC = ({ return ( - - } - isOpen={false} - panelPaddingSize="s" - anchorPosition="rightDown" - /> - + {!usedExpressions.length && ( + + + } + isOpen={false} + panelPaddingSize="s" + anchorPosition="rightDown" + /> + + )} {usedExpressions.map((exp, idx) => ( = ({ onClick={() => { const usedExp = _.cloneDeep(usedExpressions); usedExp.splice(idx, 1); + usedExp[0].description = ''; setUsedExpressions([...usedExp]); onChange(getValue(usedExp)); }} @@ -222,7 +226,7 @@ export const SelectionExpField: React.FC = ({ const exp = [ ...usedExp, { - description: 'AND', + description: usedExpressions.length ? 'AND' : '', isOpen: false, name: differences[0]?.name, }, @@ -230,7 +234,8 @@ export const SelectionExpField: React.FC = ({ setUsedExpressions(exp); onChange(getValue(exp)); }} - iconType="plusInCircle" + color={'primary'} + iconType="plusInCircleFilled" aria-label={'Add one more condition'} /> From a3cdcf1b26280b9c06c8557ce8f2f82bdcfe68a8 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Thu, 1 Jun 2023 14:39:17 +0200 Subject: [PATCH 12/22] [FEATURE] Replace code editor with expression editor #602 Signed-off-by: Jovan Cvetkovic --- cypress/integration/2_rules.spec.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index 45aad7441..45d1f251b 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -196,6 +196,8 @@ describe('Rules', () => { }); }); + cy.get('[aria-label="Add one more condition"]').click({ force: true }); + // Enter the author cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}`); From 36aab7e7e83bf34a0895cafbfc6207f330753452 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Fri, 2 Jun 2023 08:47:22 +0200 Subject: [PATCH 13/22] Improve text area ux and add expression UI #603 Signed-off-by: Jovan Cvetkovic --- .../RuleEditor/DetectionVisualEditor.tsx | 608 +++++++++--------- .../components/RuleEditor/RuleEditorForm.tsx | 1 + .../components/SelectionExpField.tsx | 3 +- 3 files changed, 317 insertions(+), 295 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index 5547ee691..b17982763 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -21,14 +21,12 @@ import { EuiRadioGroup, EuiTextArea, EuiButton, - EuiHorizontalRule, EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, EuiModalFooter, EuiFilePicker, - EuiCodeEditor, EuiButtonEmpty, EuiCallOut, } from '@elastic/eui'; @@ -41,6 +39,7 @@ export interface DetectionVisualEditorProps { onChange: (value: string) => void; setIsDetectionInvalid: (isInvalid: boolean) => void; mode?: string; + isInvalid?: boolean; } interface Errors { @@ -131,10 +130,12 @@ export class DetectionVisualEditor extends React.Component< this.props.onChange(this.createDetectionYml()); } - if (Object.keys(this.state.errors.fields).length || !this.validateValuesExist()) { - this.props.setIsDetectionInvalid(true); - } else { - this.props.setIsDetectionInvalid(false); + const isValid = !!Object.keys(this.state.errors.fields).length || !this.validateValuesExist(); + this.props.setIsDetectionInvalid(isValid); + + if (this.props.isInvalid != prevProps.isInvalid) { + this.validateCondition(this.state.detectionObj.condition); + this.validateDatum(this.state.detectionObj.selections); } } @@ -206,83 +207,83 @@ export class DetectionVisualEditor extends React.Component< return dump(compiledDetection); }; - private updateDatumInState = ( - selectionIdx: number, - dataIdx: number, - newDatum: Partial - ) => { + private validateDatum = (selections: Selection[]) => { const { errors } = this.state; - const { condition, selections } = this.state.detectionObj; - const selection = selections[selectionIdx]; - const datum = selection.data[dataIdx]; - const newSelections = [ - ...selections.slice(0, selectionIdx), - { - ...selection, - data: [ - ...selection.data.slice(0, dataIdx), - { - ...datum, - ...newDatum, - }, - ...selection.data.slice(dataIdx + 1), - ], - }, - ...selections.slice(selectionIdx + 1), - ]; - - newSelections.map((selection, selIdx) => { + selections.map((selection, selIdx) => { const fieldNames = new Set(); - selection.data.map((data, idx) => { - if ('field' in newDatum) { + if ('field' in data) { const fieldName = `field_${selIdx}_${idx}`; delete errors.fields[fieldName]; - if (!data.field) { errors.fields[fieldName] = 'Key name is required'; } else if (fieldNames.has(data.field)) { errors.fields[fieldName] = 'Key name already used'; } else { fieldNames.add(data.field); - if (!validateDetectionFieldName(data.field)) { errors.fields[fieldName] = 'Invalid key name.'; } } errors.touched[fieldName] = true; } - - if ('values' in newDatum) { + if ('values' in data) { const valueId = `value_${selIdx}_${idx}`; delete errors.fields[valueId]; if (data.values.length === 1 && !data.values[0]) { errors.fields[valueId] = 'Value is required'; } - errors.touched[valueId] = true; } }); }); - this.setState({ - detectionObj: { - condition, - selections: newSelections, - }, errors, }); }; + private updateDatumInState = ( + selectionIdx: number, + dataIdx: number, + newDatum: Partial + ) => { + const { condition, selections } = this.state.detectionObj; + const selection = selections[selectionIdx]; + const datum = selection.data[dataIdx]; + const newSelections = [ + ...selections.slice(0, selectionIdx), + { + ...selection, + data: [ + ...selection.data.slice(0, dataIdx), + { + ...datum, + ...newDatum, + }, + ...selection.data.slice(dataIdx + 1), + ], + }, + ...selections.slice(selectionIdx + 1), + ]; + + this.setState( + { + detectionObj: { + condition, + selections: newSelections, + }, + }, + () => { + this.validateDatum(newSelections); + } + ); + }; + private updateSelection = (selectionIdx: number, newSelection: Partial) => { const { condition, selections } = this.state.detectionObj; const { errors } = this.state; const selection = selections[selectionIdx]; - if (!selection.name) { - selection.name = `Selection_${selectionIdx + 1}`; - } - delete errors.fields['name']; if (!selection.name) { errors.fields['name'] = 'Selection name is required'; @@ -322,7 +323,7 @@ export class DetectionVisualEditor extends React.Component< ); }; - private updateCondition = (value: string) => { + private validateCondition = (value: string) => { const { errors, detectionObj: { selections }, @@ -348,15 +349,27 @@ export class DetectionVisualEditor extends React.Component< }); } } - errors.touched['condition'] = true; - const detectionObj = { ...this.state.detectionObj, condition: value } as DetectionObject; + errors.touched['condition'] = true; this.setState({ - detectionObj, errors, }); }; + private updateCondition = (value: string) => { + value = value.trim(); + + const detectionObj = { ...this.state.detectionObj, condition: value } as DetectionObject; + this.setState( + { + detectionObj, + }, + () => { + this.validateCondition(value); + } + ); + }; + private csvStringToArray = ( csvString: string, delimiter: string = ',', @@ -437,279 +450,286 @@ export class DetectionVisualEditor extends React.Component< } = this.state; return ( - +
{selections.map((selection, selectionIdx) => { return ( -
- - - - + + + + this.updateSelection(selectionIdx, { name: e.target.value })} - onBlur={(e) => this.updateSelection(selectionIdx, { name: e.target.value })} - value={selection.name || `Selection_${selectionIdx + 1}`} - /> - - -

Define the search identifier in your data the rule will be applied to.

-
-
- - {selections.length > 1 && ( - - { - const newSelections = [...selections]; - newSelections.splice(selectionIdx, 1); - this.setState( - { - detectionObj: { - condition, - selections: newSelections, - }, - }, - () => this.updateCondition(condition) - ); - }} - /> - - )} - -
- - - - {selection.data.map((datum, idx) => { - const radioGroupOptions = this.createRadioGroupOptions(selectionIdx, idx); - const fieldName = `field_${selectionIdx}_${idx}`; - const valueId = `value_${selectionIdx}_${idx}`; - return ( -
- Map {idx + 1}} - extraAction={ - selection.data.length > 1 ? ( - - { - const newData = [...selection.data]; - newData.splice(idx, 1); - this.updateSelection(selectionIdx, { data: newData }); - }} - /> - - ) : null - } - style={{ maxWidth: '500px' }} + error={errors.fields.name} > - - - - - Key} - > - - this.updateDatumInState(selectionIdx, idx, { - field: e.target.value, - }) - } - onBlur={(e) => - this.updateDatumInState(selectionIdx, idx, { - field: e.target.value, - }) - } - value={datum.field} - /> - - - - Modifier}> - { - this.updateDatumInState(selectionIdx, idx, { - modifier: e[0].value, - }); - }} - onBlur={(e) => {}} - selectedOptions={ - datum.modifier - ? [{ value: datum.modifier, label: datum.modifier }] - : [detectionModifierOptions[0]] - } - /> - - - - - - - { - this.updateDatumInState(selectionIdx, idx, { - selectedRadioId: id as SelectionMapValueRadioId, - }); - }} + + this.updateSelection(selectionIdx, { name: e.target.value }) + } + onBlur={(e) => this.updateSelection(selectionIdx, { name: e.target.value })} + value={selection.name} /> - - - - {datum.selectedRadioId?.includes('list') ? ( - <> - - - + +

Define the search identifier in your data the rule will be applied to.

+
+
+ + {selections.length > 1 && ( + + { + const newSelections = [...selections]; + newSelections.splice(selectionIdx, 1); + this.setState( + { + detectionObj: { + condition, + selections: newSelections, + }, + }, + () => this.updateCondition(condition) + ); + }} + /> + + )} + +
+ + + + {selection.data.map((datum, idx) => { + const radioGroupOptions = this.createRadioGroupOptions(selectionIdx, idx); + const fieldName = `field_${selectionIdx}_${idx}`; + const valueId = `value_${selectionIdx}_${idx}`; + return ( +
+ Map {idx + 1}} + extraAction={ + selection.data.length > 1 ? ( + + { - this.setState({ - fileUploadModalState: { - selectionIdx, - dataIdx: idx, - }, + const newData = [...selection.data]; + newData.splice(idx, 1); + this.updateSelection(selectionIdx, { data: newData }); + }} + /> + + ) : null + } + style={{ maxWidth: '500px' }} + > + + + + + Key} + > + + this.updateDatumInState(selectionIdx, idx, { + field: e.target.value, + }) + } + onBlur={(e) => + this.updateDatumInState(selectionIdx, idx, { + field: e.target.value, + }) + } + value={datum.field} + /> + + + + Modifier}> + { + this.updateDatumInState(selectionIdx, idx, { + modifier: e[0].value, }); }} + onBlur={() => {}} + selectedOptions={ + datum.modifier + ? [{ value: datum.modifier, label: datum.modifier }] + : [detectionModifierOptions[0]] + } + /> + + + + + + + { + this.updateDatumInState(selectionIdx, idx, { + selectedRadioId: id as SelectionMapValueRadioId, + }); + }} + /> + + + + {datum.selectedRadioId?.includes('list') ? ( + <> + + + { + this.setState({ + fileUploadModalState: { + selectionIdx, + dataIdx: idx, + }, + }); + }} + > + Upload file + + + + + - Upload file - - - - - { + this.updateDatumInState(selectionIdx, idx, { + values: [], + }); + }} + > + Clear list + + + + + - { + { + const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { - values: [], + values, }); }} - > - Clear list - - - - + onBlur={(e) => { + const values = e.target.value.split('\n'); + this.updateDatumInState(selectionIdx, idx, { + values, + }); + }} + value={datum.values.join('\n')} + compressed={true} + isInvalid={errors.touched[valueId] && !!errors.fields[valueId]} + /> + + + ) : ( - { - const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { - values, + values: [e.target.value, ...datum.values.slice(1)], }); }} onBlur={(e) => { - const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { - values, + values: [e.target.value, ...datum.values.slice(1)], }); }} - value={datum.values.join('\n')} - compressed={true} - isInvalid={errors.touched[valueId] && !!errors.fields[valueId]} + value={datum.values[0]} /> - - ) : ( - - { - this.updateDatumInState(selectionIdx, idx, { - values: [e.target.value, ...datum.values.slice(1)], - }); - }} - onBlur={(e) => { - this.updateDatumInState(selectionIdx, idx, { - values: [e.target.value, ...datum.values.slice(1)], - }); - }} - value={datum.values[0]} - /> - - )} - - - -
- ); - })} - - - - { - const newData = [ - ...selection.data, - { ...defaultDetectionObj.selections[0].data[0] }, - ]; - this.updateSelection(selectionIdx, { data: newData }); - }} - > - Add map - - - - - + )} + + +
+
+ ); + })} + + + + { + const newData = [ + ...selection.data, + { ...defaultDetectionObj.selections[0].data[0] }, + ]; + this.updateSelection(selectionIdx, { data: newData }); + }} + > + Add map + +
+ + {selections.length > 1 && selections.length !== selectionIdx ? ( + + ) : null}
); })} + + )} - +
); } } diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index 968852860..b3cfa4952 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -232,6 +232,7 @@ export const RuleEditorForm: React.FC = ({ { diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx index be3291c80..23b66f0fa 100644 --- a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -59,7 +59,7 @@ export const SelectionExpField: React.FC = ({ } setUsedExpressions(expressions); - onChange(getValue(expressions)); + expressions.length && onChange(getValue(expressions)); }, [value]); const getValue = (usedExp: UsedSelection[]) => { @@ -166,6 +166,7 @@ export const SelectionExpField: React.FC = ({ isOpen={false} panelPaddingSize="s" anchorPosition="rightDown" + closePopover={() => {}} /> )} From 4a36997a324c1369b053f54c0dccfee66aaa5f34 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Mon, 5 Jun 2023 20:50:57 +0200 Subject: [PATCH 14/22] Cypress cases for detectors and rules, validate forms and fields Signed-off-by: Jovan Cvetkovic --- cypress/integration/1_detectors.spec.js | 401 +++++++----- cypress/integration/2_rules.spec.js | 609 +++++++++++++----- cypress/support/helpers.js | 18 +- .../RuleEditor/DetectionVisualEditor.tsx | 7 +- .../components/RuleEditor/RuleEditorForm.tsx | 61 +- .../components/SelectionExpField.tsx | 1 + public/utils/validation.ts | 2 +- 7 files changed, 707 insertions(+), 392 deletions(-) diff --git a/cypress/integration/1_detectors.spec.js b/cypress/integration/1_detectors.spec.js index 59c2214e6..584d9297e 100644 --- a/cypress/integration/1_detectors.spec.js +++ b/cypress/integration/1_detectors.spec.js @@ -108,14 +108,16 @@ const validatePendingFieldMappingsPanel = (mappings) => { }); }; -const createDetector = (detectorName, dataSource, expectFailure) => { - getCreateDetectorButton().click({ force: true }); - - // TEST DETAILS PAGE +const fillDetailsForm = (detectorName, dataSource) => { getNameField().type(detectorName); getDataSourceField().selectComboboxItem(dataSource); - selectDnsLogType(); +}; + +const createDetector = (detectorName, dataSource, expectFailure) => { + getCreateDetectorButton().click({ force: true }); + + fillDetailsForm(detectorName, dataSource); cy.getElementByText('.euiAccordion .euiTitle', 'Detection rules (14 selected)') .click({ force: true, timeout: 5000 }) @@ -226,6 +228,11 @@ const createDetector = (detectorName, dataSource, expectFailure) => { } }; +const openCreateForm = () => getCreateDetectorButton().click({ force: true }); + +const getDescriptionField = () => cy.getTextareaByLabel('Description - optional'); +const getTriggerNameField = () => cy.getFieldByLabel('Trigger name'); + describe('Detectors', () => { before(() => { cy.cleanUpTests(); @@ -252,203 +259,289 @@ describe('Detectors', () => { cy.createRule(dns_type_rule_data); }); - beforeEach(() => { - cy.intercept('/detectors/_search').as('detectorsSearch'); + describe('...should validate form fields', () => { + beforeEach(() => { + cy.intercept('/detectors/_search').as('detectorsSearch'); - // Visit Detectors page before any test - cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); - cy.wait('@detectorsSearch').should('have.property', 'state', 'Complete'); - }); + // Visit Detectors page before any test + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); + cy.wait('@detectorsSearch').should('have.property', 'state', 'Complete'); + + openCreateForm(); + }); + + it('...should validate name field', () => { + getNameField().should('be.empty'); + getNameField().focus().blur(); + getNameField().parentsUntil('.euiFormRow__fieldWrapper').siblings().contains('Enter a name.'); + + getNameField().type('text').focus().blur(); + + getNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains( + 'Name should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores. Use between 5 and 50 characters.' + ); + + getNameField().type('{selectall}').type('{backspace}').type('tex&').focus().blur(); + + getNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains( + 'Name should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores. Use between 5 and 50 characters.' + ); + + getNameField() + .type('{selectall}') + .type('{backspace}') + .type('Detector name') + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); - it('...should validate form', () => { - getCreateDetectorButton().click({ force: true }); + it('...should validate description field', () => { + const longDescriptionText = + 'This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text.'; + + getDescriptionField().should('be.empty'); + + getDescriptionField().type(longDescriptionText).focus().blur(); + + getDescriptionField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains( + 'Description should only consist of upper and lowercase letters, numbers 0-9, commas, hyphens, periods, spaces, and underscores. Max limit of 500 characters.' + ); + + getDescriptionField() + .type('{selectall}') + .type('{backspace}') + .type('Detector description...') + .focus() + .blur(); + + getDescriptionField() + .type('{selectall}') + .type('{backspace}') + .type('Detector name') + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); - getNextButton().should('be.disabled'); + it('...should validate data source field', () => { + getDataSourceField() + .focus() + .blur() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Select an input source.'); + + getDataSourceField().selectComboboxItem(cypressIndexDns); + getDataSourceField() + .focus() + .blur() + .parentsUntil('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); - getNameField().should('be.empty'); - getNameField().type('text').focus().blur(); + it('...should validate next button', () => { + getNextButton().should('be.disabled'); - getNameField() - .parentsUntil('.euiFormRow__fieldWrapper') - .siblings() - .contains( - 'Name should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores. Use between 5 and 50 characters.' - ); + fillDetailsForm(detectorName, cypressIndexDns); + getNextButton().should('be.enabled'); + }); - getNameField() - .type(' and more text') - .focus() - .blur() - .parentsUntil('.euiFormRow__fieldWrapper') - .siblings() - .should('not.exist'); - getNextButton().should('be.disabled'); + it('...should validate alerts page', () => { + fillDetailsForm(detectorName, cypressIndexDns); + getNextButton().click({ force: true }); + getTriggerNameField().should('be.empty'); - getDataSourceField() - .focus() - .blur() - .parentsUntil('.euiFormRow__fieldWrapper') - .siblings() - .contains('Select an input source'); - getNextButton().should('be.disabled'); - - getDataSourceField().selectComboboxItem(cypressIndexDns); - getDataSourceField() - .focus() - .blur() - .parentsUntil('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .should('not.exist'); - getNextButton().should('not.be.disabled'); - }); + getTriggerNameField().focus().blur(); + getTriggerNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains('Enter a name.'); - it('...should show mappings warning', () => { - getCreateDetectorButton().click({ force: true }); + getTriggerNameField().type('Trigger name').focus().blur(); - getDataSourceField().selectComboboxItem(cypressIndexDns); + getTriggerNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); - selectDnsLogType(); + getNextButton().should('be.enabled'); - getDataSourceField().selectComboboxItem(cypressIndexWindows); - getDataSourceField().focus().blur(); + getTriggerNameField().type('{selectall}').type('{backspace}').focus().blur(); + getNextButton().should('be.disabled'); - cy.get('.euiCallOut') - .should('be.visible') - .contains( - 'To avoid issues with field mappings, we recommend creating separate detectors for different log types.' - ); - }); + cy.getButtonByText('Remove').click({ force: true }); + getNextButton().should('be.enabled'); + }); - it('...can fail creation', () => { - createDetector(`${detectorName}_fail`, '.kibana_1', true); - cy.getElementByText('.euiCallOut', 'Create detector failed.'); - }); + it('...should show mappings warning', () => { + fillDetailsForm(detectorName, cypressIndexDns); + + getDataSourceField().selectComboboxItem(cypressIndexWindows); + getDataSourceField().focus().blur(); - it('...can be created', () => { - createDetector(detectorName, cypressIndexDns, false); - cy.getElementByText('.euiCallOut', 'Detector created successfully'); + cy.get('.euiCallOut') + .should('be.visible') + .contains( + 'To avoid issues with field mappings, we recommend creating separate detectors for different log types.' + ); + }); }); - it('...basic details can be edited', () => { - cy.intercept('GET', '/indices').as('getIndices'); - openDetectorDetails(detectorName); + describe('...validate create detector', () => { + beforeEach(() => { + cy.intercept('/detectors/_search').as('detectorsSearch'); - editDetectorDetails(detectorName, 'Detector details'); + // Visit Detectors page before any test + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); + cy.wait('@detectorsSearch').should('have.property', 'state', 'Complete'); + }); + + it('...can fail creation', () => { + createDetector(`${detectorName}_fail`, '.kibana_1', true); + cy.getElementByText('.euiCallOut', 'Create detector failed.'); + }); - cy.urlShouldContain('edit-detector-details').then(() => { - cy.getElementByText('.euiTitle', 'Edit detector details'); + it('...can be created', () => { + createDetector(detectorName, cypressIndexDns, false); + cy.getElementByText('.euiCallOut', 'Detector created successfully'); }); - cy.wait('@getIndices'); - getNameField().type('{selectall}{backspace}').type('test detector edited'); - cy.getTextareaByLabel('Description - optional').type('Edited description'); + it('...basic details can be edited', () => { + cy.intercept('GET', '/indices').as('getIndices'); + openDetectorDetails(detectorName); + + editDetectorDetails(detectorName, 'Detector details'); - getDataSourceField().clearCombobox(); - getDataSourceField().selectComboboxItem(cypressIndexWindows); + cy.urlShouldContain('edit-detector-details').then(() => { + cy.getElementByText('.euiTitle', 'Edit detector details'); + }); + + cy.wait('@getIndices'); + getNameField().type('{selectall}{backspace}').type('test detector edited'); + cy.getTextareaByLabel('Description - optional').type('Edited description'); - cy.getFieldByLabel('Run every').type('{selectall}{backspace}').type('10'); - cy.getFieldByLabel('Run every', 'select').select('Hours'); + getDataSourceField().clearCombobox(); + getDataSourceField().selectComboboxItem(cypressIndexWindows); - cy.getElementByText('button', 'Save changes').click({ force: true }); + cy.getFieldByLabel('Run every').type('{selectall}{backspace}').type('10'); + cy.getFieldByLabel('Run every', 'select').select('Hours'); - cy.urlShouldContain('detector-details').then(() => { - cy.validateDetailsItem('Detector name', 'test detector edited'); - cy.validateDetailsItem('Description', 'Edited description'); - cy.validateDetailsItem('Detector schedule', 'Every 10 hours'); - cy.validateDetailsItem('Data source', cypressIndexWindows); + cy.getElementByText('button', 'Save changes').click({ force: true }); + + cy.urlShouldContain('detector-details').then(() => { + cy.validateDetailsItem('Detector name', 'test detector edited'); + cy.validateDetailsItem('Description', 'Edited description'); + cy.validateDetailsItem('Detector schedule', 'Every 10 hours'); + cy.validateDetailsItem('Data source', cypressIndexWindows); + }); }); - }); - it('...rules can be edited', () => { - openDetectorDetails(detectorName); + it('...rules can be edited', () => { + openDetectorDetails(detectorName); - editDetectorDetails(detectorName, 'Active rules'); - cy.getElementByText('.euiTitle', 'Detection rules (14)'); + editDetectorDetails(detectorName, 'Active rules'); + cy.getElementByText('.euiTitle', 'Detection rules (14)'); - cy.getInputByPlaceholder('Search...').type(`${cypressDNSRule}`).pressEnterKey(); + cy.getInputByPlaceholder('Search...').type(`${cypressDNSRule}`).pressEnterKey(); - cy.getElementByText('.euiTableCellContent button', cypressDNSRule) - .parents('td') - .prev() - .find('.euiTableCellContent button') - .click(); + cy.getElementByText('.euiTableCellContent button', cypressDNSRule) + .parents('td') + .prev() + .find('.euiTableCellContent button') + .click(); - cy.getElementByText('.euiTitle', 'Detection rules (13)'); - cy.getElementByText('button', 'Save changes').click({ force: true }); - cy.urlShouldContain('detector-details').then(() => { - cy.getElementByText('.euiTitle', detectorName); - cy.getElementByText('.euiPanel .euiTitle', 'Active rules (13)'); + cy.getElementByText('.euiTitle', 'Detection rules (13)'); + cy.getElementByText('button', 'Save changes').click({ force: true }); + cy.urlShouldContain('detector-details').then(() => { + cy.getElementByText('.euiTitle', detectorName); + cy.getElementByText('.euiPanel .euiTitle', 'Active rules (13)'); + }); }); - }); - it('...should update field mappings if data source is changed', () => { - cy.intercept('mappings/view').as('getMappingsView'); - cy.intercept('GET', '/indices').as('getIndices'); - openDetectorDetails(detectorName); + it('...should update field mappings if data source is changed', () => { + cy.intercept('mappings/view').as('getMappingsView'); + cy.intercept('GET', '/indices').as('getIndices'); + openDetectorDetails(detectorName); - editDetectorDetails(detectorName, 'Detector details'); + editDetectorDetails(detectorName, 'Detector details'); - cy.urlShouldContain('edit-detector-details').then(() => { - cy.getElementByText('.euiTitle', 'Edit detector details'); - }); + cy.urlShouldContain('edit-detector-details').then(() => { + cy.getElementByText('.euiTitle', 'Edit detector details'); + }); - cy.wait('@getIndices'); - cy.get('.reviewFieldMappings').should('not.exist'); + cy.wait('@getIndices'); + cy.get('.reviewFieldMappings').should('not.exist'); - getDataSourceField().clearCombobox(); - getDataSourceField().should('not.have.value'); - getDataSourceField().type(`${cypressIndexDns}{enter}`); + getDataSourceField().clearCombobox(); + getDataSourceField().should('not.have.value'); + getDataSourceField().type(`${cypressIndexDns}{enter}`); - validateFieldMappingsTable('data source is changed'); + validateFieldMappingsTable('data source is changed'); - cy.getElementByText('button', 'Save changes').click({ force: true }); - }); + cy.getElementByText('button', 'Save changes').click({ force: true }); + }); - it('...should show field mappings if rule selection is changed', () => { - cy.intercept('mappings/view').as('getMappingsView'); + it('...should show field mappings if rule selection is changed', () => { + cy.intercept('mappings/view').as('getMappingsView'); - openDetectorDetails(detectorName); + openDetectorDetails(detectorName); - editDetectorDetails(detectorName, 'Active rules'); + editDetectorDetails(detectorName, 'Active rules'); - cy.urlShouldContain('edit-detector-rules').then(() => { - cy.getElementByText('.euiTitle', 'Edit detector rules'); - }); + cy.urlShouldContain('edit-detector-rules').then(() => { + cy.getElementByText('.euiTitle', 'Edit detector rules'); + }); - cy.get('.reviewFieldMappings').should('not.exist'); + cy.get('.reviewFieldMappings').should('not.exist'); - cy.wait('@detectorsSearch'); + cy.wait('@detectorsSearch'); - // Toggle single search result to unchecked - cy.get( - '[data-test-subj="edit-detector-rules-table"] table thead tr:first th:first button' - ).click({ force: true }); + // Toggle single search result to unchecked + cy.get( + '[data-test-subj="edit-detector-rules-table"] table thead tr:first th:first button' + ).click({ force: true }); - validateFieldMappingsTable('rules are changed'); - }); + validateFieldMappingsTable('rules are changed'); + }); - it('...can be deleted', () => { - cy.intercept('/_plugins/_security_analytics/rules/_search?prePackaged=true').as( - 'getSigmaRules' - ); - cy.intercept('/_plugins/_security_analytics/rules/_search?prePackaged=false').as( - 'getCustomRules' - ); - openDetectorDetails(detectorName); - - cy.wait('@detectorsSearch'); - cy.wait('@getCustomRules'); - cy.wait('@getSigmaRules'); - - cy.getButtonByText('Actions') - .click({ force: true }) - .then(() => { - cy.intercept('/detectors').as('detectors'); - cy.getElementByText('.euiContextMenuItem', 'Delete').click({ force: true }); - cy.wait('@detectors').then(() => { - cy.contains('There are no existing detectors'); + it('...can be deleted', () => { + cy.intercept('/_plugins/_security_analytics/rules/_search?prePackaged=true').as( + 'getSigmaRules' + ); + cy.intercept('/_plugins/_security_analytics/rules/_search?prePackaged=false').as( + 'getCustomRules' + ); + openDetectorDetails(detectorName); + + cy.wait('@detectorsSearch'); + cy.wait('@getCustomRules'); + cy.wait('@getSigmaRules'); + + cy.getButtonByText('Actions') + .click({ force: true }) + .then(() => { + cy.intercept('/detectors').as('detectors'); + cy.getElementByText('.euiContextMenuItem', 'Delete').click({ force: true }); + cy.wait('@detectors').then(() => { + cy.contains('There are no existing detectors'); + }); }); - }); + }); }); after(() => cy.cleanUpTests()); diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index 33d044d20..1279796f5 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -129,211 +129,496 @@ const checkRulesFlyout = () => { }); }; +const getCreateButton = () => cy.get('[data-test-subj="create_rule_button"]'); +const getNameField = () => cy.getFieldByLabel('Rule name'); +const getDescriptionField = () => cy.getFieldByLabel('Description - optional'); +const getAuthorField = () => cy.getFieldByLabel('Author'); +const getLogTypeField = () => cy.getFieldByLabel('Log type'); +const getRuleLevelField = () => cy.getFieldByLabel('Rule level (severity)'); +const getSelectionPanelByIndex = (index) => + cy.get(`[data-test-subj="detection-visual-editor-${index}"]`); +const getSelectionNameField = () => cy.get('[data-test-subj="selection_name"]'); +const getMapKeyField = () => cy.get('[data-test-subj="selection_field_key_name"]'); +const getMapValueField = () => cy.get('[data-test-subj="selection_field_value"]'); +const getMapListField = () => cy.get('[data-test-subj="selection_field_list"]'); +const getListRadioField = () => cy.get('[for="selection-map-list-0-0"]'); +const getConditionField = () => cy.get('[data-test-subj="rule_detection_field"]'); +const getConditionAddButton = () => cy.get('[data-test-subj="condition-add-selection-btn"]'); +const getRuleSubmitButton = () => cy.get('[data-test-subj="submit_rule_form_button"]'); +const getTagField = () => cy.getFieldByLabel('Tag'); + describe('Rules', () => { before(() => cy.cleanUpTests()); - beforeEach(() => { - cy.intercept('/rules/_search').as('rulesSearch'); - // Visit Rules page - cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/rules`); - cy.wait('@rulesSearch').should('have.property', 'state', 'Complete'); - - // Check that correct page is showing - cy.waitForPageLoad('rules', { - contains: 'Detection rules', - }); - }); - - it('...can be created', () => { - // Click "create new rule" button - cy.get('[data-test-subj="create_rule_button"]').click({ - force: true, - }); - // Enter the log type - cy.get('[data-test-subj="rule_status_dropdown"]').type(SAMPLE_RULE.status); + describe('...should validate rules form', () => { + beforeEach(() => { + cy.intercept('/rules/_search').as('rulesSearch'); + // Visit Rules page + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/rules`); + cy.wait('@rulesSearch').should('have.property', 'state', 'Complete'); - // Enter the name - cy.get('[data-test-subj="rule_name_field"]').type(SAMPLE_RULE.name); - - // Enter the log type - cy.get('[data-test-subj="rule_type_dropdown"]').type(SAMPLE_RULE.logType); + // Check that correct page is showing + cy.waitForPageLoad('rules', { + contains: 'Detection rules', + }); - // Enter the description - cy.get('[data-test-subj="rule_description_field"]').type(SAMPLE_RULE.description); + getCreateButton().click({ force: true }); + }); - // Enter the severity - cy.get('[data-test-subj="rule_severity_dropdown"]').type(SAMPLE_RULE.severity); + xit('...should validate rule name', () => { + getNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormHelpText') + .contains( + 'Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores' + ); + + getNameField().should('be.empty'); + getNameField().focus().blur(); + getNameField() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Rule name is required'); + + getNameField().type('text').focus().blur(); + + getNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains('Invalid rule name.'); + + getNameField().type('{selectall}').type('{backspace}').type('tex&').focus().blur(); + + getNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains('Invalid rule name.'); + + getNameField() + .type('{selectall}') + .type('{backspace}') + .type('Rule name') + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); - // Enter the tags - SAMPLE_RULE.tags.forEach((tag, index) => { - cy.get(`[data-test-subj="rule_tags_field_${index}"]`).type(`${tag}{enter}`); - index < SAMPLE_RULE.tags.length - 1 && - cy.get('.euiButton').contains('Add tag').click({ force: true }); + xit('...should validate rule description field', () => { + const longDescriptionText = + 'This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text.'; + + getDescriptionField().should('be.empty'); + + getDescriptionField().type(longDescriptionText).focus().blur(); + + getDescriptionField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains( + 'Description should only consist of upper and lowercase letters, numbers 0-9, commas, hyphens, periods, spaces, and underscores. Max limit of 500 characters.' + ); + + getDescriptionField() + .type('{selectall}') + .type('{backspace}') + .type('Detector description...') + .focus() + .blur(); + + getDescriptionField() + .type('{selectall}') + .type('{backspace}') + .type('Detector name') + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); }); - // Enter the reference - cy.get('[data-test-subj="rule_references_field_0"]').type(SAMPLE_RULE.references); + xit('...should validate author', () => { + getAuthorField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormHelpText') + .contains('Combine multiple authors separated with a comma'); + + getAuthorField().should('be.empty'); + getAuthorField().focus().blur(); + getAuthorField() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Author name is required'); + + getAuthorField().type('text').focus().blur(); + + getAuthorField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains('Invalid author.'); + + getAuthorField().type('{selectall}').type('{backspace}').type('tex&').focus().blur(); + + getAuthorField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains('Invalid author.'); + + getAuthorField() + .type('{selectall}') + .type('{backspace}') + .type('Rule name') + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); - // Enter the false positive cases - cy.get('[data-test-subj="rule_false_positives_field_0"]').type( - `${SAMPLE_RULE.falsePositive}{enter}` - ); + xit('...should validate log type field', () => { + getLogTypeField().should('be.empty'); + getLogTypeField().focus().blur(); + getLogTypeField() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Log type is required'); + + getLogTypeField().selectComboboxItem(SAMPLE_RULE.logType); + + getLogTypeField() + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); - // Enter the author - cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}{enter}`); + xit('...should validate rule level field', () => { + getRuleLevelField().should('be.empty'); + getRuleLevelField().focus().blur(); + getRuleLevelField() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Rule level is required'); + + getRuleLevelField().selectComboboxItem(SAMPLE_RULE.severity); + + getRuleLevelField() + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); - cy.get('[data-test-subj="detection-visual-editor-0"]').within(() => { - cy.getFieldByLabel('Name').type('{selectall}{backspace}').type('Selection_1'); - cy.getFieldByLabel('Key').type('Provider_Name'); - cy.getInputByPlaceholder('Value').type('Service Control Manager'); + xit('...should validate selection', () => { + getSelectionPanelByIndex(0).within((selectionPanel) => { + getSelectionNameField().should('have.value', 'Selection_1'); + getSelectionNameField().type('{selectall}').type('{backspace}'); + getSelectionNameField().focus().blur(); + getSelectionNameField() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Selection name is required'); + + getSelectionNameField().type('Selection'); + getSelectionNameField() + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); + }); - cy.getButtonByText('Add map').click(); - cy.get('[data-test-subj="Map-1"]').within(() => { - cy.getFieldByLabel('Key').type('EventID'); - cy.getInputByPlaceholder('Value').type('7045'); + xit('...should validate selection map key field', () => { + getSelectionPanelByIndex(0).within((selectionPanel) => { + getMapKeyField().should('be.empty'); + getMapKeyField().focus().blur(); + getMapKeyField() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Key name is required'); + + getMapKeyField().type('FieldKey'); + getMapKeyField() + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); }); + }); - cy.getButtonByText('Add map').click(); - cy.get('[data-test-subj="Map-2"]').within(() => { - cy.getFieldByLabel('Key').type('ServiceName'); - cy.getInputByPlaceholder('Value').type('ZzNetSvc'); + xit('...should validate selection map value field', () => { + getSelectionPanelByIndex(0).within((selectionPanel) => { + getMapValueField().should('be.empty'); + getMapValueField().focus().blur(); + getMapValueField() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Value is required'); + + getMapValueField().type('FieldValue'); + getMapValueField() + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); }); }); - cy.get('[data-test-subj="rule_detection_field"] textarea').type('Selection_1', { - force: true, + + xit('...should validate selection map list field', () => { + getSelectionPanelByIndex(0).within((selectionPanel) => { + getListRadioField().click({ force: true }); + getMapListField().should('be.empty'); + getMapListField().focus().blur(); + getMapListField().parentsUntil('.euiFormRow').contains('Value is required'); + + getMapListField().type('FieldValue'); + getMapListField() + .focus() + .blur() + .parents('.euiFormRow') + .find('.euiFormErrorText') + .should('not.exist'); + }); }); - cy.get('[aria-label="Add one more condition"]').click({ force: true }); + xit('...should validate condition field', () => { + getConditionField().scrollIntoView(); + getConditionField().find('.euiFormErrorText').should('not.exist'); + getRuleSubmitButton().click({ force: true }); + getConditionField().parents('.euiFormRow__fieldWrapper').contains('Condition is required'); - // Enter the author - cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}`); + getConditionAddButton().click({ force: true }); + getConditionField().find('.euiFormErrorText').should('not.exist'); + }); - // Switch to YAML editor - cy.get('[data-test-subj="change-editor-type"] label:nth-child(2)').click({ - force: true, + xit('...should validate tag field', () => { + getTagField().should('be.empty'); + getTagField().type('wrong.tag').focus().blur(); + getTagField().parents('.euiFormRow__fieldWrapper').contains("Tags must start with 'attack.'"); + + getTagField().type('{selectall}').type('{backspace}').type('attack.tag'); + getTagField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); }); - YAML_RULE_LINES.forEach((line) => cy.get('[data-test-subj="rule_yaml_editor"]').contains(line)); + it('...should validate create button', () => {}); + }); - cy.intercept({ - url: '/rules', - }).as('getRules'); + xdescribe('...should validate rules create', () => { + beforeEach(() => { + cy.intercept('/rules/_search').as('rulesSearch'); + // Visit Rules page + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/rules`); + cy.wait('@rulesSearch').should('have.property', 'state', 'Complete'); - // Click "create" button - cy.get('[data-test-subj="submit_rule_form_button"]').click({ - force: true, + // Check that correct page is showing + cy.waitForPageLoad('rules', { + contains: 'Detection rules', + }); }); - cy.wait('@getRules'); + it('...can be created', () => { + // Click "create new rule" button + cy.get('[data-test-subj="create_rule_button"]').click({ + force: true, + }); - cy.waitForPageLoad('rules', { - contains: 'Detection rules', - }); + // Enter the log type + cy.get('[data-test-subj="rule_status_dropdown"]').type(SAMPLE_RULE.status); - checkRulesFlyout(); - }); + // Enter the name + cy.get('[data-test-subj="rule_name_field"]').type(SAMPLE_RULE.name); - it('...can be edited', () => { - cy.waitForPageLoad('rules', { - contains: 'Detection rules', - }); + // Enter the log type + cy.get('[data-test-subj="rule_type_dropdown"]').type(SAMPLE_RULE.logType); + + // Enter the description + cy.get('[data-test-subj="rule_description_field"]').type(SAMPLE_RULE.description); - cy.get(`input[placeholder="Search rules"]`).ospSearch(SAMPLE_RULE.name); - cy.get(`[data-test-subj="rule_link_${SAMPLE_RULE.name}"]`).click({ force: true }); + // Enter the severity + cy.get('[data-test-subj="rule_severity_dropdown"]').type(SAMPLE_RULE.severity); - cy.get(`[data-test-subj="rule_flyout_${SAMPLE_RULE.name}"]`) - .find('button') - .contains('Action') - .click({ force: true }) - .then(() => { - // Confirm arrival at detectors page - cy.get('.euiPopover__panel').find('button').contains('Edit').click(); + // Enter the tags + SAMPLE_RULE.tags.forEach((tag, index) => { + cy.get(`[data-test-subj="rule_tags_field_${index}"]`).type(`${tag}{enter}`); + index < SAMPLE_RULE.tags.length - 1 && + cy.get('.euiButton').contains('Add tag').click({ force: true }); }); - const ruleNameSelector = '[data-test-subj="rule_name_field"]'; - cy.get(ruleNameSelector).clear(); - - SAMPLE_RULE.name += ' edited'; - cy.get(ruleNameSelector).type(SAMPLE_RULE.name); - cy.get(ruleNameSelector).should('have.value', SAMPLE_RULE.name); - - // Enter the log type - const logSelector = '[data-test-subj="rule_type_dropdown"]'; - cy.get(logSelector).within(() => cy.get('.euiFormControlLayoutClearButton').click()); - SAMPLE_RULE.logType = 'dns'; - YAML_RULE_LINES[2] = `product: ${SAMPLE_RULE.logType}`; - YAML_RULE_LINES[3] = `title: ${SAMPLE_RULE.name}`; - cy.get(logSelector).type(SAMPLE_RULE.logType).type('{enter}'); - cy.get(logSelector).contains(SAMPLE_RULE.logType, { - matchCase: false, - }); + // Enter the reference + cy.get('[data-test-subj="rule_references_field_0"]').type(SAMPLE_RULE.references); + + // Enter the false positive cases + cy.get('[data-test-subj="rule_false_positives_field_0"]').type( + `${SAMPLE_RULE.falsePositive}{enter}` + ); - const ruleDescriptionSelector = '[data-test-subj="rule_description_field"]'; - SAMPLE_RULE.description += ' edited'; - YAML_RULE_LINES[4] = `description: ${SAMPLE_RULE.description}`; - cy.get(ruleDescriptionSelector).clear(); - cy.get(ruleDescriptionSelector).type(SAMPLE_RULE.description); - cy.get(ruleDescriptionSelector).should('have.value', SAMPLE_RULE.description); + // Enter the author + cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}{enter}`); - cy.intercept({ - url: '/rules', - }).as('getRules'); + cy.get('[data-test-subj="detection-visual-editor-0"]').within(() => { + cy.getFieldByLabel('Name').type('{selectall}{backspace}').type('Selection_1'); + cy.getFieldByLabel('Key').type('Provider_Name'); + cy.getInputByPlaceholder('Value').type('Service Control Manager'); - // Click "create" button - cy.get('[data-test-subj="submit_rule_form_button"]').click({ - force: true, - }); + cy.getButtonByText('Add map').click(); + cy.get('[data-test-subj="Map-1"]').within(() => { + cy.getFieldByLabel('Key').type('EventID'); + cy.getInputByPlaceholder('Value').type('7045'); + }); - cy.waitForPageLoad('rules', { - contains: 'Detection rules', + cy.getButtonByText('Add map').click(); + cy.get('[data-test-subj="Map-2"]').within(() => { + cy.getFieldByLabel('Key').type('ServiceName'); + cy.getInputByPlaceholder('Value').type('ZzNetSvc'); + }); + }); + cy.get('[data-test-subj="rule_detection_field"] textarea').type('Selection_1', { + force: true, + }); + + cy.get('[aria-label="Add one more condition"]').click({ force: true }); + + // Enter the author + cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}`); + + // Switch to YAML editor + cy.get('[data-test-subj="change-editor-type"] label:nth-child(2)').click({ + force: true, + }); + + YAML_RULE_LINES.forEach((line) => + cy.get('[data-test-subj="rule_yaml_editor"]').contains(line) + ); + + cy.intercept({ + url: '/rules', + }).as('getRules'); + + // Click "create" button + cy.get('[data-test-subj="submit_rule_form_button"]').click({ + force: true, + }); + + cy.wait('@getRules'); + + cy.waitForPageLoad('rules', { + contains: 'Detection rules', + }); + + checkRulesFlyout(); }); - cy.wait('@getRules'); + it('...can be edited', () => { + cy.waitForPageLoad('rules', { + contains: 'Detection rules', + }); - checkRulesFlyout(); - }); + cy.get(`input[placeholder="Search rules"]`).ospSearch(SAMPLE_RULE.name); + cy.get(`[data-test-subj="rule_link_${SAMPLE_RULE.name}"]`).click({ force: true }); - it('...can be deleted', () => { - cy.intercept({ - url: '/rules', - }).as('deleteRule'); - - cy.intercept('POST', 'rules/_search?prePackaged=true', { - delay: 5000, - }).as('getPrePackagedRules'); - - cy.intercept('POST', 'rules/_search?prePackaged=false', { - delay: 5000, - }).as('getCustomRules'); - - cy.wait('@rulesSearch'); - cy.get(`input[placeholder="Search rules"]`).ospSearch(SAMPLE_RULE.name); - - // Click the rule link to open the details flyout - cy.get(`[data-test-subj="rule_link_${SAMPLE_RULE.name}"]`).click({ force: true }); - - cy.get(`[data-test-subj="rule_flyout_${SAMPLE_RULE.name}"]`) - .find('button') - .contains('Action') - .click({ force: true }) - .then(() => { - // Confirm arrival at detectors page - cy.get('.euiPopover__panel') - .find('button') - .contains('Delete') - .click() - .then(() => cy.get('.euiModalFooter > .euiButton').contains('Delete').click()); - - cy.wait('@deleteRule'); - cy.wait('@getCustomRules'); - cy.wait('@getPrePackagedRules'); - - // Search for sample_detector, presumably deleted - cy.wait(3000); - cy.get(`input[placeholder="Search rules"]`).ospSearch(SAMPLE_RULE.name); - // Click the rule link to open the details flyout - cy.get('tbody').contains(SAMPLE_RULE.name).should('not.exist'); + cy.get(`[data-test-subj="rule_flyout_${SAMPLE_RULE.name}"]`) + .find('button') + .contains('Action') + .click({ force: true }) + .then(() => { + // Confirm arrival at detectors page + cy.get('.euiPopover__panel').find('button').contains('Edit').click(); + }); + + const ruleNameSelector = '[data-test-subj="rule_name_field"]'; + cy.get(ruleNameSelector).clear(); + + SAMPLE_RULE.name += ' edited'; + cy.get(ruleNameSelector).type(SAMPLE_RULE.name); + cy.get(ruleNameSelector).should('have.value', SAMPLE_RULE.name); + + // Enter the log type + const logSelector = '[data-test-subj="rule_type_dropdown"]'; + cy.get(logSelector).within(() => cy.get('.euiFormControlLayoutClearButton').click()); + SAMPLE_RULE.logType = 'dns'; + YAML_RULE_LINES[2] = `product: ${SAMPLE_RULE.logType}`; + YAML_RULE_LINES[3] = `title: ${SAMPLE_RULE.name}`; + cy.get(logSelector).type(SAMPLE_RULE.logType).type('{enter}'); + cy.get(logSelector).contains(SAMPLE_RULE.logType, { + matchCase: false, + }); + + const ruleDescriptionSelector = '[data-test-subj="rule_description_field"]'; + SAMPLE_RULE.description += ' edited'; + YAML_RULE_LINES[4] = `description: ${SAMPLE_RULE.description}`; + cy.get(ruleDescriptionSelector).clear(); + cy.get(ruleDescriptionSelector).type(SAMPLE_RULE.description); + cy.get(ruleDescriptionSelector).should('have.value', SAMPLE_RULE.description); + + cy.intercept({ + url: '/rules', + }).as('getRules'); + + // Click "create" button + cy.get('[data-test-subj="submit_rule_form_button"]').click({ + force: true, }); + + cy.waitForPageLoad('rules', { + contains: 'Detection rules', + }); + + cy.wait('@getRules'); + + checkRulesFlyout(); + }); + + it('...can be deleted', () => { + cy.intercept({ + url: '/rules', + }).as('deleteRule'); + + cy.intercept('POST', 'rules/_search?prePackaged=true', { + delay: 5000, + }).as('getPrePackagedRules'); + + cy.intercept('POST', 'rules/_search?prePackaged=false', { + delay: 5000, + }).as('getCustomRules'); + + cy.wait('@rulesSearch'); + cy.get(`input[placeholder="Search rules"]`).ospSearch(SAMPLE_RULE.name); + + // Click the rule link to open the details flyout + cy.get(`[data-test-subj="rule_link_${SAMPLE_RULE.name}"]`).click({ force: true }); + + cy.get(`[data-test-subj="rule_flyout_${SAMPLE_RULE.name}"]`) + .find('button') + .contains('Action') + .click({ force: true }) + .then(() => { + // Confirm arrival at detectors page + cy.get('.euiPopover__panel') + .find('button') + .contains('Delete') + .click() + .then(() => cy.get('.euiModalFooter > .euiButton').contains('Delete').click()); + + cy.wait('@deleteRule'); + cy.wait('@getCustomRules'); + cy.wait('@getPrePackagedRules'); + + // Search for sample_detector, presumably deleted + cy.wait(3000); + cy.get(`input[placeholder="Search rules"]`).ospSearch(SAMPLE_RULE.name); + // Click the rule link to open the details flyout + cy.get('tbody').contains(SAMPLE_RULE.name).should('not.exist'); + }); + }); }); after(() => cy.cleanUpTests()); diff --git a/cypress/support/helpers.js b/cypress/support/helpers.js index ca572887e..34a5a6081 100644 --- a/cypress/support/helpers.js +++ b/cypress/support/helpers.js @@ -62,17 +62,7 @@ Cypress.Commands.add( items = [items]; } Cypress.log({ message: `Select combobox items: ${items.join(' | ')}` }); - cy.wrap(subject) - .focus() - .click({ force: true }) - .then(() => { - items.map((item) => - cy.get('.euiComboBoxOptionsList__rowWrap').within(() => { - cy.get('button').contains(item).should('be.visible'); - cy.get('button').contains(item).click(); - }) - ); - }); + items.map((item) => cy.wrap(subject).type(item).type('{enter}')); } ); @@ -93,11 +83,7 @@ Cypress.Commands.add( message: `Number of combo badges to clear: ${numberOfBadges}`, }); - cy.wrap(subject) - .parents('.euiComboBox__inputWrap') - .find('input') - .focus() - .pressBackspaceKey(numberOfBadges); + _.times(cy.wrap(subject).type('{backspace}'), numberOfBadges); }); } ); diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index 2d8595ad0..8c5d302f1 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -540,7 +540,7 @@ export class DetectionVisualEditor extends React.Component< ) : null } - style={{ maxWidth: '500px' }} + style={{ maxWidth: '70%' }} > @@ -622,7 +622,7 @@ export class DetectionVisualEditor extends React.Component< > Upload file - + Clear list +
- { const values = e.target.value.split('\n'); diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index 7d6c3d4f8..fb453ca47 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -96,7 +96,8 @@ export const RuleEditorForm: React.FC = ({ } if (values.description && !validateDescription(values.description)) { - errors.description = 'Invalid description.'; + errors.description = + 'Description should only consist of upper and lowercase letters, numbers 0-9, commas, hyphens, periods, spaces, and underscores. Max limit of 500 characters.'; } if (!values.logType) { @@ -228,7 +229,7 @@ export const RuleEditorForm: React.FC = ({ Author
} - helpText="Combine miltiple authors separated with a comma" + helpText="Combine multiple authors separated with a comma" isInvalid={props.touched.author && !!props.errors?.author} error={props.errors.author} > @@ -283,56 +284,6 @@ export const RuleEditorForm: React.FC = ({ - - Description - - optional - - } - helpText="Description must contain 5-500 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, dots, commas, and underscores." - isInvalid={!!props.errors?.description} - error={props.errors.description} - > - { - props.handleChange('description')(e.target.value); - }} - onBlur={props.handleBlur('description')} - value={props.values.description} - /> - - - - - Detection - - -

Define the detection criteria for the rule

-
- - - { - if (isInvalid) { - props.errors.detection = 'Invalid detection entries'; - } else { - delete props.errors.detection; - } - - setIsDetectionInvalid(isInvalid); - }} - onChange={(detection: string) => { - props.handleChange('detection')(detection); - }} - /> - - - @@ -394,9 +345,7 @@ export const RuleEditorForm: React.FC = ({ /> - - - + @@ -426,7 +375,7 @@ export const RuleEditorForm: React.FC = ({ }} /> - + = ({ color={'primary'} iconType="plusInCircleFilled" aria-label={'Add one more condition'} + data-test-subj={'condition-add-selection-btn'} /> )} diff --git a/public/utils/validation.ts b/public/utils/validation.ts index 43e01923d..5e2e89e65 100644 --- a/public/utils/validation.ts +++ b/public/utils/validation.ts @@ -79,7 +79,7 @@ export function validateDescription(name: string): boolean { } export const descriptionErrorString = `Description should only consist of upper and lowercase letters, numbers 0-9, - commas, hyphens, periods, spaces, and underscores. Max limit of ${MAX_DESCRIPTION_CHARACTERS} characters,`; + commas, hyphens, periods, spaces, and underscores. Max limit of ${MAX_DESCRIPTION_CHARACTERS} characters.`; export function getDescriptionErrorMessage( _description: string, From cc0c408bf345cfff5bd27e3bbbc89798d37a31f9 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Wed, 7 Jun 2023 12:20:26 +0200 Subject: [PATCH 15/22] Cypress cases for detectors and rules, validate forms and fields Signed-off-by: Jovan Cvetkovic --- cypress/integration/2_rules.spec.js | 371 +++++++++--------- cypress/support/helpers.js | 73 +++- cypress/support/index.d.ts | 36 ++ .../components/RuleEditor/RuleEditorForm.tsx | 4 +- .../components/SelectionExpField.tsx | 35 +- yarn.lock | 119 +++++- 6 files changed, 405 insertions(+), 233 deletions(-) diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index 1279796f5..8f3abec0d 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -10,16 +10,7 @@ const SAMPLE_RULE = { name: `Cypress test rule ${uniqueId}`, logType: 'windows', description: 'This is a rule used to test the rule creation workflow.', - detectionLine: [ - 'condition: Selection_1', - 'Selection_1:', - 'Provider_Name|contains:', - '- Service Control Manager', - 'EventID|contains:', - "- '7045'", - 'ServiceName|contains:', - '- ZzNetSvc', - ], + detectionLine: ['condition: Selection_1', 'Selection_1:', 'FieldKey|contains:', '- FieldValue'], severity: 'critical', tags: ['attack.persistence', 'attack.privilege_escalation', 'attack.t1543.003'], references: 'https://nohello.com', @@ -131,6 +122,7 @@ const checkRulesFlyout = () => { const getCreateButton = () => cy.get('[data-test-subj="create_rule_button"]'); const getNameField = () => cy.getFieldByLabel('Rule name'); +const getRuleStatusField = () => cy.getFieldByLabel('Rule Status'); const getDescriptionField = () => cy.getFieldByLabel('Description - optional'); const getAuthorField = () => cy.getFieldByLabel('Author'); const getLogTypeField = () => cy.getFieldByLabel('Log type'); @@ -142,15 +134,59 @@ const getMapKeyField = () => cy.get('[data-test-subj="selection_field_key_name"] const getMapValueField = () => cy.get('[data-test-subj="selection_field_value"]'); const getMapListField = () => cy.get('[data-test-subj="selection_field_list"]'); const getListRadioField = () => cy.get('[for="selection-map-list-0-0"]'); +const getTextRadioField = () => cy.get('[for="selection-map-value-0-0"]'); const getConditionField = () => cy.get('[data-test-subj="rule_detection_field"]'); const getConditionAddButton = () => cy.get('[data-test-subj="condition-add-selection-btn"]'); +const getConditionRemoveButton = (index) => + cy.get(`[data-test-subj="selection-exp-field-item-remove-${index}"]`); const getRuleSubmitButton = () => cy.get('[data-test-subj="submit_rule_form_button"]'); -const getTagField = () => cy.getFieldByLabel('Tag'); +const getTagField = (index) => cy.get(`[data-test-subj="rule_tags_field_${index}"]`); +const getReferenceFieldByIndex = (index) => + cy.get(`[data-test-subj="rule_references_field_${index}"]`); +const getFalsePositiveFieldByIndex = (index) => + cy.get(`[data-test-subj="rule_false_positives_field_${index}"]`); + +const toastShouldExist = () => { + submitRule(); + cy.get('.euiToast').contains('Failed to create rule:'); +}; + +const submitRule = () => getRuleSubmitButton().click({ force: true }); +const fillCreateForm = () => { + // rule overview + getNameField().type(SAMPLE_RULE.name); + getDescriptionField().type(SAMPLE_RULE.description); + getAuthorField().type(`${SAMPLE_RULE.author}`); + + // rule details + getLogTypeField().type(SAMPLE_RULE.logType); + getRuleLevelField().selectComboboxItem(SAMPLE_RULE.severity); + + // rule detection + getSelectionPanelByIndex(0).within(() => { + getSelectionNameField().should('have.value', 'Selection_1'); + getMapKeyField().type('FieldKey'); + + getTextRadioField().click({ force: true }); + getMapValueField().type('FieldValue'); + }); + + getConditionAddButton().click({ force: true }); + + // rule additional details + SAMPLE_RULE.tags.forEach((tag, idx) => { + getTagField(idx).type(tag); + idx < SAMPLE_RULE.tags.length - 1 && cy.getButtonByText('Add tag').click({ force: true }); + }); + + getReferenceFieldByIndex(0).type(SAMPLE_RULE.references); + getFalsePositiveFieldByIndex(0).type(SAMPLE_RULE.falsePositive); +}; describe('Rules', () => { before(() => cy.cleanUpTests()); - describe('...should validate rules form', () => { + describe('...should validate rules form fields', () => { beforeEach(() => { cy.intercept('/rules/_search').as('rulesSearch'); // Visit Rules page @@ -165,34 +201,19 @@ describe('Rules', () => { getCreateButton().click({ force: true }); }); - xit('...should validate rule name', () => { - getNameField() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormHelpText') - .contains( - 'Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores' - ); + it('...should validate rule name', () => { + getNameField().containsHelperText( + 'Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores' + ); getNameField().should('be.empty'); getNameField().focus().blur(); - getNameField() - .parentsUntil('.euiFormRow__fieldWrapper') - .siblings() - .contains('Rule name is required'); - + getNameField().containsError('Rule name is required'); getNameField().type('text').focus().blur(); - - getNameField() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .contains('Invalid rule name.'); + getNameField().containsError('Invalid rule name.'); getNameField().type('{selectall}').type('{backspace}').type('tex&').focus().blur(); - - getNameField() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .contains('Invalid rule name.'); + getNameField().containsError('Invalid rule name.'); getNameField() .type('{selectall}') @@ -200,17 +221,14 @@ describe('Rules', () => { .type('Rule name') .focus() .blur() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .should('not.exist'); + .shouldNotHaveError(); }); - xit('...should validate rule description field', () => { + it('...should validate rule description field', () => { const longDescriptionText = 'This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text.'; getDescriptionField().should('be.empty'); - getDescriptionField().type(longDescriptionText).focus().blur(); getDescriptionField() @@ -238,32 +256,17 @@ describe('Rules', () => { .should('not.exist'); }); - xit('...should validate author', () => { - getAuthorField() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormHelpText') - .contains('Combine multiple authors separated with a comma'); + it('...should validate author', () => { + getAuthorField().containsHelperText('Combine multiple authors separated with a comma'); getAuthorField().should('be.empty'); getAuthorField().focus().blur(); - getAuthorField() - .parentsUntil('.euiFormRow__fieldWrapper') - .siblings() - .contains('Author name is required'); - + getAuthorField().containsError('Author name is required'); getAuthorField().type('text').focus().blur(); - - getAuthorField() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .contains('Invalid author.'); + getAuthorField().containsError('Invalid author.'); getAuthorField().type('{selectall}').type('{backspace}').type('tex&').focus().blur(); - - getAuthorField() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .contains('Invalid author.'); + getAuthorField().containsError('Invalid author.'); getAuthorField() .type('{selectall}') @@ -271,58 +274,47 @@ describe('Rules', () => { .type('Rule name') .focus() .blur() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .should('not.exist'); + .shouldNotHaveError(); }); - xit('...should validate log type field', () => { + it('...should validate log type field', () => { getLogTypeField().should('be.empty'); getLogTypeField().focus().blur(); - getLogTypeField() - .parentsUntil('.euiFormRow__fieldWrapper') - .siblings() - .contains('Log type is required'); + getLogTypeField().containsError('Log type is required'); getLogTypeField().selectComboboxItem(SAMPLE_RULE.logType); - - getLogTypeField() - .focus() - .blur() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .should('not.exist'); + getLogTypeField().focus().blur().shouldNotHaveError(); }); - xit('...should validate rule level field', () => { + it('...should validate rule level field', () => { getRuleLevelField().should('be.empty'); getRuleLevelField().focus().blur(); - getRuleLevelField() - .parentsUntil('.euiFormRow__fieldWrapper') - .siblings() - .contains('Rule level is required'); + getRuleLevelField().containsError('Rule level is required'); getRuleLevelField().selectComboboxItem(SAMPLE_RULE.severity); + getRuleLevelField().focus().blur().shouldNotHaveError(); + }); - getRuleLevelField() - .focus() - .blur() - .parents('.euiFormRow__fieldWrapper') - .find('.euiFormErrorText') - .should('not.exist'); + it('...should validate rule status field', () => { + getRuleStatusField().containsValue(SAMPLE_RULE.status); + getRuleStatusField().focus().blur().shouldNotHaveError(); + + getRuleStatusField().clearCombobox(); + getRuleStatusField().focus().blur(); + getRuleStatusField().containsError('Rule status is required'); }); - xit('...should validate selection', () => { - getSelectionPanelByIndex(0).within((selectionPanel) => { + it('...should validate selection', () => { + getSelectionPanelByIndex(0).within(() => { getSelectionNameField().should('have.value', 'Selection_1'); - getSelectionNameField().type('{selectall}').type('{backspace}'); + getSelectionNameField().clearValue(); getSelectionNameField().focus().blur(); getSelectionNameField() .parentsUntil('.euiFormRow__fieldWrapper') .siblings() .contains('Selection name is required'); - getSelectionNameField().type('Selection'); + getSelectionNameField().type('Selection_1'); getSelectionNameField() .focus() .blur() @@ -332,8 +324,8 @@ describe('Rules', () => { }); }); - xit('...should validate selection map key field', () => { - getSelectionPanelByIndex(0).within((selectionPanel) => { + it('...should validate selection map key field', () => { + getSelectionPanelByIndex(0).within(() => { getMapKeyField().should('be.empty'); getMapKeyField().focus().blur(); getMapKeyField() @@ -351,8 +343,8 @@ describe('Rules', () => { }); }); - xit('...should validate selection map value field', () => { - getSelectionPanelByIndex(0).within((selectionPanel) => { + it('...should validate selection map value field', () => { + getSelectionPanelByIndex(0).within(() => { getMapValueField().should('be.empty'); getMapValueField().focus().blur(); getMapValueField() @@ -370,8 +362,8 @@ describe('Rules', () => { }); }); - xit('...should validate selection map list field', () => { - getSelectionPanelByIndex(0).within((selectionPanel) => { + it('...should validate selection map list field', () => { + getSelectionPanelByIndex(0).within(() => { getListRadioField().click({ force: true }); getMapListField().should('be.empty'); getMapListField().focus().blur(); @@ -387,7 +379,7 @@ describe('Rules', () => { }); }); - xit('...should validate condition field', () => { + it('...should validate condition field', () => { getConditionField().scrollIntoView(); getConditionField().find('.euiFormErrorText').should('not.exist'); getRuleSubmitButton().click({ force: true }); @@ -395,100 +387,115 @@ describe('Rules', () => { getConditionAddButton().click({ force: true }); getConditionField().find('.euiFormErrorText').should('not.exist'); + + getConditionRemoveButton(0).click({ force: true }); + getConditionField().parents('.euiFormRow__fieldWrapper').contains('Condition is required'); }); - xit('...should validate tag field', () => { - getTagField().should('be.empty'); - getTagField().type('wrong.tag').focus().blur(); - getTagField().parents('.euiFormRow__fieldWrapper').contains("Tags must start with 'attack.'"); + it('...should validate tag field', () => { + getTagField(0).should('be.empty'); + getTagField(0).type('wrong.tag').focus().blur(); + getTagField(0) + .parents('.euiFormRow__fieldWrapper') + .contains("Tags must start with 'attack.'"); - getTagField().type('{selectall}').type('{backspace}').type('attack.tag'); - getTagField() + getTagField(0).clearValue().type('attack.tag'); + getTagField(0) .parents('.euiFormRow__fieldWrapper') .find('.euiFormErrorText') .should('not.exist'); }); - it('...should validate create button', () => {}); - }); + it('...should validate form', () => { + toastShouldExist(); + fillCreateForm(); - xdescribe('...should validate rules create', () => { - beforeEach(() => { - cy.intercept('/rules/_search').as('rulesSearch'); - // Visit Rules page - cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/rules`); - cy.wait('@rulesSearch').should('have.property', 'state', 'Complete'); + // rule name field + getNameField().clearValue(); + toastShouldExist(); + getNameField().type('Rule name'); - // Check that correct page is showing - cy.waitForPageLoad('rules', { - contains: 'Detection rules', - }); - }); + // author field + getAuthorField().clearValue(); + toastShouldExist(); + getAuthorField().type('John Doe'); - it('...can be created', () => { - // Click "create new rule" button - cy.get('[data-test-subj="create_rule_button"]').click({ - force: true, - }); + // log field + getLogTypeField().clearCombobox(); + toastShouldExist(); + getLogTypeField().selectComboboxItem(SAMPLE_RULE.logType); - // Enter the log type - cy.get('[data-test-subj="rule_status_dropdown"]').type(SAMPLE_RULE.status); + // severity field + getRuleLevelField().clearCombobox(); + toastShouldExist(); + getRuleLevelField().selectComboboxItem(SAMPLE_RULE.severity); - // Enter the name - cy.get('[data-test-subj="rule_name_field"]').type(SAMPLE_RULE.name); + // status field + getRuleStatusField().clearCombobox(); + toastShouldExist(); + getRuleStatusField().selectComboboxItem(SAMPLE_RULE.status); - // Enter the log type - cy.get('[data-test-subj="rule_type_dropdown"]').type(SAMPLE_RULE.logType); + // selection name field + getSelectionPanelByIndex(0).within(() => + getSelectionNameField().type('{selectall}').type('{backspace}') + ); + toastShouldExist(); + getSelectionPanelByIndex(0).within(() => getSelectionNameField().type('Selection_1')); - // Enter the description - cy.get('[data-test-subj="rule_description_field"]').type(SAMPLE_RULE.description); + // selection map key field + getSelectionPanelByIndex(0).within(() => + getMapKeyField().type('{selectall}').type('{backspace}') + ); + toastShouldExist(); + getSelectionPanelByIndex(0).within(() => getMapKeyField().type('FieldKey')); - // Enter the severity - cy.get('[data-test-subj="rule_severity_dropdown"]').type(SAMPLE_RULE.severity); + // selection map value field + getSelectionPanelByIndex(0).within(() => + getMapValueField().type('{selectall}').type('{backspace}') + ); + toastShouldExist(); + getSelectionPanelByIndex(0).within(() => getMapValueField().type('FieldValue')); - // Enter the tags - SAMPLE_RULE.tags.forEach((tag, index) => { - cy.get(`[data-test-subj="rule_tags_field_${index}"]`).type(`${tag}{enter}`); - index < SAMPLE_RULE.tags.length - 1 && - cy.get('.euiButton').contains('Add tag').click({ force: true }); + // selection map list field + getSelectionPanelByIndex(0).within(() => { + getListRadioField().click({ force: true }); + getMapListField().clearValue(); + }); + toastShouldExist(); + getSelectionPanelByIndex(0).within(() => { + getListRadioField().click({ force: true }); + getMapListField().type('FieldValue'); }); - // Enter the reference - cy.get('[data-test-subj="rule_references_field_0"]').type(SAMPLE_RULE.references); - - // Enter the false positive cases - cy.get('[data-test-subj="rule_false_positives_field_0"]').type( - `${SAMPLE_RULE.falsePositive}{enter}` - ); - - // Enter the author - cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}{enter}`); + // condition field + getConditionRemoveButton(0).click({ force: true }); + toastShouldExist(); + getConditionAddButton().click({ force: true }); - cy.get('[data-test-subj="detection-visual-editor-0"]').within(() => { - cy.getFieldByLabel('Name').type('{selectall}{backspace}').type('Selection_1'); - cy.getFieldByLabel('Key').type('Provider_Name'); - cy.getInputByPlaceholder('Value').type('Service Control Manager'); + // tags field + getTagField(0).clearValue().type('wrong.tag'); + toastShouldExist(); + getTagField(0).clearValue().type('attack.tag'); + }); + }); - cy.getButtonByText('Add map').click(); - cy.get('[data-test-subj="Map-1"]').within(() => { - cy.getFieldByLabel('Key').type('EventID'); - cy.getInputByPlaceholder('Value').type('7045'); - }); + describe('...should validate rules create flow', () => { + beforeEach(() => { + cy.intercept('/rules/_search').as('rulesSearch'); + // Visit Rules page + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/rules`); + cy.wait('@rulesSearch').should('have.property', 'state', 'Complete'); - cy.getButtonByText('Add map').click(); - cy.get('[data-test-subj="Map-2"]').within(() => { - cy.getFieldByLabel('Key').type('ServiceName'); - cy.getInputByPlaceholder('Value').type('ZzNetSvc'); - }); - }); - cy.get('[data-test-subj="rule_detection_field"] textarea').type('Selection_1', { - force: true, + // Check that correct page is showing + cy.waitForPageLoad('rules', { + contains: 'Detection rules', }); + }); - cy.get('[aria-label="Add one more condition"]').click({ force: true }); + it('...can be created', () => { + getCreateButton().click({ force: true }); - // Enter the author - cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}`); + fillCreateForm(); // Switch to YAML editor cy.get('[data-test-subj="change-editor-type"] label:nth-child(2)').click({ @@ -503,10 +510,7 @@ describe('Rules', () => { url: '/rules', }).as('getRules'); - // Click "create" button - cy.get('[data-test-subj="submit_rule_form_button"]').click({ - force: true, - }); + submitRule(); cy.wait('@getRules'); @@ -534,39 +538,30 @@ describe('Rules', () => { cy.get('.euiPopover__panel').find('button').contains('Edit').click(); }); - const ruleNameSelector = '[data-test-subj="rule_name_field"]'; - cy.get(ruleNameSelector).clear(); + getNameField().clear(); SAMPLE_RULE.name += ' edited'; - cy.get(ruleNameSelector).type(SAMPLE_RULE.name); - cy.get(ruleNameSelector).should('have.value', SAMPLE_RULE.name); + getNameField().type(SAMPLE_RULE.name); + getNameField().should('have.value', SAMPLE_RULE.name); - // Enter the log type - const logSelector = '[data-test-subj="rule_type_dropdown"]'; - cy.get(logSelector).within(() => cy.get('.euiFormControlLayoutClearButton').click()); + getLogTypeField().clearCombobox(); SAMPLE_RULE.logType = 'dns'; YAML_RULE_LINES[2] = `product: ${SAMPLE_RULE.logType}`; YAML_RULE_LINES[3] = `title: ${SAMPLE_RULE.name}`; - cy.get(logSelector).type(SAMPLE_RULE.logType).type('{enter}'); - cy.get(logSelector).contains(SAMPLE_RULE.logType, { - matchCase: false, - }); + getLogTypeField().type(SAMPLE_RULE.logType).type('{enter}'); + getLogTypeField().containsValue(SAMPLE_RULE.logType).contains(SAMPLE_RULE.logType); - const ruleDescriptionSelector = '[data-test-subj="rule_description_field"]'; SAMPLE_RULE.description += ' edited'; YAML_RULE_LINES[4] = `description: ${SAMPLE_RULE.description}`; - cy.get(ruleDescriptionSelector).clear(); - cy.get(ruleDescriptionSelector).type(SAMPLE_RULE.description); - cy.get(ruleDescriptionSelector).should('have.value', SAMPLE_RULE.description); + getDescriptionField().clear(); + getDescriptionField().type(SAMPLE_RULE.description); + getDescriptionField().should('have.value', SAMPLE_RULE.description); cy.intercept({ url: '/rules', }).as('getRules'); - // Click "create" button - cy.get('[data-test-subj="submit_rule_form_button"]').click({ - force: true, - }); + submitRule(); cy.waitForPageLoad('rules', { contains: 'Detection rules', diff --git a/cypress/support/helpers.js b/cypress/support/helpers.js index 34a5a6081..c3b990892 100644 --- a/cypress/support/helpers.js +++ b/cypress/support/helpers.js @@ -4,7 +4,7 @@ */ import sample_detector from '../fixtures/integration_tests/detector/create_usb_detector_data.json'; -import { NODE_API, OPENSEARCH_DASHBOARDS_URL } from './constants'; +import { OPENSEARCH_DASHBOARDS_URL } from './constants'; import _ from 'lodash'; Cypress.Commands.add('getElementByText', (locator, text) => { @@ -75,19 +75,70 @@ Cypress.Commands.add( Cypress.log({ message: `Clear combobox` }); return cy .wrap(subject) - .parents('.euiComboBox__inputWrap') - .find('.euiBadge') - .then(($badge) => { - let numberOfBadges = $badge.length; - Cypress.log({ - message: `Number of combo badges to clear: ${numberOfBadges}`, - }); - - _.times(cy.wrap(subject).type('{backspace}'), numberOfBadges); - }); + .parents('.euiFormRow__fieldWrapper') + .find('[data-test-subj="comboBoxClearButton"]') + .click({ force: true }); } ); +Cypress.Commands.add( + 'containsValue', + { + prevSubject: true, + }, + (subject, value) => + cy.wrap(subject).parents('.euiFormRow__fieldWrapper').contains(value, { + matchCase: false, + }) +); + +Cypress.Commands.add( + 'clearValue', + { + prevSubject: true, + }, + (subject) => cy.wrap(subject).type('{selectall}').type('{backspace}') +); + +Cypress.Commands.add( + 'containsError', + { + prevSubject: true, + }, + (subject, errorText) => + cy + .wrap(subject) + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains(errorText) +); + +Cypress.Commands.add( + 'containsHelperText', + { + prevSubject: true, + }, + (subject, helperText) => + cy + .wrap(subject) + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormHelpText') + .contains(helperText) +); + +Cypress.Commands.add( + 'shouldNotHaveError', + { + prevSubject: true, + }, + (subject) => + cy + .wrap(subject) + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist') +); + Cypress.Commands.add('validateDetailsItem', (label, value) => { Cypress.log({ message: `Validate details item by label: ${label} and value: ${value}` }); return cy.getElementByText('.euiFlexItem label', label).parent().siblings().contains(value); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 2ffaed7e5..1225c9c59 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -99,6 +99,42 @@ declare namespace Cypress { */ validateDetailsItem(label: string, value: string): Chainable; + /** + * Should clear a field value (use with text and textarea fields) + * @example + * cy.getFieldByLabel('Rule name').clearValue() + */ + clearValue(): Chainable; + + /** + * Validates that field contains value + * Should be used with combobox or other fields that don't print its value in inputs + * @example + * cy.getFieldByLabel('Rule name').containsValue('Name') + */ + containsValue(value: string): Chainable; + + /** + * Validates that field has error text + * @example + * cy.getFieldByLabel('Rule name').containsError('This fields is invalid') + */ + containsError(errorText: string): Chainable; + + /** + * Validates that field has helper text + * @example + * cy.getFieldByLabel('Rule name').containsHelperText('Use this field for...') + */ + containsHelperText(helperText: string): Chainable; + + /** + * Should not have error text + * @example + * cy.getFieldByLabel('Rule name').shouldNotHaveError() + */ + shouldNotHaveError(): Chainable; + /** * Validates url path * @example diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index fb453ca47..6c64db743 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -340,7 +340,7 @@ export const RuleEditorForm: React.FC = ({ selectedOptions={ props.values.status ? [{ value: props.values.status, label: props.values.status }] - : [{ value: ruleStatus[0], label: ruleStatus[0] }] + : [] } /> @@ -492,7 +492,7 @@ export const RuleEditorForm: React.FC = ({ data-test-subj={'submit_rule_form_button'} fill > - {mode === 'create' ? 'Create' : 'Save changes'} + {mode === 'create' ? 'Create detection rule' : 'Save changes'} diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx index 6924578e6..1ec6188be 100644 --- a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -174,11 +174,7 @@ export const SelectionExpField: React.FC = ({ 1 - ? 'selection-exp-field-item-with-remove' - : 'selection-exp-field-item' - } + className={'selection-exp-field-item-with-remove'} > = ({ > {renderOptions(exp, idx)} - {usedExpressions.length > 1 ? ( - { - const usedExp = _.cloneDeep(usedExpressions); - usedExp.splice(idx, 1); - usedExp[0].description = ''; - setUsedExpressions([...usedExp]); - onChange(getValue(usedExp)); - }} - color={'danger'} - iconType="cross" - aria-label={'Remove condition'} - /> - ) : null} + { + const usedExp = _.cloneDeep(usedExpressions); + usedExp.splice(idx, 1); + usedExp.length && (usedExp[0].description = ''); + setUsedExpressions([...usedExp]); + onChange(getValue(usedExp)); + }} + color={'danger'} + iconType="cross" + aria-label={'Remove condition'} + /> ))} {selections.length > usedExpressions.length && ( diff --git a/yarn.lock b/yarn.lock index 16ea9f2f1..9ee0798fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -672,6 +672,90 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@node-rs/xxhash-android-arm-eabi@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-android-arm-eabi/-/xxhash-android-arm-eabi-1.4.0.tgz#55ace4d3882686d1e379aaf613e1338d78f13fc8" + integrity sha512-JuZNqt5/znWkIGteikQdS+HT9S0JsMYi06S4yzU/sMKLCIPvD0MnCTXlYtuDcgRIKScCaepAsSQVomnAyLFNNA== + +"@node-rs/xxhash-android-arm64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-android-arm64/-/xxhash-android-arm64-1.4.0.tgz#2290c53ceabda804afb4c45679613d833a6385a0" + integrity sha512-BZzQO5jlgsIr9HhiqTwZjYqlfVeZiu+7PaoAdNEOq+i/SjyAqv1jGSkyek4rBSAiodyNkXcbE0eQtomeN6a55w== + +"@node-rs/xxhash-darwin-arm64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-darwin-arm64/-/xxhash-darwin-arm64-1.4.0.tgz#96df4f48b13deb6899e84ed0882bdbd0a4856f13" + integrity sha512-JlEAzTsQaqJaWVse/JP//6QKBIhzqzTlvNY4uEbi8TaZMfvDDhW//ClXM6CkSV799GJxAYPu1LXa4+OeBQpa7Q== + +"@node-rs/xxhash-darwin-x64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-darwin-x64/-/xxhash-darwin-x64-1.4.0.tgz#9df3ca3a87354dd5386aadfa20ad032a299c2b8f" + integrity sha512-9ycVJfzLvw1wc6Tgq0giLkMn5nGOBawTeOA17t27dQFdY/scZPz583DO7w+eznMnlzUXwoLiloanUebRhy+piQ== + +"@node-rs/xxhash-freebsd-x64@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-freebsd-x64/-/xxhash-freebsd-x64-1.4.0.tgz#24b0c0bfd33429303688b4af78f9d323daa0fb5b" + integrity sha512-vFRDr6qA0gHWQDjuSxXcdzM4Ppk+5VebEhc76zkWrRVc6RG60fxLo5B4j6QwMwXGTYaG8HMv/nQhAgbnOCWWxQ== + +"@node-rs/xxhash-linux-arm-gnueabihf@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-linux-arm-gnueabihf/-/xxhash-linux-arm-gnueabihf-1.4.0.tgz#4c09f70cd39429fb1a52f3567085e949603d4817" + integrity sha512-0KS6y1caqbtPanos9XNMekWpozCHA6QSlQzaZyn9Hn+Z+mYpR5+NoWixefhp06jt59qF9+LkkF3C9fSEHYmq/w== + +"@node-rs/xxhash-linux-arm64-gnu@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-linux-arm64-gnu/-/xxhash-linux-arm64-gnu-1.4.0.tgz#e92d7026614506fb4db309977127fd8589fabd7c" + integrity sha512-QI97JK2qiQhVgRtUBMgA1ZjPLpwnz11SE2Mw1jryejmyH9EXKKiCyt2FweO6MVP7bEuMxcdajBho4pEL7s/QsA== + +"@node-rs/xxhash-linux-arm64-musl@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-linux-arm64-musl/-/xxhash-linux-arm64-musl-1.4.0.tgz#a8b16233a86c116e6af32a69278248d17b2d09e7" + integrity sha512-dtMid4OMkNBYGJkjoT1jdkENpV8m8MGp3lliDN8C+2znZUQM8KFRTXRkfaq4lgzu3Y2XeYzsLOoBsBd3Hgf7gA== + +"@node-rs/xxhash-linux-x64-gnu@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-linux-x64-gnu/-/xxhash-linux-x64-gnu-1.4.0.tgz#385ec91396ebaa2b73abf419be3971ec893dcbd1" + integrity sha512-OeOQL10cG62wL1IVoeC74xESmefHU7r3xiZMTP2hK5Dh3FdF2sa3x/Db9BcGXlaokg/lMGDxuTuzOLC2Rv/wlQ== + +"@node-rs/xxhash-linux-x64-musl@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-linux-x64-musl/-/xxhash-linux-x64-musl-1.4.0.tgz#715bb962502b0ec69e1fc19db22ac035c63d30c7" + integrity sha512-kZ8wNi5bH9b+ZpuPlSbFd6JXk8CKbfCvCPZ0Vk0IqLkzB6PihQflnZPM9r0QZ2jtFgyfWmpbFK4YxwX9YcyLog== + +"@node-rs/xxhash-win32-arm64-msvc@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-win32-arm64-msvc/-/xxhash-win32-arm64-msvc-1.4.0.tgz#4a3a4ebcb50c73e4309e429b28eb44dbf8f7f71f" + integrity sha512-Ggv66jlhQvj4XgQqNgl2JKQ7My/97PvPZi5jKbcS7t65wJC36J6XERQwRPdupO8UH63XfPqb7HJqrgmiz8tmlA== + +"@node-rs/xxhash-win32-ia32-msvc@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-win32-ia32-msvc/-/xxhash-win32-ia32-msvc-1.4.0.tgz#fdfdb43e41113a8baf15779ca53bb637d2e1bc8f" + integrity sha512-mYpF1+7unqKKGsPn7Y8X6SqP2Bc5BU5dsHBKhAGAuvrMg9W63zM+YWM8/fpNGfFlOrjiKRvXHZ96nrZyzoxeBw== + +"@node-rs/xxhash-win32-x64-msvc@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash-win32-x64-msvc/-/xxhash-win32-x64-msvc-1.4.0.tgz#aee714a4ae0121f3947f94139adf13f5b6d93d12" + integrity sha512-rKuqWHuQNlrfjIOkQW3oCBta/GUlyVoUkKB13aVr8uixOs/eneuDaYJx2h02FAAWlWCKADFnMxgDl0LVFBy53w== + +"@node-rs/xxhash@^1.3.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@node-rs/xxhash/-/xxhash-1.4.0.tgz#1e75850e0e530c9224e8e5ba4056d52e8868291b" + integrity sha512-UpSOParhMqbQ7hsYovN2e+uqvWqHJiCDvFl8gDzMcXgBY/PkI2zo2zhdRAZdz48c6/dke+0WjCKy90wDVQxS6g== + optionalDependencies: + "@node-rs/xxhash-android-arm-eabi" "1.4.0" + "@node-rs/xxhash-android-arm64" "1.4.0" + "@node-rs/xxhash-darwin-arm64" "1.4.0" + "@node-rs/xxhash-darwin-x64" "1.4.0" + "@node-rs/xxhash-freebsd-x64" "1.4.0" + "@node-rs/xxhash-linux-arm-gnueabihf" "1.4.0" + "@node-rs/xxhash-linux-arm64-gnu" "1.4.0" + "@node-rs/xxhash-linux-arm64-musl" "1.4.0" + "@node-rs/xxhash-linux-x64-gnu" "1.4.0" + "@node-rs/xxhash-linux-x64-musl" "1.4.0" + "@node-rs/xxhash-win32-arm64-msvc" "1.4.0" + "@node-rs/xxhash-win32-ia32-msvc" "1.4.0" + "@node-rs/xxhash-win32-x64-msvc" "1.4.0" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.1" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" @@ -4142,7 +4226,7 @@ json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== -json5@^1.0.1, json5@^2.2.2, json5@^2.2.3: +json5@^1.0.1, json5@^2.1.2, json5@^2.2.2, json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -4283,7 +4367,7 @@ loader-runner@^2.4.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== -loader-utils@^1.0.2, loader-utils@^1.2.3: +loader-utils@^1.0.2: version "1.4.2" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3" integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg== @@ -4292,6 +4376,15 @@ loader-utils@^1.0.2, loader-utils@^1.2.3: emojis-list "^3.0.0" json5 "^1.0.1" +loader-utils@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" @@ -5946,11 +6039,12 @@ terminal-link@^2.0.0: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" -terser-webpack-plugin@^1.4.3: - version "1.4.5" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b" - integrity sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw== +"terser-webpack-plugin@npm:@amoo-miki/terser-webpack-plugin@1.4.5-rc.2": + version "1.4.5-rc.2" + resolved "https://registry.yarnpkg.com/@amoo-miki/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5-rc.2.tgz#046c062ef22c126c2544718674bc6624e3651b9c" + integrity sha512-JFSGSzsWgSHEqQXlnHDh3gw+jdVdVlWM2Irdps9P/yWYNY/5VjuG8sdoW4mbuP8/HM893/k8N+ipbeqsd8/xpA== dependencies: + "@node-rs/xxhash" "^1.3.0" cacache "^12.0.2" find-cache-dir "^2.1.0" is-wsl "^1.1.0" @@ -6385,11 +6479,12 @@ webpack-sources@^1.4.0, webpack-sources@^1.4.1: source-list-map "^2.0.0" source-map "~0.6.1" -webpack@^4.41.5: - version "4.46.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542" - integrity sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q== +"webpack@npm:@amoo-miki/webpack@4.46.0-rc.2": + version "4.46.0-rc.2" + resolved "https://registry.yarnpkg.com/@amoo-miki/webpack/-/webpack-4.46.0-rc.2.tgz#36824597c14557a7bb0a8e13203e30275e7b02bd" + integrity sha512-Y/ZqxTHOoDF1kz3SR63Y9SZGTDUpZNNFrisTRHofWhP8QvNX3LMN+TCmEP56UfLaiLVKMcaiFjx8kFb2TgyBaQ== dependencies: + "@node-rs/xxhash" "^1.3.0" "@webassemblyjs/ast" "1.9.0" "@webassemblyjs/helper-module-context" "1.9.0" "@webassemblyjs/wasm-edit" "1.9.0" @@ -6402,7 +6497,7 @@ webpack@^4.41.5: eslint-scope "^4.0.3" json-parse-better-errors "^1.0.2" loader-runner "^2.4.0" - loader-utils "^1.2.3" + loader-utils "^2.0.4" memory-fs "^0.4.1" micromatch "^3.1.10" mkdirp "^0.5.3" @@ -6410,7 +6505,7 @@ webpack@^4.41.5: node-libs-browser "^2.2.1" schema-utils "^1.0.0" tapable "^1.1.3" - terser-webpack-plugin "^1.4.3" + terser-webpack-plugin "npm:@amoo-miki/terser-webpack-plugin@1.4.5-rc.2" watchpack "^1.7.4" webpack-sources "^1.4.1" From 07a2d6f98ab0f7721ea85eedbbd93c174083877b Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Wed, 7 Jun 2023 12:49:13 +0200 Subject: [PATCH 16/22] Cypress cases for detectors and rules, validate forms and fields Signed-off-by: Jovan Cvetkovic --- .../components/SelectionExpField.test.tsx | 16 +++ .../components/SelectionExpField.tsx | 2 +- .../SelectionExpField.test.tsx.snap | 122 ++++++++++++++++++ .../components/SelectionExpField.mock.ts | 13 ++ test/mocks/Rules/index.ts | 2 + 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 public/pages/Rules/components/RuleEditor/components/SelectionExpField.test.tsx create mode 100644 public/pages/Rules/components/RuleEditor/components/__snapshots__/SelectionExpField.test.tsx.snap create mode 100644 test/mocks/Rules/components/RuleEditor/components/SelectionExpField.mock.ts diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.test.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.test.tsx new file mode 100644 index 000000000..07948c0f5 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { SelectionExpField } from './SelectionExpField'; +import SelectionExpFieldMock from '../../../../../../test/mocks/Rules/components/RuleEditor/components/SelectionExpField.mock'; + +describe(' spec', () => { + it('renders the component', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx index 1ec6188be..691f3548d 100644 --- a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -11,7 +11,7 @@ import { import * as _ from 'lodash'; import { Selection } from '../DetectionVisualEditor'; -interface SelectionExpFieldProps { +export interface SelectionExpFieldProps { selections: Selection[]; dataTestSubj: string; onChange: (value: string) => void; diff --git a/public/pages/Rules/components/RuleEditor/components/__snapshots__/SelectionExpField.test.tsx.snap b/public/pages/Rules/components/RuleEditor/components/__snapshots__/SelectionExpField.test.tsx.snap new file mode 100644 index 000000000..2aad8c8a6 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/components/__snapshots__/SelectionExpField.test.tsx.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+
+ + + Select + + + + +
+
+
+
+
+ , + "container":
+
+
+
+
+ + + Select + + + + +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/test/mocks/Rules/components/RuleEditor/components/SelectionExpField.mock.ts b/test/mocks/Rules/components/RuleEditor/components/SelectionExpField.mock.ts new file mode 100644 index 000000000..61faf0a6a --- /dev/null +++ b/test/mocks/Rules/components/RuleEditor/components/SelectionExpField.mock.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SelectionExpFieldProps } from '../../../../../../public/pages/Rules/components/RuleEditor/components/SelectionExpField'; + +export default { + selections: [], + dataTestSubj: 'data-test-subject-selector', + onChange: () => {}, + value: '', +} as SelectionExpFieldProps; diff --git a/test/mocks/Rules/index.ts b/test/mocks/Rules/index.ts index b9c0565e2..ff77222b9 100644 --- a/test/mocks/Rules/index.ts +++ b/test/mocks/Rules/index.ts @@ -5,8 +5,10 @@ import RuleOptions from './RuleOptions.mock'; import RulePage from './RulePage.mock'; +import SelectionExpField from './components/RuleEditor/components/SelectionExpField.mock'; export default { RuleOptions, RulePage, + SelectionExpField, }; From f56ff33b680d324884774fb7275cb625b8ae0f20 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Wed, 7 Jun 2023 19:24:16 +0200 Subject: [PATCH 17/22] Cypress cases for detectors and rules, validate forms and fields Signed-off-by: Jovan Cvetkovic --- .../RuleEditor/DetectionVisualEditor.test.tsx | 16 ++++++++++++++++ .../RuleEditor/DetectionVisualEditor.mock.ts | 14 ++++++++++++++ test/mocks/Rules/index.ts | 2 ++ 3 files changed, 32 insertions(+) create mode 100644 public/pages/Rules/components/RuleEditor/DetectionVisualEditor.test.tsx create mode 100644 test/mocks/Rules/components/RuleEditor/DetectionVisualEditor.mock.ts diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.test.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.test.tsx new file mode 100644 index 000000000..f48d79979 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { DetectionVisualEditor } from './DetectionVisualEditor'; +import DetectionVisualEditorMock from '../../../../../test/mocks/Rules/components/RuleEditor/DetectionVisualEditor.mock'; + +describe(' spec', () => { + it('renders the component', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/test/mocks/Rules/components/RuleEditor/DetectionVisualEditor.mock.ts b/test/mocks/Rules/components/RuleEditor/DetectionVisualEditor.mock.ts new file mode 100644 index 000000000..74974f865 --- /dev/null +++ b/test/mocks/Rules/components/RuleEditor/DetectionVisualEditor.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DetectionVisualEditorProps } from '../../../../../public/pages/Rules/components/RuleEditor/DetectionVisualEditor'; + +export default { + detectionYml: '', + onChange: () => {}, + setIsDetectionInvalid: () => {}, + mode: 'create', + isInvalid: false, +} as DetectionVisualEditorProps; diff --git a/test/mocks/Rules/index.ts b/test/mocks/Rules/index.ts index ff77222b9..803f68f17 100644 --- a/test/mocks/Rules/index.ts +++ b/test/mocks/Rules/index.ts @@ -6,9 +6,11 @@ import RuleOptions from './RuleOptions.mock'; import RulePage from './RulePage.mock'; import SelectionExpField from './components/RuleEditor/components/SelectionExpField.mock'; +import DetectionVisualEditor from './components/RuleEditor/DetectionVisualEditor.mock'; export default { RuleOptions, RulePage, SelectionExpField, + DetectionVisualEditor, }; From 17109d774624a0451b26b69aad09c2c903696cac Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Wed, 7 Jun 2023 19:25:48 +0200 Subject: [PATCH 18/22] Cypress cases for detectors and rules, validate forms and fields Signed-off-by: Jovan Cvetkovic --- .../DetectionVisualEditor.test.tsx.snap | 938 ++++++++++++++++++ 1 file changed, 938 insertions(+) create mode 100644 public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap diff --git a/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap b/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap new file mode 100644 index 000000000..09c8599d6 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap @@ -0,0 +1,938 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+

+ Define the search identifier in your data the rule will be applied to. +

+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+ + + Select + + + + +
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+

+ Define the search identifier in your data the rule will be applied to. +

+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+ + + Select + + + + +
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; From bf34509bed9deaed963c428c5dddffea13d2f9e5 Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Thu, 8 Jun 2023 08:44:50 +0200 Subject: [PATCH 19/22] Cypress cases for detectors and rules, validate forms and fields Signed-off-by: Jovan Cvetkovic --- cypress/integration/1_detectors.spec.js | 2 +- cypress/integration/2_rules.spec.js | 4 +- public/app.scss | 8 ++++ .../RuleEditor/DetectionVisualEditor.tsx | 10 ++++- .../components/RuleEditor/RuleEditorForm.tsx | 1 + .../DetectionVisualEditor.test.tsx.snap | 42 ++++++++++++++++++- public/pages/Rules/containers/Rules/Rules.tsx | 5 +-- 7 files changed, 62 insertions(+), 10 deletions(-) diff --git a/cypress/integration/1_detectors.spec.js b/cypress/integration/1_detectors.spec.js index 584d9297e..f9e111f81 100644 --- a/cypress/integration/1_detectors.spec.js +++ b/cypress/integration/1_detectors.spec.js @@ -402,7 +402,7 @@ describe('Detectors', () => { }); }); - describe('...validate create detector', () => { + describe('...validate create detector flow', () => { beforeEach(() => { cy.intercept('/detectors/_search').as('detectorsSearch'); diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index 8f3abec0d..fde8195c6 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -186,7 +186,7 @@ const fillCreateForm = () => { describe('Rules', () => { before(() => cy.cleanUpTests()); - describe('...should validate rules form fields', () => { + describe('...should validate form fields', () => { beforeEach(() => { cy.intercept('/rules/_search').as('rulesSearch'); // Visit Rules page @@ -479,7 +479,7 @@ describe('Rules', () => { }); }); - describe('...should validate rules create flow', () => { + describe('...should validate create rule flow', () => { beforeEach(() => { cy.intercept('/rules/_search').as('rulesSearch'); // Visit Rules page diff --git a/public/app.scss b/public/app.scss index db4370d1d..d721e5522 100644 --- a/public/app.scss +++ b/public/app.scss @@ -136,3 +136,11 @@ $euiTextColor: $euiColorDarkestShade !default; .detailsFormRow { width: auto !important; } + +.empty-text-button { + vertical-align: baseline; + + .euiButtonEmpty__content { + padding: 0 !important; + } +} diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index 8c5d302f1..775f22bce 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -37,6 +37,7 @@ import { SelectionExpField } from './components/SelectionExpField'; export interface DetectionVisualEditorProps { detectionYml: string; onChange: (value: string) => void; + goToYamlEditor: (value: string) => void; setIsDetectionInvalid: (isInvalid: boolean) => void; mode?: string; isInvalid?: boolean; @@ -766,7 +767,14 @@ export class DetectionVisualEditor extends React.Component< Define how each selection should be included in the final query. For more options - use YAML editor. + use{' '} + this.props.goToYamlEditor('yaml')} + > + YAML editor + + . } diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index 6c64db743..61a5da923 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -361,6 +361,7 @@ export const RuleEditorForm: React.FC = ({ { if (isInvalid) { props.errors.detection = 'Invalid detection entries'; diff --git a/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap b/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap index 09c8599d6..9181b98b0 100644 --- a/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap +++ b/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap @@ -391,7 +391,26 @@ Object {
- Define how each selection should be included in the final query. For more options use YAML editor. + Define how each selection should be included in the final query. For more options use + + .
@@ -831,7 +850,26 @@ Object {
- Define how each selection should be included in the final query. For more options use YAML editor. + Define how each selection should be included in the final query. For more options use + + .
diff --git a/public/pages/Rules/containers/Rules/Rules.tsx b/public/pages/Rules/containers/Rules/Rules.tsx index a5f3af2fe..e5ee1ca67 100644 --- a/public/pages/Rules/containers/Rules/Rules.tsx +++ b/public/pages/Rules/containers/Rules/Rules.tsx @@ -4,10 +4,8 @@ */ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { ServicesContext } from '../../../../services'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { BrowserServices } from '../../../../models/interfaces'; import { RulesTable } from '../../components/RulesTable/RulesTable'; import { RuleTableItem } from '../../utils/helpers'; import { RuleViewerFlyout } from '../../components/RuleViewerFlyout/RuleViewerFlyout'; @@ -22,7 +20,6 @@ export interface RulesProps extends RouteComponentProps { } export const Rules: React.FC = (props) => { - const services = useContext(ServicesContext) as BrowserServices; const context = useContext(CoreServicesContext); const [allRules, setAllRules] = useState([]); @@ -64,7 +61,7 @@ export const Rules: React.FC = (props) => { const headerActions = useMemo( () => [ - Import rule + Import detection rule , Create detection rule From 05f0967fa6369a038c6403e63ff9843b361cbafe Mon Sep 17 00:00:00 2001 From: Jovan Cvetkovic Date: Thu, 8 Jun 2023 08:54:44 +0200 Subject: [PATCH 20/22] Cypress cases for detectors and rules, validate forms and fields Signed-off-by: Jovan Cvetkovic --- .../__snapshots__/DetectionVisualEditor.test.tsx.snap | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap b/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap index 9181b98b0..afcdacc96 100644 --- a/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap +++ b/public/pages/Rules/components/RuleEditor/__snapshots__/DetectionVisualEditor.test.tsx.snap @@ -391,7 +391,8 @@ Object {
- Define how each selection should be included in the final query. For more options use + Define how each selection should be included in the final query. For more options use + +
+
+
+ Delete Delete rule? +
+
+
+
+
+

+ Delete the rule permanently? This action cannot be undone. +

+
+
+
+
+ + +
+
+
+
+ + , + "container":