- {replaceTokens(firingState.state.ui.message)}
+ {replaceTokens(alertState.state.ui.message)}
{nextStepsUi ?
: null}
{nextStepsUi}
diff --git a/x-pack/plugins/monitoring/public/alerts/status.tsx b/x-pack/plugins/monitoring/public/alerts/status.tsx
index 9c262884d7257..0407ddfecf5e9 100644
--- a/x-pack/plugins/monitoring/public/alerts/status.tsx
+++ b/x-pack/plugins/monitoring/public/alerts/status.tsx
@@ -11,14 +11,17 @@ import { CommonAlertStatus } from '../../common/types';
import { AlertSeverity } from '../../common/enums';
import { AlertState } from '../../server/alerts/types';
import { AlertsBadge } from './badge';
+import { isInSetupMode } from '../lib/setup_mode';
interface Props {
alerts: { [alertTypeId: string]: CommonAlertStatus };
showBadge: boolean;
showOnlyCount: boolean;
+ stateFilter: (state: AlertState) => boolean;
}
export const AlertsStatus: React.FC
= (props: Props) => {
- const { alerts, showBadge = false, showOnlyCount = false } = props;
+ const { alerts, showBadge = false, showOnlyCount = false, stateFilter = () => true } = props;
+ const inSetupMode = isInSetupMode();
if (!alerts) {
return null;
@@ -26,21 +29,26 @@ export const AlertsStatus: React.FC = (props: Props) => {
let atLeastOneDanger = false;
const count = Object.values(alerts).reduce((cnt, alertStatus) => {
- if (alertStatus.states.length) {
+ const firingStates = alertStatus.states.filter((state) => state.firing);
+ const firingAndFilterStates = firingStates.filter((state) => stateFilter(state.state));
+ cnt += firingAndFilterStates.length;
+ if (firingStates.length) {
if (!atLeastOneDanger) {
for (const state of alertStatus.states) {
- if ((state.state as AlertState).ui.severity === AlertSeverity.Danger) {
+ if (
+ stateFilter(state.state) &&
+ (state.state as AlertState).ui.severity === AlertSeverity.Danger
+ ) {
atLeastOneDanger = true;
break;
}
}
}
- cnt++;
}
return cnt;
}, 0);
- if (count === 0) {
+ if (count === 0 && (!inSetupMode || showOnlyCount)) {
return (
= (props: Props) => {
);
}
- if (showBadge) {
- return ;
+ if (showBadge || inSetupMode) {
+ return ;
}
const severity = atLeastOneDanger ? AlertSeverity.Danger : AlertSeverity.Warning;
diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js
index f91e251030d76..ac1a5212a8d26 100644
--- a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js
+++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js
@@ -70,10 +70,14 @@ export const Node = ({
-
+ state.nodeId === nodeId}
+ />
-
+ state.nodeId === nodeId} />
{metricsToShow.map((metric, index) => (
diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js
index 85b4d0daddade..77d0b294f66d0 100644
--- a/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js
+++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js
@@ -11,7 +11,7 @@ import { formatMetric } from '../../../lib/format_number';
import { i18n } from '@kbn/i18n';
import { AlertsStatus } from '../../../alerts/status';
-export function NodeDetailStatus({ stats, alerts = {} }) {
+export function NodeDetailStatus({ stats, alerts = {}, alertsStateFilter = () => true }) {
const {
transport_address: transportAddress,
usedHeap,
@@ -33,7 +33,7 @@ export function NodeDetailStatus({ stats, alerts = {} }) {
label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.alerts', {
defaultMessage: 'Alerts',
}),
- value: ,
+ value: ,
},
{
label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.transportAddress', {
diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js
index c2e5c8e22a1c0..b7463fe6532b7 100644
--- a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js
+++ b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js
@@ -131,8 +131,14 @@ const getColumns = (showCgroupMetricsElasticsearch, setupMode, clusterUuid, aler
field: 'alerts',
width: '175px',
sortable: true,
- render: () => {
- return ;
+ render: (_field, node) => {
+ return (
+ state.nodeId === node.resolver}
+ />
+ );
},
});
diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts
index 1a66560ae124a..2596252c92d11 100644
--- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts
+++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts
@@ -372,5 +372,197 @@ describe('CpuUsageAlert', () => {
state: 'firing',
});
});
+
+ it('should show proper counts for resolved and firing nodes', async () => {
+ (fetchCpuUsageNodeStats as jest.Mock).mockImplementation(() => {
+ return [
+ {
+ ...stat,
+ cpuUsage: 1,
+ },
+ {
+ ...stat,
+ nodeId: 'anotherNode',
+ nodeName: 'anotherNode',
+ cpuUsage: 99,
+ },
+ ];
+ });
+ (getState as jest.Mock).mockImplementation(() => {
+ return {
+ alertStates: [
+ {
+ cluster: {
+ clusterUuid,
+ clusterName,
+ },
+ ccs: null,
+ cpuUsage: 91,
+ nodeId,
+ nodeName,
+ ui: {
+ isFiring: true,
+ message: null,
+ severity: 'danger',
+ resolvedMS: 0,
+ triggeredMS: 1,
+ lastCheckedMS: 0,
+ },
+ },
+ {
+ cluster: {
+ clusterUuid,
+ clusterName,
+ },
+ ccs: null,
+ cpuUsage: 100,
+ nodeId: 'anotherNode',
+ nodeName: 'anotherNode',
+ ui: {
+ isFiring: true,
+ message: null,
+ severity: 'danger',
+ resolvedMS: 0,
+ triggeredMS: 1,
+ lastCheckedMS: 0,
+ },
+ },
+ ],
+ };
+ });
+ const alert = new CpuUsageAlert();
+ alert.initializeAlertType(
+ getUiSettingsService as any,
+ monitoringCluster as any,
+ getLogger as any,
+ config as any,
+ kibanaUrl
+ );
+ const type = alert.getAlertType();
+ await type.executor({
+ ...executorOptions,
+ // @ts-ignore
+ params: alert.defaultParams,
+ } as any);
+ const count = 1;
+ expect(replaceState).toHaveBeenCalledWith({
+ alertStates: [
+ {
+ cluster: { clusterUuid, clusterName },
+ ccs: null,
+ cpuUsage: 1,
+ nodeId,
+ nodeName,
+ ui: {
+ isFiring: false,
+ message: {
+ text:
+ 'The cpu usage on node myNodeName is now under the threshold, currently reporting at 1.00% as of #resolved',
+ tokens: [
+ {
+ startToken: '#resolved',
+ type: 'time',
+ isAbsolute: true,
+ isRelative: false,
+ timestamp: 1,
+ },
+ ],
+ },
+ severity: 'danger',
+ resolvedMS: 1,
+ triggeredMS: 1,
+ lastCheckedMS: 0,
+ },
+ },
+ {
+ ccs: null,
+ cluster: { clusterUuid, clusterName },
+ cpuUsage: 99,
+ nodeId: 'anotherNode',
+ nodeName: 'anotherNode',
+ ui: {
+ isFiring: true,
+ message: {
+ text:
+ 'Node #start_linkanotherNode#end_link is reporting cpu usage of 99.00% at #absolute',
+ nextSteps: [
+ {
+ text: '#start_linkCheck hot threads#end_link',
+ tokens: [
+ {
+ startToken: '#start_link',
+ endToken: '#end_link',
+ type: 'docLink',
+ partialUrl:
+ '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html',
+ },
+ ],
+ },
+ {
+ text: '#start_linkCheck long running tasks#end_link',
+ tokens: [
+ {
+ startToken: '#start_link',
+ endToken: '#end_link',
+ type: 'docLink',
+ partialUrl:
+ '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html',
+ },
+ ],
+ },
+ ],
+ tokens: [
+ {
+ startToken: '#absolute',
+ type: 'time',
+ isAbsolute: true,
+ isRelative: false,
+ timestamp: 1,
+ },
+ {
+ startToken: '#start_link',
+ endToken: '#end_link',
+ type: 'link',
+ url: 'elasticsearch/nodes/anotherNode',
+ },
+ ],
+ },
+ severity: 'danger',
+ resolvedMS: 0,
+ triggeredMS: 1,
+ lastCheckedMS: 0,
+ },
+ },
+ ],
+ });
+ expect(scheduleActions).toHaveBeenCalledTimes(1);
+ // expect(scheduleActions.mock.calls[0]).toEqual([
+ // 'default',
+ // {
+ // internalFullMessage: `CPU usage alert is resolved for ${count} node(s) in cluster: ${clusterName}.`,
+ // internalShortMessage: `CPU usage alert is resolved for ${count} node(s) in cluster: ${clusterName}.`,
+ // clusterName,
+ // count,
+ // nodes: `${nodeName}:1.00`,
+ // state: 'resolved',
+ // },
+ // ]);
+ expect(scheduleActions.mock.calls[0]).toEqual([
+ 'default',
+ {
+ action:
+ '[View nodes](http://localhost:5601/app/monitoring#elasticsearch/nodes?_g=(cluster_uuid:abc123))',
+ actionPlain: 'Verify CPU levels across affected nodes.',
+ internalFullMessage:
+ 'CPU usage alert is firing for 1 node(s) in cluster: testCluster. [View nodes](http://localhost:5601/app/monitoring#elasticsearch/nodes?_g=(cluster_uuid:abc123))',
+ internalShortMessage:
+ 'CPU usage alert is firing for 1 node(s) in cluster: testCluster. Verify CPU levels across affected nodes.',
+ nodes: 'anotherNode:99.00',
+ clusterName,
+ count,
+ state: 'firing',
+ },
+ ]);
+ });
});
});
diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts
index b543a4c976377..4742f55487045 100644
--- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts
+++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts
@@ -291,13 +291,6 @@ export class CpuUsageAlert extends BaseAlert {
return;
}
- const nodes = instanceState.alertStates
- .map((_state) => {
- const state = _state as AlertCpuUsageState;
- return `${state.nodeName}:${state.cpuUsage.toFixed(2)}`;
- })
- .join(',');
-
const ccs = instanceState.alertStates.reduce((accum: string, state): string => {
if (state.ccs) {
return state.ccs;
@@ -305,35 +298,16 @@ export class CpuUsageAlert extends BaseAlert {
return accum;
}, '');
- const count = instanceState.alertStates.length;
- if (!instanceState.alertStates[0].ui.isFiring) {
- instance.scheduleActions('default', {
- internalShortMessage: i18n.translate(
- 'xpack.monitoring.alerts.cpuUsage.resolved.internalShortMessage',
- {
- defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`,
- values: {
- count,
- clusterName: cluster.clusterName,
- },
- }
- ),
- internalFullMessage: i18n.translate(
- 'xpack.monitoring.alerts.cpuUsage.resolved.internalFullMessage',
- {
- defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`,
- values: {
- count,
- clusterName: cluster.clusterName,
- },
- }
- ),
- state: RESOLVED,
- nodes,
- count,
- clusterName: cluster.clusterName,
- });
- } else {
+ const firingCount = instanceState.alertStates.filter((alertState) => alertState.ui.isFiring)
+ .length;
+ const firingNodes = instanceState.alertStates
+ .filter((_state) => (_state as AlertCpuUsageState).ui.isFiring)
+ .map((_state) => {
+ const state = _state as AlertCpuUsageState;
+ return `${state.nodeName}:${state.cpuUsage.toFixed(2)}`;
+ })
+ .join(',');
+ if (firingCount > 0) {
const shortActionText = i18n.translate('xpack.monitoring.alerts.cpuUsage.shortAction', {
defaultMessage: 'Verify CPU levels across affected nodes.',
});
@@ -354,7 +328,7 @@ export class CpuUsageAlert extends BaseAlert {
{
defaultMessage: `CPU usage alert is firing for {count} node(s) in cluster: {clusterName}. {shortActionText}`,
values: {
- count,
+ count: firingCount,
clusterName: cluster.clusterName,
shortActionText,
},
@@ -365,19 +339,58 @@ export class CpuUsageAlert extends BaseAlert {
{
defaultMessage: `CPU usage alert is firing for {count} node(s) in cluster: {clusterName}. {action}`,
values: {
- count,
+ count: firingCount,
clusterName: cluster.clusterName,
action,
},
}
),
state: FIRING,
- nodes,
- count,
+ nodes: firingNodes,
+ count: firingCount,
clusterName: cluster.clusterName,
action,
actionPlain: shortActionText,
});
+ } else {
+ const resolvedCount = instanceState.alertStates.filter(
+ (alertState) => !alertState.ui.isFiring
+ ).length;
+ const resolvedNodes = instanceState.alertStates
+ .filter((_state) => !(_state as AlertCpuUsageState).ui.isFiring)
+ .map((_state) => {
+ const state = _state as AlertCpuUsageState;
+ return `${state.nodeName}:${state.cpuUsage.toFixed(2)}`;
+ })
+ .join(',');
+ if (resolvedCount > 0) {
+ instance.scheduleActions('default', {
+ internalShortMessage: i18n.translate(
+ 'xpack.monitoring.alerts.cpuUsage.resolved.internalShortMessage',
+ {
+ defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`,
+ values: {
+ count: resolvedCount,
+ clusterName: cluster.clusterName,
+ },
+ }
+ ),
+ internalFullMessage: i18n.translate(
+ 'xpack.monitoring.alerts.cpuUsage.resolved.internalFullMessage',
+ {
+ defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`,
+ values: {
+ count: resolvedCount,
+ clusterName: cluster.clusterName,
+ },
+ }
+ ),
+ state: RESOLVED,
+ nodes: resolvedNodes,
+ count: resolvedCount,
+ clusterName: cluster.clusterName,
+ });
+ }
}
}
diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts
index 5a09a2f753dc4..c0436603a256a 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts
@@ -6,7 +6,7 @@
export const esArchiverLoadEmptyKibana = () => {
cy.exec(
- `node ../../../scripts/es_archiver empty_kibana load empty--dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env(
+ `node ../../../scripts/es_archiver load empty_kibana --dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env(
'ELASTICSEARCH_URL'
)} --kibana-url ${Cypress.config().baseUrl}`
);
@@ -30,7 +30,7 @@ export const esArchiverUnload = (folder: string) => {
export const esArchiverUnloadEmptyKibana = () => {
cy.exec(
- `node ../../../scripts/es_archiver unload empty_kibana empty--dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env(
+ `node ../../../scripts/es_archiver unload empty_kibana --dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env(
'ELASTICSEARCH_URL'
)} --kibana-url ${Cypress.config().baseUrl}`
);
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx
index 90e195b6e95a0..eca38b9effe1b 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx
@@ -52,20 +52,18 @@ describe('AutocompleteFieldListsComponent', () => {
selectedField={getField('ip')}
selectedValue="some-list-id"
isLoading={false}
- isClearable={false}
- isDisabled={true}
+ isClearable={true}
+ isDisabled
onChange={jest.fn()}
/>
);
- await waitFor(() => {
- expect(
- wrapper
- .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`)
- .prop('disabled')
- ).toBeTruthy();
- });
+ expect(
+ wrapper
+ .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`)
+ .prop('disabled')
+ ).toBeTruthy();
});
test('it renders loading if "isLoading" is true', async () => {
@@ -73,9 +71,9 @@ describe('AutocompleteFieldListsComponent', () => {
({ eui: euiLightVars, darkMode: false })}>
{
);
- await waitFor(() => {
+ wrapper
+ .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`)
+ .at(0)
+ .simulate('click');
+ expect(
wrapper
- .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`)
- .at(0)
- .simulate('click');
- expect(
- wrapper
- .find(
- `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]`
- )
- .prop('isLoading')
- ).toBeTruthy();
- });
+ .find(
+ `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]`
+ )
+ .prop('isLoading')
+ ).toBeTruthy();
});
test('it allows user to clear values if "isClearable" is true', async () => {
@@ -104,9 +100,9 @@ describe('AutocompleteFieldListsComponent', () => {
@@ -114,9 +110,9 @@ describe('AutocompleteFieldListsComponent', () => {
);
expect(
wrapper
- .find(`[data-test-subj="comboBoxInput"]`)
- .hasClass('euiComboBox__inputWrap-isClearable')
- ).toBeTruthy();
+ .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]')
+ .prop('options')
+ ).toEqual([{ label: 'some name' }]);
});
test('it correctly displays lists that match the selected "keyword" field esType', () => {
@@ -210,19 +206,24 @@ describe('AutocompleteFieldListsComponent', () => {
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange([{ label: 'some name' }]);
- expect(mockOnChange).toHaveBeenCalledWith({
- created_at: DATE_NOW,
- created_by: 'some user',
- description: 'some description',
- id: 'some-list-id',
- meta: {},
- name: 'some name',
- tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
- type: 'ip',
- updated_at: DATE_NOW,
- updated_by: 'some user',
- version: VERSION,
- immutable: IMMUTABLE,
+ await waitFor(() => {
+ expect(mockOnChange).toHaveBeenCalledWith({
+ created_at: DATE_NOW,
+ created_by: 'some user',
+ description: 'some description',
+ id: 'some-list-id',
+ meta: {},
+ name: 'some name',
+ tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
+ type: 'ip',
+ updated_at: DATE_NOW,
+ updated_by: 'some user',
+ _version: undefined,
+ version: VERSION,
+ deserializer: undefined,
+ serializer: undefined,
+ immutable: IMMUTABLE,
+ });
});
});
});
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx
index cd90d6eb85623..4349e70594ecb 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx
@@ -9,7 +9,7 @@ import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui';
import { IFieldType } from '../../../../../../../src/plugins/data/common';
import { useFindLists, ListSchema } from '../../../lists_plugin_deps';
import { useKibana } from '../../../common/lib/kibana';
-import { getGenericComboBoxProps } from './helpers';
+import { getGenericComboBoxProps, paramIsValid } from './helpers';
interface AutocompleteFieldListsProps {
placeholder: string;
@@ -75,6 +75,8 @@ export const AutocompleteFieldListsComponent: React.FC setIsTouched(true), [setIsTouched]);
+
useEffect(() => {
if (result != null) {
setLists(result.data);
@@ -91,17 +93,24 @@ export const AutocompleteFieldListsComponent: React.FC paramIsValid(selectedValue, selectedField, isRequired, touched),
+ [selectedField, selectedValue, isRequired, touched]
+ );
+
+ const isLoadingState = useMemo((): boolean => isLoading || loading, [isLoading, loading]);
+
return (
setIsTouched(true)}
+ isInvalid={!isValid}
+ onFocus={setIsTouchedValue}
singleSelection={{ asPlainText: true }}
sortMatchesBy="startsWith"
data-test-subj="valuesAutocompleteComboBox listsComboxBox"
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx
index 992005b3be8bc..137f6803dc54e 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx
@@ -9,7 +9,7 @@ import { uniq } from 'lodash';
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
-import { validateParams, getGenericComboBoxProps } from './helpers';
+import { paramIsValid, getGenericComboBoxProps } from './helpers';
import { OperatorTypeEnum } from '../../../lists_plugin_deps';
import { GetGenericComboBoxPropsReturn } from './types';
import * as i18n from './translations';
@@ -82,16 +82,28 @@ export const AutocompleteFieldMatchComponent: React.FC validateParams(selectedValue, selectedField), [
- selectedField,
- selectedValue,
+ const isValid = useMemo(
+ (): boolean => paramIsValid(selectedValue, selectedField, isRequired, touched),
+ [selectedField, selectedValue, isRequired, touched]
+ );
+
+ const setIsTouchedValue = useCallback((): void => setIsTouched(true), [setIsTouched]);
+
+ const inputPlaceholder = useMemo(
+ (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder),
+ [isLoading, isLoadingSuggestions, placeholder]
+ );
+
+ const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [
+ isLoading,
+ isLoadingSuggestions,
]);
return (
setIsTouched(true)}
+ isInvalid={!isValid}
+ onFocus={setIsTouchedValue}
sortMatchesBy="startsWith"
data-test-subj="valuesAutocompleteComboBox matchComboxBox"
style={fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}}
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx
index 27807a752c141..5a15c1f7238de 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx
@@ -9,7 +9,7 @@ import { uniq } from 'lodash';
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
-import { getGenericComboBoxProps, validateParams } from './helpers';
+import { getGenericComboBoxProps, paramIsValid } from './helpers';
import { OperatorTypeEnum } from '../../../lists_plugin_deps';
import { GetGenericComboBoxPropsReturn } from './types';
import * as i18n from './translations';
@@ -78,16 +78,29 @@ export const AutocompleteFieldMatchAnyComponent: React.FC onChange([...(selectedValue || []), option]);
const isValid = useMemo((): boolean => {
- const areAnyInvalid = selectedComboOptions.filter(
- ({ label }) => !validateParams(label, selectedField)
- );
- return areAnyInvalid.length === 0;
- }, [selectedComboOptions, selectedField]);
+ const areAnyInvalid =
+ selectedComboOptions.filter(
+ ({ label }) => !paramIsValid(label, selectedField, isRequired, touched)
+ ).length > 0;
+ return !areAnyInvalid;
+ }, [selectedComboOptions, selectedField, isRequired, touched]);
+
+ const setIsTouchedValue = useCallback((): void => setIsTouched(true), [setIsTouched]);
+
+ const inputPlaceholder = useMemo(
+ (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder),
+ [isLoading, isLoadingSuggestions, placeholder]
+ );
+
+ const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [
+ isLoading,
+ isLoadingSuggestions,
+ ]);
return (
setIsTouched(true)}
+ isInvalid={!isValid}
+ onFocus={setIsTouchedValue}
delimiter=", "
data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"
fullWidth
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts
index b25bb245c6792..289cdd5e87c00 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts
@@ -14,7 +14,7 @@ import {
existsOperator,
doesNotExistOperator,
} from './operators';
-import { getOperators, validateParams, getGenericComboBoxProps } from './helpers';
+import { getOperators, paramIsValid, getGenericComboBoxProps } from './helpers';
describe('helpers', () => {
describe('#getOperators', () => {
@@ -53,27 +53,67 @@ describe('helpers', () => {
});
});
- describe('#validateParams', () => {
- test('returns false if value is undefined', () => {
- const isValid = validateParams(undefined, getField('@timestamp'));
+ describe('#paramIsValid', () => {
+ test('returns false if value is undefined and "isRequired" nad "touched" are true', () => {
+ const isValid = paramIsValid(undefined, getField('@timestamp'), true, true);
expect(isValid).toBeFalsy();
});
- test('returns false if value is empty string', () => {
- const isValid = validateParams('', getField('@timestamp'));
+ test('returns true if value is undefined and "isRequired" is true but "touched" is false', () => {
+ const isValid = paramIsValid(undefined, getField('@timestamp'), true, false);
- expect(isValid).toBeFalsy();
+ expect(isValid).toBeTruthy();
+ });
+
+ test('returns true if value is undefined and "isRequired" is false', () => {
+ const isValid = paramIsValid(undefined, getField('@timestamp'), false, false);
+
+ expect(isValid).toBeTruthy();
+ });
+
+ test('returns false if value is empty string when "isRequired" is true and "touched" is false', () => {
+ const isValid = paramIsValid('', getField('@timestamp'), true, false);
+
+ expect(isValid).toBeTruthy();
+ });
+
+ test('returns true if value is empty string and "isRequired" is false', () => {
+ const isValid = paramIsValid('', getField('@timestamp'), false, false);
+
+ expect(isValid).toBeTruthy();
});
- test('returns true if type is "date" and value is valid', () => {
- const isValid = validateParams('1994-11-05T08:15:30-05:00', getField('@timestamp'));
+ test('returns true if type is "date" and value is valid and "isRequired" is false', () => {
+ const isValid = paramIsValid(
+ '1994-11-05T08:15:30-05:00',
+ getField('@timestamp'),
+ false,
+ false
+ );
expect(isValid).toBeTruthy();
});
- test('returns false if type is "date" and value is not valid', () => {
- const isValid = validateParams('1593478826', getField('@timestamp'));
+ test('returns true if type is "date" and value is valid and "isRequired" is true', () => {
+ const isValid = paramIsValid(
+ '1994-11-05T08:15:30-05:00',
+ getField('@timestamp'),
+ true,
+ false
+ );
+
+ expect(isValid).toBeTruthy();
+ });
+
+ test('returns false if type is "date" and value is not valid and "isRequired" is false', () => {
+ const isValid = paramIsValid('1593478826', getField('@timestamp'), false, false);
+
+ expect(isValid).toBeFalsy();
+ });
+
+ test('returns false if type is "date" and value is not valid and "isRequired" is true', () => {
+ const isValid = paramIsValid('1593478826', getField('@timestamp'), true, true);
expect(isValid).toBeFalsy();
});
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts
index a65f1fa35d3c2..3dcaf612da649 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts
@@ -30,21 +30,26 @@ export const getOperators = (field: IFieldType | undefined): OperatorOption[] =>
}
};
-export const validateParams = (
+export const paramIsValid = (
params: string | undefined,
- field: IFieldType | undefined
+ field: IFieldType | undefined,
+ isRequired: boolean,
+ touched: boolean
): boolean => {
- // Box would show error state if empty otherwise
- if (params == null || params === '') {
+ if (isRequired && touched && (params == null || params === '')) {
return false;
}
+ if ((isRequired && !touched) || (!isRequired && (params == null || params === ''))) {
+ return true;
+ }
+
const types = field != null && field.esTypes != null ? field.esTypes : [];
return types.reduce((acc, type) => {
switch (type) {
case 'date':
- const moment = dateMath.parse(params);
+ const moment = dateMath.parse(params ?? '');
return Boolean(moment && moment.isValid());
default:
return acc;
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx
index 45fe6be78ace6..737be199e2481 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx
@@ -74,7 +74,7 @@ describe('OperatorComponent', () => {
expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy();
});
- test('it displays "operatorOptions" if param is passed in', () => {
+ test('it displays "operatorOptions" if param is passed in with items', () => {
const wrapper = mount(
({ eui: euiLightVars, darkMode: false })}>
{
).toEqual([{ label: 'is not' }]);
});
+ test('it does not display "operatorOptions" if param is passed in with no items', () => {
+ const wrapper = mount(
+ ({ eui: euiLightVars, darkMode: false })}>
+
+
+ );
+
+ expect(
+ wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options')
+ ).toEqual([
+ {
+ label: 'is',
+ },
+ {
+ label: 'is not',
+ },
+ {
+ label: 'is one of',
+ },
+ {
+ label: 'is not one of',
+ },
+ {
+ label: 'exists',
+ },
+ {
+ label: 'does not exist',
+ },
+ {
+ label: 'is in list',
+ },
+ {
+ label: 'is not in list',
+ },
+ ]);
+ });
+
test('it correctly displays selected operator', () => {
const wrapper = mount(
({ eui: euiLightVars, darkMode: false })}>
diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx
index 6d9a684aab2de..cec7d575fc78e 100644
--- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx
@@ -35,7 +35,10 @@ export const OperatorComponent: React.FC = ({
}): JSX.Element => {
const getLabel = useCallback(({ message }): string => message, []);
const optionsMemo = useMemo(
- (): OperatorOption[] => (operatorOptions ? operatorOptions : getOperators(selectedField)),
+ (): OperatorOption[] =>
+ operatorOptions != null && operatorOptions.length > 0
+ ? operatorOptions
+ : getOperators(selectedField),
[operatorOptions, selectedField]
);
const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx
index 6e77cd7082d56..2abbaee5187a9 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx
@@ -307,6 +307,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
indexPatterns={indexPatterns}
isOrDisabled={false}
isAndDisabled={false}
+ isNestedDisabled={false}
data-test-subj="alert-exception-builder"
id-aria="alert-exception-builder"
onChange={handleBuilderOnChange}
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx
index 9486008e708ea..5ca2d2b86a527 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx
@@ -21,22 +21,43 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module)
);
})
- .add('nested button', () => {
+ .add('nested button - isNested false', () => {
return (
+ );
+ })
+ .add('nested button - isNested true', () => {
+ return (
+
);
})
@@ -45,10 +66,13 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module)
);
})
@@ -57,10 +81,28 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module)
+ );
+ })
+ .add('nested disabled', () => {
+ return (
+
);
});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx
index 66968ee95d3fa..6564770196b89 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx
@@ -15,10 +15,13 @@ describe('BuilderButtonOptions', () => {
);
@@ -37,10 +40,13 @@ describe('BuilderButtonOptions', () => {
);
@@ -49,17 +55,20 @@ describe('BuilderButtonOptions', () => {
expect(onOrClicked).toHaveBeenCalledTimes(1);
});
- test('it invokes "onAndClicked" when "and" button is clicked', () => {
+ test('it invokes "onAndClicked" when "and" button is clicked and "isNested" is "false"', () => {
const onAndClicked = jest.fn();
const wrapper = mount(
);
@@ -68,15 +77,40 @@ describe('BuilderButtonOptions', () => {
expect(onAndClicked).toHaveBeenCalledTimes(1);
});
+ test('it invokes "onAddClickWhenNested" when "and" button is clicked and "isNested" is "true"', () => {
+ const onAddClickWhenNested = jest.fn();
+
+ const wrapper = mount(
+
+ );
+
+ wrapper.find('[data-test-subj="exceptionsAndButton"] button').simulate('click');
+
+ expect(onAddClickWhenNested).toHaveBeenCalledTimes(1);
+ });
+
test('it disables "and" button if "isAndDisabled" is true', () => {
const wrapper = mount(
);
@@ -85,15 +119,18 @@ describe('BuilderButtonOptions', () => {
expect(andButton.prop('disabled')).toBeTruthy();
});
- test('it disables "or" button if "isOrDisabled" is true', () => {
+ test('it disables "or" button if "isOrDisabled" is "true"', () => {
const wrapper = mount(
);
@@ -102,17 +139,40 @@ describe('BuilderButtonOptions', () => {
expect(orButton.prop('disabled')).toBeTruthy();
});
- test('it invokes "onNestedClicked" when "and" button is clicked', () => {
+ test('it disables "add nested" button if "isNestedDisabled" is "true"', () => {
+ const wrapper = mount(
+
+ );
+
+ const nestedButton = wrapper.find('[data-test-subj="exceptionsNestedButton"] button').at(0);
+
+ expect(nestedButton.prop('disabled')).toBeTruthy();
+ });
+
+ test('it invokes "onNestedClicked" when "isNested" is "false" and "nested" button is clicked', () => {
const onNestedClicked = jest.fn();
const wrapper = mount(
);
@@ -120,4 +180,26 @@ describe('BuilderButtonOptions', () => {
expect(onNestedClicked).toHaveBeenCalledTimes(1);
});
+
+ test('it invokes "onAndClicked" when "isNested" is "true" and "nested" button is clicked', () => {
+ const onAndClicked = jest.fn();
+
+ const wrapper = mount(
+
+ );
+
+ wrapper.find('[data-test-subj="exceptionsNestedButton"] button').simulate('click');
+
+ expect(onAndClicked).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx
index eb224b82d756f..bef47ce877b93 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx
@@ -7,7 +7,8 @@ import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import styled from 'styled-components';
-import * as i18n from '../translations';
+import * as i18n from './translations';
+import * as i18nShared from '../translations';
const MyEuiButton = styled(EuiButton)`
min-width: 95px;
@@ -16,19 +17,25 @@ const MyEuiButton = styled(EuiButton)`
interface BuilderButtonOptionsProps {
isOrDisabled: boolean;
isAndDisabled: boolean;
+ isNestedDisabled: boolean;
+ isNested: boolean;
showNestedButton: boolean;
onAndClicked: () => void;
onOrClicked: () => void;
onNestedClicked: () => void;
+ onAddClickWhenNested: () => void;
}
export const BuilderButtonOptions: React.FC = ({
isOrDisabled = false,
isAndDisabled = false,
showNestedButton = false,
+ isNestedDisabled = true,
+ isNested,
onAndClicked,
onOrClicked,
onNestedClicked,
+ onAddClickWhenNested,
}) => (
@@ -36,11 +43,11 @@ export const BuilderButtonOptions: React.FC = ({
fill
size="s"
iconType="plusInCircle"
- onClick={onAndClicked}
+ onClick={isNested ? onAddClickWhenNested : onAndClicked}
data-test-subj="exceptionsAndButton"
isDisabled={isAndDisabled}
>
- {i18n.AND}
+ {i18nShared.AND}
@@ -52,7 +59,7 @@ export const BuilderButtonOptions: React.FC = ({
isDisabled={isOrDisabled}
data-test-subj="exceptionsOrButton"
>
- {i18n.OR}
+ {i18nShared.OR}
{showNestedButton && (
@@ -60,10 +67,11 @@ export const BuilderButtonOptions: React.FC = ({
- {i18n.ADD_NESTED_DESCRIPTION}
+ {isNested ? i18n.ADD_NON_NESTED_DESCRIPTION : i18n.ADD_NESTED_DESCRIPTION}
)}
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx
similarity index 70%
rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx
rename to x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx
index 791782b0f0152..b845848bd14d8 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx
@@ -8,7 +8,7 @@ import { mount } from 'enzyme';
import React from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
-import { EntryItemComponent } from './entry_item';
+import { BuilderEntryItem } from './builder_entry_item';
import {
isOperator,
isNotOperator,
@@ -44,47 +44,26 @@ jest.mock('../../../../lists_plugin_deps', () => {
};
});
-describe('EntryItemComponent', () => {
- test('it renders fields disabled if "isLoading" is "true"', () => {
- const wrapper = mount(
-
- );
-
- expect(
- wrapper.find('[data-test-subj="exceptionBuilderEntryField"] input').props().disabled
- ).toBeTruthy();
- expect(
- wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"] input').props().disabled
- ).toBeTruthy();
- expect(
- wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatch"] input').props().disabled
- ).toBeTruthy();
- expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldFormRow"]')).toHaveLength(0);
- });
-
+describe('BuilderEntryItem', () => {
test('it renders field labels if "showLabel" is "true"', () => {
const wrapper = mount(
-
);
@@ -94,16 +73,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "isOperator"', () => {
const wrapper = mount(
-
);
@@ -117,16 +103,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "isNotOperator"', () => {
const wrapper = mount(
-
);
@@ -142,16 +135,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "isOneOfOperator"', () => {
const wrapper = mount(
-
);
@@ -167,16 +167,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "isNotOneOfOperator"', () => {
const wrapper = mount(
-
);
@@ -192,16 +199,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "isInListOperator"', () => {
const wrapper = mount(
-
);
@@ -217,16 +231,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "isNotInListOperator"', () => {
const wrapper = mount(
-
);
@@ -242,16 +263,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "existsOperator"', () => {
const wrapper = mount(
-
);
@@ -270,16 +298,23 @@ describe('EntryItemComponent', () => {
test('it renders field values correctly when operator is "doesNotExistOperator"', () => {
const wrapper = mount(
-
);
@@ -299,16 +334,23 @@ describe('EntryItemComponent', () => {
test('it invokes "onChange" when new field is selected and resets operator and value fields', () => {
const mockOnChange = jest.fn();
const wrapper = mount(
-
);
@@ -318,24 +360,31 @@ describe('EntryItemComponent', () => {
}).onChange([{ label: 'machine.os' }]);
expect(mockOnChange).toHaveBeenCalledWith(
- { field: 'machine.os', operator: 'included', type: 'match', value: undefined },
+ { field: 'machine.os', operator: 'included', type: 'match', value: '' },
0
);
});
- test('it invokes "onChange" when new operator is selected and resets value field', () => {
+ test('it invokes "onChange" when new operator is selected', () => {
const mockOnChange = jest.fn();
const wrapper = mount(
-
);
@@ -345,7 +394,7 @@ describe('EntryItemComponent', () => {
}).onChange([{ label: 'is not' }]);
expect(mockOnChange).toHaveBeenCalledWith(
- { field: 'ip', operator: 'excluded', type: 'match', value: '' },
+ { field: 'ip', operator: 'excluded', type: 'match', value: '1234' },
0
);
});
@@ -353,16 +402,23 @@ describe('EntryItemComponent', () => {
test('it invokes "onChange" when new value field is entered for match operator', () => {
const mockOnChange = jest.fn();
const wrapper = mount(
-
);
@@ -380,16 +436,23 @@ describe('EntryItemComponent', () => {
test('it invokes "onChange" when new value field is entered for match_any operator', () => {
const mockOnChange = jest.fn();
const wrapper = mount(
-
);
@@ -407,16 +470,23 @@ describe('EntryItemComponent', () => {
test('it invokes "onChange" when new value field is entered for list operator', () => {
const mockOnChange = jest.fn();
const wrapper = mount(
-
);
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx
similarity index 62%
rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx
rename to x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx
index 7bf279168a9a0..736e88ee9fe06 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx
@@ -9,136 +9,135 @@ import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common';
import { FieldComponent } from '../../autocomplete/field';
import { OperatorComponent } from '../../autocomplete/operator';
-import { isOperator } from '../../autocomplete/operators';
import { OperatorOption } from '../../autocomplete/types';
import { AutocompleteFieldMatchComponent } from '../../autocomplete/field_value_match';
import { AutocompleteFieldMatchAnyComponent } from '../../autocomplete/field_value_match_any';
import { AutocompleteFieldExistsComponent } from '../../autocomplete/field_value_exists';
import { FormattedBuilderEntry, BuilderEntry } from '../types';
import { AutocompleteFieldListsComponent } from '../../autocomplete/field_value_lists';
-import { ListSchema, OperatorTypeEnum } from '../../../../lists_plugin_deps';
-import { getValueFromOperator } from '../helpers';
+import { ListSchema, OperatorTypeEnum, ExceptionListType } from '../../../../lists_plugin_deps';
import { getEmptyValue } from '../../empty_value';
-import * as i18n from '../translations';
+import * as i18n from './translations';
+import {
+ getFilteredIndexPatterns,
+ getOperatorOptions,
+ getEntryOnFieldChange,
+ getEntryOnOperatorChange,
+ getEntryOnMatchChange,
+ getEntryOnMatchAnyChange,
+ getEntryOnListChange,
+} from './helpers';
interface EntryItemProps {
entry: FormattedBuilderEntry;
- entryIndex: number;
indexPattern: IIndexPattern;
- isLoading: boolean;
showLabel: boolean;
+ listType: ExceptionListType;
+ addNested: boolean;
onChange: (arg: BuilderEntry, i: number) => void;
}
-export const EntryItemComponent: React.FC = ({
+export const BuilderEntryItem: React.FC = ({
entry,
- entryIndex,
indexPattern,
- isLoading,
+ listType,
+ addNested,
showLabel,
onChange,
}): JSX.Element => {
const handleFieldChange = useCallback(
([newField]: IFieldType[]): void => {
- onChange(
- {
- field: newField.name,
- type: OperatorTypeEnum.MATCH,
- operator: isOperator.operator,
- value: undefined,
- },
- entryIndex
- );
+ const { updatedEntry, index } = getEntryOnFieldChange(entry, newField);
+
+ onChange(updatedEntry, index);
},
- [onChange, entryIndex]
+ [onChange, entry]
);
const handleOperatorChange = useCallback(
([newOperator]: OperatorOption[]): void => {
- const newEntry = getValueFromOperator(entry.field, newOperator);
- onChange(newEntry, entryIndex);
+ const { updatedEntry, index } = getEntryOnOperatorChange(entry, newOperator);
+
+ onChange(updatedEntry, index);
},
- [onChange, entryIndex, entry.field]
+ [onChange, entry]
);
const handleFieldMatchValueChange = useCallback(
(newField: string): void => {
- onChange(
- {
- field: entry.field != null ? entry.field.name : undefined,
- type: OperatorTypeEnum.MATCH,
- operator: entry.operator.operator,
- value: newField,
- },
- entryIndex
- );
+ const { updatedEntry, index } = getEntryOnMatchChange(entry, newField);
+
+ onChange(updatedEntry, index);
},
- [onChange, entryIndex, entry.field, entry.operator.operator]
+ [onChange, entry]
);
const handleFieldMatchAnyValueChange = useCallback(
(newField: string[]): void => {
- onChange(
- {
- field: entry.field != null ? entry.field.name : undefined,
- type: OperatorTypeEnum.MATCH_ANY,
- operator: entry.operator.operator,
- value: newField,
- },
- entryIndex
- );
+ const { updatedEntry, index } = getEntryOnMatchAnyChange(entry, newField);
+
+ onChange(updatedEntry, index);
},
- [onChange, entryIndex, entry.field, entry.operator.operator]
+ [onChange, entry]
);
const handleFieldListValueChange = useCallback(
(newField: ListSchema): void => {
- onChange(
- {
- field: entry.field != null ? entry.field.name : undefined,
- type: OperatorTypeEnum.LIST,
- operator: entry.operator.operator,
- list: { id: newField.id, type: newField.type },
- },
- entryIndex
- );
+ const { updatedEntry, index } = getEntryOnListChange(entry, newField);
+
+ onChange(updatedEntry, index);
},
- [onChange, entryIndex, entry.field, entry.operator.operator]
+ [onChange, entry]
);
- const renderFieldInput = (isFirst: boolean): JSX.Element => {
- const comboBox = (
-
- );
-
- if (isFirst) {
- return (
-
- {comboBox}
-
+ const renderFieldInput = useCallback(
+ (isFirst: boolean): JSX.Element => {
+ const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry);
+ const comboBox = (
+
);
- } else {
- return comboBox;
- }
- };
+
+ if (isFirst) {
+ return (
+
+ {comboBox}
+
+ );
+ } else {
+ return comboBox;
+ }
+ },
+ [handleFieldChange, indexPattern, entry]
+ );
const renderOperatorInput = (isFirst: boolean): JSX.Element => {
+ const operatorOptions = getOperatorOptions(
+ entry,
+ listType,
+ entry.field != null && entry.field.type === 'boolean'
+ );
const comboBox = (
= ({
placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER}
selectedField={entry.field}
selectedValue={value}
- isDisabled={isLoading}
- isLoading={isLoading}
+ isDisabled={
+ indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0)
+ }
+ isLoading={false}
isClearable={false}
indexPattern={indexPattern}
onChange={handleFieldMatchValueChange}
@@ -182,8 +183,10 @@ export const EntryItemComponent: React.FC = ({
placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER}
selectedField={entry.field}
selectedValue={values}
- isDisabled={isLoading}
- isLoading={isLoading}
+ isDisabled={
+ indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0)
+ }
+ isLoading={false}
isClearable={false}
indexPattern={indexPattern}
onChange={handleFieldMatchAnyValueChange}
@@ -198,8 +201,10 @@ export const EntryItemComponent: React.FC = ({
selectedField={entry.field}
placeholder={i18n.EXCEPTION_FIELD_LISTS_PLACEHOLDER}
selectedValue={id}
- isLoading={isLoading}
- isDisabled={isLoading}
+ isLoading={false}
+ isDisabled={
+ indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0)
+ }
isClearable={false}
onChange={handleFieldListValueChange}
isRequired
@@ -240,9 +245,14 @@ export const EntryItemComponent: React.FC = ({
>
{renderFieldInput(showLabel)}
{renderOperatorInput(showLabel)}
- {renderFieldValueInput(showLabel, entry.operator.type)}
+
+ {renderFieldValueInput(
+ showLabel,
+ entry.nested === 'parent' ? OperatorTypeEnum.EXISTS : entry.operator.type
+ )}
+
);
};
-EntryItemComponent.displayName = 'EntryItem';
+BuilderEntryItem.displayName = 'BuilderEntryItem';
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx
index 0f3b6ec2e94e4..584f0971a4193 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx
@@ -18,8 +18,10 @@ import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/typ
describe('ExceptionListItemComponent', () => {
describe('and badge logic', () => {
test('it renders "and" badge with extra top padding for the first exception item when "andLogicIncluded" is "true"', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
- exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()];
+ const exceptionItem = {
+ ...getExceptionListItemSchemaMock(),
+ entries: [getEntryMatchMock(), getEntryMatchMock()],
+ };
const wrapper = mount(
({ eui: euiLightVars, darkMode: false })}>
{
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={true}
isOnlyItem={false}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -46,7 +49,7 @@ describe('ExceptionListItemComponent', () => {
});
test('it renders "and" badge when more than one exception item entry exists and it is not the first exception item', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
+ const exceptionItem = { ...getExceptionListItemSchemaMock() };
exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()];
const wrapper = mount(
({ eui: euiLightVars, darkMode: false })}>
@@ -59,9 +62,10 @@ describe('ExceptionListItemComponent', () => {
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={true}
isOnlyItem={false}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -72,7 +76,7 @@ describe('ExceptionListItemComponent', () => {
});
test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
+ const exceptionItem = { ...getExceptionListItemSchemaMock() };
exceptionItem.entries = [getEntryMatchMock()];
const wrapper = mount(
({ eui: euiLightVars, darkMode: false })}>
@@ -85,9 +89,10 @@ describe('ExceptionListItemComponent', () => {
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={true}
isOnlyItem={false}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -100,7 +105,7 @@ describe('ExceptionListItemComponent', () => {
});
test('it renders no "and" badge when "andLogicIncluded" is "false"', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
+ const exceptionItem = { ...getExceptionListItemSchemaMock() };
exceptionItem.entries = [getEntryMatchMock()];
const wrapper = mount(
({ eui: euiLightVars, darkMode: false })}>
@@ -113,9 +118,10 @@ describe('ExceptionListItemComponent', () => {
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={false}
isOnlyItem={false}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -134,8 +140,10 @@ describe('ExceptionListItemComponent', () => {
describe('delete button logic', () => {
test('it renders delete button disabled when it is only entry left in builder', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
- exceptionItem.entries = [getEntryMatchMock()];
+ const exceptionItem = {
+ ...getExceptionListItemSchemaMock(),
+ entries: [{ ...getEntryMatchMock(), field: '' }],
+ };
const wrapper = mount(
{
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={false}
isOnlyItem={true}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -160,7 +169,7 @@ describe('ExceptionListItemComponent', () => {
});
test('it does not render delete button disabled when it is not the only entry left in builder', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
+ const exceptionItem = { ...getExceptionListItemSchemaMock() };
exceptionItem.entries = [getEntryMatchMock()];
const wrapper = mount(
@@ -173,9 +182,10 @@ describe('ExceptionListItemComponent', () => {
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={false}
isOnlyItem={false}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -187,7 +197,7 @@ describe('ExceptionListItemComponent', () => {
});
test('it does not render delete button disabled when "exceptionItemIndex" is not "0"', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
+ const exceptionItem = { ...getExceptionListItemSchemaMock() };
exceptionItem.entries = [getEntryMatchMock()];
const wrapper = mount(
{
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={false}
// if exceptionItemIndex is not 0, wouldn't make sense for
// this to be true, but done for testing purposes
isOnlyItem={true}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -215,7 +226,7 @@ describe('ExceptionListItemComponent', () => {
});
test('it does not render delete button disabled when more than one entry exists', () => {
- const exceptionItem = getExceptionListItemSchemaMock();
+ const exceptionItem = { ...getExceptionListItemSchemaMock() };
exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()];
const wrapper = mount(
{
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={false}
isOnlyItem={true}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
/>
@@ -243,7 +255,7 @@ describe('ExceptionListItemComponent', () => {
test('it invokes "onChangeExceptionItem" when delete button clicked', () => {
const mockOnDeleteExceptionItem = jest.fn();
- const exceptionItem = getExceptionListItemSchemaMock();
+ const exceptionItem = { ...getExceptionListItemSchemaMock() };
exceptionItem.entries = [getEntryMatchMock(), getEntryMatchAnyMock()];
const wrapper = mount(
{
title: 'logstash-*',
fields,
}}
- isLoading={false}
andLogicIncluded={false}
isOnlyItem={true}
+ listType="detection"
+ addNested={false}
onDeleteExceptionItem={mockOnDeleteExceptionItem}
onChangeExceptionItem={jest.fn()}
/>
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx
index 8e57e83d0e7e4..dce78f3cb9ceb 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx
@@ -10,9 +10,10 @@ import styled from 'styled-components';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common';
import { AndOrBadge } from '../../and_or_badge';
-import { EntryItemComponent } from './entry_item';
-import { getFormattedBuilderEntries } from '../helpers';
+import { BuilderEntryItem } from './builder_entry_item';
+import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers';
import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types';
+import { ExceptionListType } from '../../../../../public/lists_plugin_deps';
const MyInvisibleAndBadge = styled(EuiFlexItem)`
visibility: hidden;
@@ -22,14 +23,25 @@ const MyFirstRowContainer = styled(EuiFlexItem)`
padding-top: 20px;
`;
+const MyBeautifulLine = styled(EuiFlexItem)`
+ &:after {
+ background: ${({ theme }) => theme.eui.euiColorLightShade};
+ content: '';
+ width: 2px;
+ height: 40px;
+ margin: 0 15px;
+ }
+`;
+
interface ExceptionListItemProps {
exceptionItem: ExceptionsBuilderExceptionItem;
exceptionId: string;
exceptionItemIndex: number;
- isLoading: boolean;
indexPattern: IIndexPattern;
andLogicIncluded: boolean;
isOnlyItem: boolean;
+ listType: ExceptionListType;
+ addNested: boolean;
onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void;
onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void;
}
@@ -40,8 +52,9 @@ export const ExceptionListItemComponent = React.memo(
exceptionId,
exceptionItemIndex,
indexPattern,
- isLoading,
isOnlyItem,
+ listType,
+ addNested,
andLogicIncluded,
onDeleteExceptionItem,
onChangeExceptionItem,
@@ -63,15 +76,12 @@ export const ExceptionListItemComponent = React.memo(
);
const handleDeleteEntry = useCallback(
- (entryIndex: number): void => {
- const updatedEntries: BuilderEntry[] = [
- ...exceptionItem.entries.slice(0, entryIndex),
- ...exceptionItem.entries.slice(entryIndex + 1),
- ];
- const updatedExceptionItem: ExceptionsBuilderExceptionItem = {
- ...exceptionItem,
- entries: updatedEntries,
- };
+ (entryIndex: number, parentIndex: number | null): void => {
+ const updatedExceptionItem = getUpdatedEntriesOnDelete(
+ exceptionItem,
+ parentIndex ? parentIndex : entryIndex,
+ parentIndex ? entryIndex : null
+ );
onDeleteExceptionItem(updatedExceptionItem, exceptionItemIndex);
},
@@ -80,80 +90,98 @@ export const ExceptionListItemComponent = React.memo(
const entries = useMemo(
(): FormattedBuilderEntry[] =>
- indexPattern != null ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) : [],
- [indexPattern, exceptionItem]
+ indexPattern != null && exceptionItem.entries.length > 0
+ ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries)
+ : [],
+ [exceptionItem.entries, indexPattern]
);
- const andBadge = useMemo((): JSX.Element => {
+ const getAndBadge = useCallback((): JSX.Element => {
const badge = ;
- if (entries.length > 1 && exceptionItemIndex === 0) {
+
+ if (andLogicIncluded && exceptionItem.entries.length > 1 && exceptionItemIndex === 0) {
return (
{badge}
);
- } else if (entries.length > 1) {
+ } else if (andLogicIncluded && exceptionItem.entries.length <= 1) {
return (
-
+
{badge}
-
+
);
- } else {
+ } else if (andLogicIncluded && exceptionItem.entries.length > 1) {
return (
-
+
{badge}
-
+
);
+ } else {
+ return <>>;
}
- }, [entries.length, exceptionItemIndex]);
+ }, [exceptionItem.entries.length, exceptionItemIndex, andLogicIncluded]);
const getDeleteButton = useCallback(
- (index: number): JSX.Element => {
+ (entryIndex: number, parentIndex: number | null): JSX.Element => {
const button = (
handleDeleteEntry(index)}
- isDisabled={isOnlyItem && entries.length === 1 && exceptionItemIndex === 0}
+ onClick={() => handleDeleteEntry(entryIndex, parentIndex)}
+ isDisabled={
+ isOnlyItem &&
+ exceptionItem.entries.length === 1 &&
+ exceptionItemIndex === 0 &&
+ (exceptionItem.entries[0].field == null || exceptionItem.entries[0].field === '')
+ }
aria-label="entryDeleteButton"
className="exceptionItemEntryDeleteButton"
data-test-subj="exceptionItemEntryDeleteButton"
/>
);
- if (index === 0 && exceptionItemIndex === 0) {
+ if (entryIndex === 0 && exceptionItemIndex === 0 && parentIndex == null) {
return {button};
} else {
return {button};
}
},
- [entries.length, exceptionItemIndex, handleDeleteEntry, isOnlyItem]
+ [exceptionItemIndex, exceptionItem.entries, handleDeleteEntry, isOnlyItem]
);
return (
-
- {andLogicIncluded && andBadge}
-
-
- {entries.map((item, index) => (
-
-
-
-
-
- {getDeleteButton(index)}
-
-
- ))}
-
-
-
+
+
+ {getAndBadge()}
+
+
+ {entries.map((item, index) => (
+
+
+ {item.nested === 'child' && }
+
+
+
+ {getDeleteButton(
+ item.entryIndex,
+ item.parent != null ? item.parent.parentIndex : null
+ )}
+
+
+ ))}
+
+
+
+
);
}
);
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx
new file mode 100644
index 0000000000000..8b74d44f29a18
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx
@@ -0,0 +1,1014 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import {
+ fields,
+ getField,
+} from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
+import { getEntryNestedMock } from '../../../../../../lists/common/schemas/types/entry_nested.mock';
+import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock';
+import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock';
+import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock';
+import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
+import { getListResponseMock } from '../../../../../../lists/common/schemas/response/list_schema.mock';
+import {
+ isOperator,
+ isOneOfOperator,
+ isNotOperator,
+ isNotOneOfOperator,
+ existsOperator,
+ doesNotExistOperator,
+ isInListOperator,
+ EXCEPTION_OPERATORS,
+} from '../../autocomplete/operators';
+import { FormattedBuilderEntry, BuilderEntry, ExceptionsBuilderExceptionItem } from '../types';
+import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common';
+import { EntryNested, Entry } from '../../../../lists_plugin_deps';
+
+import {
+ getFilteredIndexPatterns,
+ getFormattedBuilderEntry,
+ isEntryNested,
+ getFormattedBuilderEntries,
+ getUpdatedEntriesOnDelete,
+ getEntryFromOperator,
+ getOperatorOptions,
+ getEntryOnFieldChange,
+ getEntryOnOperatorChange,
+ getEntryOnMatchChange,
+ getEntryOnMatchAnyChange,
+ getEntryOnListChange,
+} from './helpers';
+import { OperatorOption } from '../../autocomplete/types';
+
+const getMockIndexPattern = (): IIndexPattern => ({
+ id: '1234',
+ title: 'logstash-*',
+ fields,
+});
+
+const getMockBuilderEntry = (): FormattedBuilderEntry => ({
+ field: getField('ip'),
+ operator: isOperator,
+ value: 'some value',
+ nested: undefined,
+ parent: undefined,
+ entryIndex: 0,
+});
+
+const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({
+ field: getField('nestedField.child'),
+ operator: isOperator,
+ value: 'some value',
+ nested: 'child',
+ parent: {
+ parent: {
+ ...getEntryNestedMock(),
+ field: 'nestedField',
+ entries: [{ ...getEntryMatchMock(), field: 'child' }],
+ },
+ parentIndex: 0,
+ },
+ entryIndex: 0,
+});
+
+const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({
+ field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] },
+ operator: isOperator,
+ value: undefined,
+ nested: 'parent',
+ parent: undefined,
+ entryIndex: 0,
+});
+
+describe('Exception builder helpers', () => {
+ describe('#getFilteredIndexPatterns', () => {
+ test('it returns nested fields that match parent value when "item.nested" is "child"', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
+ const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem);
+ const expected: IIndexPattern = {
+ fields: [
+ { ...getField('nestedField.child') },
+ { ...getField('nestedField.nestedChild.doublyNestedChild') },
+ ],
+ id: '1234',
+ title: 'logstash-*',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry();
+ const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem);
+ const expected: IIndexPattern = {
+ fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }],
+ id: '1234',
+ title: 'logstash-*',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItem: FormattedBuilderEntry = {
+ ...getMockNestedParentBuilderEntry(),
+ field: undefined,
+ };
+ const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem);
+ const expected: IIndexPattern = {
+ fields: [
+ { ...getField('nestedField.child') },
+ { ...getField('nestedField.nestedChild.doublyNestedChild') },
+ ],
+ id: '1234',
+ title: 'logstash-*',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem);
+ const expected: IIndexPattern = {
+ fields: [...fields],
+ id: '1234',
+ title: 'logstash-*',
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getFormattedBuilderEntry', () => {
+ test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' };
+ const payloadParent: EntryNested = {
+ ...getEntryNestedMock(),
+ field: 'nestedField',
+ entries: [{ ...getEntryMatchMock(), field: 'child' }],
+ };
+ const output = getFormattedBuilderEntry(
+ payloadIndexPattern,
+ payloadItem,
+ 0,
+ payloadParent,
+ 1
+ );
+ const expected: FormattedBuilderEntry = {
+ entryIndex: 0,
+ field: {
+ aggregatable: false,
+ count: 0,
+ esTypes: ['text'],
+ name: 'nestedField.child',
+ readFromDocValues: false,
+ scripted: false,
+ searchable: true,
+ subType: {
+ nested: {
+ path: 'nestedField',
+ },
+ },
+ type: 'string',
+ },
+ nested: 'child',
+ operator: isOperator,
+ parent: {
+ parent: {
+ entries: [{ ...payloadItem }],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ parentIndex: 1,
+ },
+ value: 'some host name',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'ip', value: 'some ip' };
+ const output = getFormattedBuilderEntry(
+ payloadIndexPattern,
+ payloadItem,
+ 0,
+ undefined,
+ undefined
+ );
+ const expected: FormattedBuilderEntry = {
+ entryIndex: 0,
+ field: {
+ aggregatable: true,
+ count: 0,
+ esTypes: ['ip'],
+ name: 'ip',
+ readFromDocValues: true,
+ scripted: false,
+ searchable: true,
+ type: 'ip',
+ },
+ nested: undefined,
+ operator: isOperator,
+ parent: undefined,
+ value: 'some ip',
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#isEntryNested', () => {
+ test('it returns "false" if payload is not of type EntryNested', () => {
+ const payload: BuilderEntry = { ...getEntryMatchMock() };
+ const output = isEntryNested(payload);
+ const expected = false;
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "true if payload is of type EntryNested', () => {
+ const payload: EntryNested = getEntryNestedMock();
+ const output = isEntryNested(payload);
+ const expected = true;
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getFormattedBuilderEntries', () => {
+ test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItems: BuilderEntry[] = [{ ...getEntryMatchMock() }];
+ const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
+ const expected: FormattedBuilderEntry[] = [
+ {
+ entryIndex: 0,
+ field: undefined,
+ nested: undefined,
+ operator: isOperator,
+ parent: undefined,
+ value: 'some host name',
+ },
+ ];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns formatted entries when no nested entries exist', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadItems: BuilderEntry[] = [
+ { ...getEntryMatchMock(), field: 'ip', value: 'some ip' },
+ { ...getEntryMatchAnyMock(), field: 'extension', value: ['some extension'] },
+ ];
+ const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
+ const expected: FormattedBuilderEntry[] = [
+ {
+ entryIndex: 0,
+ field: {
+ aggregatable: true,
+ count: 0,
+ esTypes: ['ip'],
+ name: 'ip',
+ readFromDocValues: true,
+ scripted: false,
+ searchable: true,
+ type: 'ip',
+ },
+ nested: undefined,
+ operator: isOperator,
+ parent: undefined,
+ value: 'some ip',
+ },
+ {
+ entryIndex: 1,
+ field: {
+ aggregatable: true,
+ count: 0,
+ esTypes: ['keyword'],
+ name: 'extension',
+ readFromDocValues: true,
+ scripted: false,
+ searchable: true,
+ type: 'string',
+ },
+ nested: undefined,
+ operator: isOneOfOperator,
+ parent: undefined,
+ value: ['some extension'],
+ },
+ ];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns formatted entries when nested entries exist', () => {
+ const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
+ const payloadParent: EntryNested = {
+ ...getEntryNestedMock(),
+ field: 'nestedField',
+ entries: [{ ...getEntryMatchMock(), field: 'child' }],
+ };
+ const payloadItems: BuilderEntry[] = [
+ { ...getEntryMatchMock(), field: 'ip', value: 'some ip' },
+ { ...payloadParent },
+ ];
+
+ const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
+ const expected: FormattedBuilderEntry[] = [
+ {
+ entryIndex: 0,
+ field: {
+ aggregatable: true,
+ count: 0,
+ esTypes: ['ip'],
+ name: 'ip',
+ readFromDocValues: true,
+ scripted: false,
+ searchable: true,
+ type: 'ip',
+ },
+ nested: undefined,
+ operator: isOperator,
+ parent: undefined,
+ value: 'some ip',
+ },
+ {
+ entryIndex: 1,
+ field: {
+ aggregatable: false,
+ esTypes: ['nested'],
+ name: 'nestedField',
+ searchable: false,
+ type: 'string',
+ },
+ nested: 'parent',
+ operator: isOperator,
+ parent: undefined,
+ value: undefined,
+ },
+ {
+ entryIndex: 0,
+ field: {
+ aggregatable: false,
+ count: 0,
+ esTypes: ['text'],
+ name: 'nestedField.child',
+ readFromDocValues: false,
+ scripted: false,
+ searchable: true,
+ subType: {
+ nested: {
+ path: 'nestedField',
+ },
+ },
+ type: 'string',
+ },
+ nested: 'child',
+ operator: isOperator,
+ parent: {
+ parent: {
+ entries: [
+ {
+ field: 'child',
+ operator: 'included',
+ type: 'match',
+ value: 'some host name',
+ },
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ parentIndex: 1,
+ },
+ value: 'some host name',
+ },
+ ];
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getUpdatedEntriesOnDelete', () => {
+ test('it removes entry corresponding to "entryIndex"', () => {
+ const payloadItem: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock() };
+ const output = getUpdatedEntriesOnDelete(payloadItem, 0, null);
+ const expected: ExceptionsBuilderExceptionItem = {
+ ...getExceptionListItemSchemaMock(),
+ entries: [
+ {
+ field: 'some.not.nested.field',
+ operator: 'included',
+ type: 'match',
+ value: 'some value',
+ },
+ ],
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it removes entry corresponding to "nestedEntryIndex"', () => {
+ const payloadItem: ExceptionsBuilderExceptionItem = {
+ ...getExceptionListItemSchemaMock(),
+ entries: [
+ {
+ ...getEntryNestedMock(),
+ entries: [{ ...getEntryExistsMock() }, { ...getEntryMatchAnyMock() }],
+ },
+ ],
+ };
+ const output = getUpdatedEntriesOnDelete(payloadItem, 0, 1);
+ const expected: ExceptionsBuilderExceptionItem = {
+ ...getExceptionListItemSchemaMock(),
+ entries: [{ ...getEntryNestedMock(), entries: [{ ...getEntryExistsMock() }] }],
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => {
+ const payloadItem: ExceptionsBuilderExceptionItem = {
+ ...getExceptionListItemSchemaMock(),
+ entries: [
+ {
+ ...getEntryNestedMock(),
+ entries: [{ ...getEntryExistsMock() }],
+ },
+ ],
+ };
+ const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0);
+ const expected: ExceptionsBuilderExceptionItem = {
+ ...getExceptionListItemSchemaMock(),
+ entries: [],
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getEntryFromOperator', () => {
+ test('it returns current value when switching from "is" to "is not"', () => {
+ const payloadOperator: OperatorOption = isNotOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ value: 'I should stay the same',
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'excluded',
+ type: 'match',
+ value: 'I should stay the same',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns current value when switching from "is not" to "is"', () => {
+ const payloadOperator: OperatorOption = isOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isNotOperator,
+ value: 'I should stay the same',
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'included',
+ type: 'match',
+ value: 'I should stay the same',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns empty value when switching operator types to "match"', () => {
+ const payloadOperator: OperatorOption = isOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isNotOneOfOperator,
+ value: ['I should stay the same'],
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'included',
+ type: 'match',
+ value: '',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns current value when switching from "is one of" to "is not one of"', () => {
+ const payloadOperator: OperatorOption = isNotOneOfOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOneOfOperator,
+ value: ['I should stay the same'],
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'excluded',
+ type: 'match_any',
+ value: ['I should stay the same'],
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns current value when switching from "is not one of" to "is one of"', () => {
+ const payloadOperator: OperatorOption = isOneOfOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isNotOneOfOperator,
+ value: ['I should stay the same'],
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'included',
+ type: 'match_any',
+ value: ['I should stay the same'],
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns empty value when switching operator types to "match_any"', () => {
+ const payloadOperator: OperatorOption = isOneOfOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOperator,
+ value: 'I should stay the same',
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'included',
+ type: 'match_any',
+ value: [],
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns current value when switching from "exists" to "does not exist"', () => {
+ const payloadOperator: OperatorOption = doesNotExistOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: existsOperator,
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'excluded',
+ type: 'exists',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns current value when switching from "does not exist" to "exists"', () => {
+ const payloadOperator: OperatorOption = existsOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: doesNotExistOperator,
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'included',
+ type: 'exists',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns empty value when switching operator types to "exists"', () => {
+ const payloadOperator: OperatorOption = existsOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOperator,
+ value: 'I should stay the same',
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'included',
+ type: 'exists',
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns empty value when switching operator types to "list"', () => {
+ const payloadOperator: OperatorOption = isInListOperator;
+ const payloadEntry: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOperator,
+ value: 'I should stay the same',
+ };
+ const output = getEntryFromOperator(payloadOperator, payloadEntry);
+ const expected: Entry = {
+ field: 'ip',
+ operator: 'included',
+ type: 'list',
+ list: { id: '', type: 'ip' },
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getOperatorOptions', () => {
+ test('it returns "isOperator" if "item.nested" is "parent"', () => {
+ const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'endpoint', false);
+ const expected: OperatorOption[] = [isOperator];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "isOperator" if no field selected', () => {
+ const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined };
+ const output = getOperatorOptions(payloadItem, 'endpoint', false);
+ const expected: OperatorOption[] = [isOperator];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => {
+ const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'endpoint', false);
+ const expected: OperatorOption[] = [isOperator, isOneOfOperator];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => {
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'endpoint', false);
+ const expected: OperatorOption[] = [isOperator, isOneOfOperator];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => {
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'endpoint', true);
+ const expected: OperatorOption[] = [isOperator];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => {
+ const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'detection', false);
+ const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => {
+ const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'detection', true);
+ const expected: OperatorOption[] = [isOperator, existsOperator];
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns all operator options if "listType" is "detection"', () => {
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'detection', false);
+ const expected: OperatorOption[] = EXCEPTION_OPERATORS;
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns "isOperator" and "existsOperator" if field type is boolean', () => {
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const output = getOperatorOptions(payloadItem, 'detection', true);
+ const expected: OperatorOption[] = [isOperator, existsOperator];
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getEntryOnFieldChange', () => {
+ test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => {
+ const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry();
+ const payloadIFieldType: IFieldType = getField('nestedField.child');
+ const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [{ field: 'child', operator: 'included', type: 'match', value: '' }],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => {
+ const payloadItem: FormattedBuilderEntry = {
+ ...getMockNestedBuilderEntry(),
+ parent: {
+ parent: {
+ ...getEntryNestedMock(),
+ field: 'nestedField',
+ entries: [{ ...getEntryMatchMock(), field: 'child' }, getEntryMatchAnyMock()],
+ },
+ parentIndex: 0,
+ },
+ };
+ const payloadIFieldType: IFieldType = getField('nestedField.child');
+ const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [
+ { field: 'child', operator: 'included', type: 'match', value: '' },
+ getEntryMatchAnyMock(),
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns field of type "match" with updated field if not a nested entry', () => {
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const payloadIFieldType: IFieldType = getField('ip');
+ const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ field: 'ip',
+ operator: 'included',
+ type: 'match',
+ value: '',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getEntryOnOperatorChange', () => {
+ test('it returns updated subentry preserving its value when entry is not switching operator types', () => {
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const payloadOperator: OperatorOption = isNotOperator;
+ const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: { field: 'ip', type: 'match', value: 'some value', operator: 'excluded' },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns updated subentry resetting its value when entry is switching operator types', () => {
+ const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
+ const payloadOperator: OperatorOption = isOneOfOperator;
+ const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: { field: 'ip', type: 'match_any', value: [], operator: 'included' },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => {
+ const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
+ const payloadOperator: OperatorOption = isNotOperator;
+ const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [
+ {
+ field: 'child',
+ operator: 'excluded',
+ type: 'match',
+ value: 'some value',
+ },
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => {
+ const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
+ const payloadOperator: OperatorOption = isOneOfOperator;
+ const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [
+ {
+ field: 'child',
+ operator: 'included',
+ type: 'match_any',
+ value: [],
+ },
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getEntryOnMatchChange', () => {
+ test('it returns entry with updated value', () => {
+ const payload: FormattedBuilderEntry = getMockBuilderEntry();
+ const output = getEntryOnMatchChange(payload, 'jibber jabber');
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: { field: 'ip', type: 'match', value: 'jibber jabber', operator: 'included' },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
+ const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined };
+ const output = getEntryOnMatchChange(payload, 'jibber jabber');
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: { field: '', type: 'match', value: 'jibber jabber', operator: 'included' },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns nested entry with updated value', () => {
+ const payload: FormattedBuilderEntry = getMockNestedBuilderEntry();
+ const output = getEntryOnMatchChange(payload, 'jibber jabber');
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [
+ {
+ field: 'child',
+ operator: 'included',
+ type: 'match',
+ value: 'jibber jabber',
+ },
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
+ const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined };
+ const output = getEntryOnMatchChange(payload, 'jibber jabber');
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [
+ {
+ field: '',
+ operator: 'included',
+ type: 'match',
+ value: 'jibber jabber',
+ },
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getEntryOnMatchAnyChange', () => {
+ test('it returns entry with updated value', () => {
+ const payload: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOneOfOperator,
+ value: ['some value'],
+ };
+ const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: {
+ field: 'ip',
+ type: 'match_any',
+ value: ['jibber jabber'],
+ operator: 'included',
+ },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
+ const payload: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOneOfOperator,
+ value: ['some value'],
+ field: undefined,
+ };
+ const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: {
+ field: '',
+ type: 'match_any',
+ value: ['jibber jabber'],
+ operator: 'included',
+ },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns nested entry with updated value', () => {
+ const payload: FormattedBuilderEntry = {
+ ...getMockNestedBuilderEntry(),
+ parent: {
+ parent: {
+ ...getEntryNestedMock(),
+ field: 'nestedField',
+ entries: [{ ...getEntryMatchAnyMock(), field: 'child' }],
+ },
+ parentIndex: 0,
+ },
+ };
+ const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [
+ {
+ field: 'child',
+ operator: 'included',
+ type: 'match_any',
+ value: ['jibber jabber'],
+ },
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
+ const payload: FormattedBuilderEntry = {
+ ...getMockNestedBuilderEntry(),
+ field: undefined,
+ parent: {
+ parent: {
+ ...getEntryNestedMock(),
+ field: 'nestedField',
+ entries: [{ ...getEntryMatchAnyMock(), field: 'child' }],
+ },
+ parentIndex: 0,
+ },
+ };
+ const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ index: 0,
+ updatedEntry: {
+ entries: [
+ {
+ field: '',
+ operator: 'included',
+ type: 'match_any',
+ value: ['jibber jabber'],
+ },
+ ],
+ field: 'nestedField',
+ type: 'nested',
+ },
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('#getEntryOnListChange', () => {
+ test('it returns entry with updated value', () => {
+ const payload: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOneOfOperator,
+ value: '1234',
+ };
+ const output = getEntryOnListChange(payload, getListResponseMock());
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: {
+ field: 'ip',
+ type: 'list',
+ list: { id: 'some-list-id', type: 'ip' },
+ operator: 'included',
+ },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+
+ test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
+ const payload: FormattedBuilderEntry = {
+ ...getMockBuilderEntry(),
+ operator: isOneOfOperator,
+ value: '1234',
+ field: undefined,
+ };
+ const output = getEntryOnListChange(payload, getListResponseMock());
+ const expected: { updatedEntry: BuilderEntry; index: number } = {
+ updatedEntry: {
+ field: '',
+ type: 'list',
+ list: { id: 'some-list-id', type: 'ip' },
+ operator: 'included',
+ },
+ index: 0,
+ };
+ expect(output).toEqual(expected);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx
new file mode 100644
index 0000000000000..2fe2c68941ae6
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx
@@ -0,0 +1,549 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common';
+import {
+ Entry,
+ OperatorTypeEnum,
+ EntryNested,
+ ExceptionListType,
+ EntryMatch,
+ EntryMatchAny,
+ EntryExists,
+ entriesList,
+ ListSchema,
+ OperatorEnum,
+} from '../../../../lists_plugin_deps';
+import {
+ isOperator,
+ existsOperator,
+ isOneOfOperator,
+ EXCEPTION_OPERATORS,
+} from '../../autocomplete/operators';
+import { OperatorOption } from '../../autocomplete/types';
+import {
+ BuilderEntry,
+ FormattedBuilderEntry,
+ ExceptionsBuilderExceptionItem,
+ EmptyEntry,
+ EmptyNestedEntry,
+} from '../types';
+import { getEntryValue, getExceptionOperatorSelect } from '../helpers';
+
+/**
+ * Returns filtered index patterns based on the field - if a user selects to
+ * add nested entry, should only show nested fields, if item is the parent
+ * field of a nested entry, we only display the parent field
+ *
+ * @param patterns IIndexPattern containing available fields on rule index
+ * @param item exception item entry
+ * @param addNested boolean noting whether or not UI is currently
+ * set to add a nested field
+ */
+export const getFilteredIndexPatterns = (
+ patterns: IIndexPattern,
+ item: FormattedBuilderEntry
+): IIndexPattern => {
+ if (item.nested === 'child' && item.parent != null) {
+ // when user has selected a nested entry, only fields with the common parent are shown
+ return {
+ ...patterns,
+ fields: patterns.fields.filter(
+ (field) =>
+ field.subType != null &&
+ field.subType.nested != null &&
+ item.parent != null &&
+ field.subType.nested.path.startsWith(item.parent.parent.field)
+ ),
+ };
+ } else if (item.nested === 'parent' && item.field != null) {
+ // when user has selected a nested entry, right above it we show the common parent
+ return { ...patterns, fields: [item.field] };
+ } else if (item.nested === 'parent' && item.field == null) {
+ // when user selects to add a nested entry, only nested fields are shown as options
+ return {
+ ...patterns,
+ fields: patterns.fields.filter(
+ (field) => field.subType != null && field.subType.nested != null
+ ),
+ };
+ } else {
+ return patterns;
+ }
+};
+
+/**
+ * Formats the entry into one that is easily usable for the UI, most of the
+ * complexity was introduced with nested fields
+ *
+ * @param patterns IIndexPattern containing available fields on rule index
+ * @param item exception item entry
+ * @param itemIndex entry index
+ * @param parent nested entries hold copy of their parent for use in various logic
+ * @param parentIndex corresponds to the entry index, this might seem obvious, but
+ * was added to ensure that nested items could be identified with their parent entry
+ */
+export const getFormattedBuilderEntry = (
+ indexPattern: IIndexPattern,
+ item: BuilderEntry,
+ itemIndex: number,
+ parent: EntryNested | undefined,
+ parentIndex: number | undefined
+): FormattedBuilderEntry => {
+ const { fields } = indexPattern;
+ const field = parent != null ? `${parent.field}.${item.field}` : item.field;
+ const [selectedField] = fields.filter(({ name }) => field != null && field === name);
+
+ if (parent != null && parentIndex != null) {
+ return {
+ field: selectedField,
+ operator: getExceptionOperatorSelect(item),
+ value: getEntryValue(item),
+ nested: 'child',
+ parent: { parent, parentIndex },
+ entryIndex: itemIndex,
+ };
+ } else {
+ return {
+ field: selectedField,
+ operator: getExceptionOperatorSelect(item),
+ value: getEntryValue(item),
+ nested: undefined,
+ parent: undefined,
+ entryIndex: itemIndex,
+ };
+ }
+};
+
+export const isEntryNested = (item: BuilderEntry): item is EntryNested => {
+ return (item as EntryNested).entries != null;
+};
+
+/**
+ * Formats the entries to be easily usable for the UI, most of the
+ * complexity was introduced with nested fields
+ *
+ * @param patterns IIndexPattern containing available fields on rule index
+ * @param entries exception item entries
+ * @param addNested boolean noting whether or not UI is currently
+ * set to add a nested field
+ * @param parent nested entries hold copy of their parent for use in various logic
+ * @param parentIndex corresponds to the entry index, this might seem obvious, but
+ * was added to ensure that nested items could be identified with their parent entry
+ */
+export const getFormattedBuilderEntries = (
+ indexPattern: IIndexPattern,
+ entries: BuilderEntry[],
+ parent?: EntryNested,
+ parentIndex?: number
+): FormattedBuilderEntry[] => {
+ return entries.reduce((acc, item, index) => {
+ const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0;
+ if (item.type !== 'nested' && !isNewNestedEntry) {
+ const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry(
+ indexPattern,
+ item,
+ index,
+ parent,
+ parentIndex
+ );
+ return [...acc, newItemEntry];
+ } else {
+ const parentEntry: FormattedBuilderEntry = {
+ operator: isOperator,
+ nested: 'parent',
+ field: isNewNestedEntry
+ ? undefined
+ : {
+ name: item.field ?? '',
+ aggregatable: false,
+ searchable: false,
+ type: 'string',
+ esTypes: ['nested'],
+ },
+ value: undefined,
+ entryIndex: index,
+ parent: undefined,
+ };
+
+ // User has selected to add a nested field, but not yet selected the field
+ if (isNewNestedEntry) {
+ return [...acc, parentEntry];
+ }
+
+ if (isEntryNested(item)) {
+ const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index);
+
+ return [...acc, parentEntry, ...nestedItems];
+ }
+
+ return [...acc];
+ }
+ }, []);
+};
+
+/**
+ * Determines whether an entire entry, exception item, or entry within a nested
+ * entry needs to be removed
+ *
+ * @param exceptionItem
+ * @param entryIndex index of given entry, for nested entries, this will correspond
+ * to their parent index
+ * @param nestedEntryIndex index of nested entry
+ *
+ */
+export const getUpdatedEntriesOnDelete = (
+ exceptionItem: ExceptionsBuilderExceptionItem,
+ entryIndex: number,
+ nestedEntryIndex: number | null
+): ExceptionsBuilderExceptionItem => {
+ const itemOfInterest: BuilderEntry = exceptionItem.entries[entryIndex];
+
+ if (nestedEntryIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) {
+ const updatedEntryEntries: Array = [
+ ...itemOfInterest.entries.slice(0, nestedEntryIndex),
+ ...itemOfInterest.entries.slice(nestedEntryIndex + 1),
+ ];
+
+ if (updatedEntryEntries.length === 0) {
+ return {
+ ...exceptionItem,
+ entries: [
+ ...exceptionItem.entries.slice(0, entryIndex),
+ ...exceptionItem.entries.slice(entryIndex + 1),
+ ],
+ };
+ } else {
+ const { field } = itemOfInterest;
+ const updatedItemOfInterest: EntryNested | EmptyNestedEntry = {
+ field,
+ type: OperatorTypeEnum.NESTED,
+ entries: updatedEntryEntries,
+ };
+
+ return {
+ ...exceptionItem,
+ entries: [
+ ...exceptionItem.entries.slice(0, entryIndex),
+ updatedItemOfInterest,
+ ...exceptionItem.entries.slice(entryIndex + 1),
+ ],
+ };
+ }
+ } else {
+ return {
+ ...exceptionItem,
+ entries: [
+ ...exceptionItem.entries.slice(0, entryIndex),
+ ...exceptionItem.entries.slice(entryIndex + 1),
+ ],
+ };
+ }
+};
+
+/**
+ * On operator change, determines whether value needs to be cleared or not
+ *
+ * @param field
+ * @param selectedOperator
+ * @param currentEntry
+ *
+ */
+export const getEntryFromOperator = (
+ selectedOperator: OperatorOption,
+ currentEntry: FormattedBuilderEntry
+): Entry => {
+ const isSameOperatorType = currentEntry.operator.type === selectedOperator.type;
+ const fieldValue = currentEntry.field != null ? currentEntry.field.name : '';
+ switch (selectedOperator.type) {
+ case 'match':
+ return {
+ field: fieldValue,
+ type: OperatorTypeEnum.MATCH,
+ operator: selectedOperator.operator,
+ value:
+ isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '',
+ };
+ case 'match_any':
+ return {
+ field: fieldValue,
+ type: OperatorTypeEnum.MATCH_ANY,
+ operator: selectedOperator.operator,
+ value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [],
+ };
+ case 'list':
+ return {
+ field: fieldValue,
+ type: OperatorTypeEnum.LIST,
+ operator: selectedOperator.operator,
+ list: { id: '', type: 'ip' },
+ };
+ default:
+ return {
+ field: fieldValue,
+ type: OperatorTypeEnum.EXISTS,
+ operator: selectedOperator.operator,
+ };
+ }
+};
+
+/**
+ * Determines which operators to make available
+ *
+ * @param item
+ * @param listType
+ *
+ */
+export const getOperatorOptions = (
+ item: FormattedBuilderEntry,
+ listType: ExceptionListType,
+ isBoolean: boolean
+): OperatorOption[] => {
+ if (item.nested === 'parent' || item.field == null) {
+ return [isOperator];
+ } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') {
+ return isBoolean ? [isOperator] : [isOperator, isOneOfOperator];
+ } else if (item.nested != null && listType === 'detection') {
+ return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator];
+ } else {
+ return isBoolean ? [isOperator, existsOperator] : EXCEPTION_OPERATORS;
+ }
+};
+
+/**
+ * Determines proper entry update when user selects new field
+ *
+ * @param item - current exception item entry values
+ * @param newField - newly selected field
+ *
+ */
+export const getEntryOnFieldChange = (
+ item: FormattedBuilderEntry,
+ newField: IFieldType
+): { updatedEntry: BuilderEntry; index: number } => {
+ const { parent, entryIndex, nested } = item;
+ const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : '';
+
+ if (nested === 'parent') {
+ // For nested entries, when user first selects to add a nested
+ // entry, they first see a row similiar to what is shown for when
+ // a user selects "exists", as soon as they make a selection
+ // we can now identify the 'parent' and 'child' this is where
+ // we first convert the entry into type "nested"
+ const newParentFieldValue =
+ newField.subType != null && newField.subType.nested != null
+ ? newField.subType.nested.path
+ : '';
+
+ return {
+ updatedEntry: {
+ field: newParentFieldValue,
+ type: OperatorTypeEnum.NESTED,
+ entries: [
+ {
+ field: newChildFieldValue ?? '',
+ type: OperatorTypeEnum.MATCH,
+ operator: isOperator.operator,
+ value: '',
+ },
+ ],
+ },
+ index: entryIndex,
+ };
+ } else if (nested === 'child' && parent != null) {
+ return {
+ updatedEntry: {
+ ...parent.parent,
+ entries: [
+ ...parent.parent.entries.slice(0, entryIndex),
+ {
+ field: newChildFieldValue ?? '',
+ type: OperatorTypeEnum.MATCH,
+ operator: isOperator.operator,
+ value: '',
+ },
+ ...parent.parent.entries.slice(entryIndex + 1),
+ ],
+ },
+ index: parent.parentIndex,
+ };
+ } else {
+ return {
+ updatedEntry: {
+ field: newField != null ? newField.name : '',
+ type: OperatorTypeEnum.MATCH,
+ operator: isOperator.operator,
+ value: '',
+ },
+ index: entryIndex,
+ };
+ }
+};
+
+/**
+ * Determines proper entry update when user selects new operator
+ *
+ * @param item - current exception item entry values
+ * @param newOperator - newly selected operator
+ *
+ */
+export const getEntryOnOperatorChange = (
+ item: FormattedBuilderEntry,
+ newOperator: OperatorOption
+): { updatedEntry: BuilderEntry; index: number } => {
+ const { parent, entryIndex, field, nested } = item;
+ const newEntry = getEntryFromOperator(newOperator, item);
+
+ if (!entriesList.is(newEntry) && nested != null && parent != null) {
+ return {
+ updatedEntry: {
+ ...parent.parent,
+ entries: [
+ ...parent.parent.entries.slice(0, entryIndex),
+ {
+ ...newEntry,
+ field: field != null ? field.name.split('.').slice(-1)[0] : '',
+ },
+ ...parent.parent.entries.slice(entryIndex + 1),
+ ],
+ },
+ index: parent.parentIndex,
+ };
+ } else {
+ return { updatedEntry: newEntry, index: entryIndex };
+ }
+};
+
+/**
+ * Determines proper entry update when user updates value
+ * when operator is of type "match"
+ *
+ * @param item - current exception item entry values
+ * @param newField - newly entered value
+ *
+ */
+export const getEntryOnMatchChange = (
+ item: FormattedBuilderEntry,
+ newField: string
+): { updatedEntry: BuilderEntry; index: number } => {
+ const { nested, parent, entryIndex, field, operator } = item;
+
+ if (nested != null && parent != null) {
+ const fieldName = field != null ? field.name.split('.').slice(-1)[0] : '';
+
+ return {
+ updatedEntry: {
+ ...parent.parent,
+ entries: [
+ ...parent.parent.entries.slice(0, entryIndex),
+ {
+ field: fieldName,
+ type: OperatorTypeEnum.MATCH,
+ operator: operator.operator,
+ value: newField,
+ },
+ ...parent.parent.entries.slice(entryIndex + 1),
+ ],
+ },
+ index: parent.parentIndex,
+ };
+ } else {
+ return {
+ updatedEntry: {
+ field: field != null ? field.name : '',
+ type: OperatorTypeEnum.MATCH,
+ operator: operator.operator,
+ value: newField,
+ },
+ index: entryIndex,
+ };
+ }
+};
+
+/**
+ * Determines proper entry update when user updates value
+ * when operator is of type "match_any"
+ *
+ * @param item - current exception item entry values
+ * @param newField - newly entered value
+ *
+ */
+export const getEntryOnMatchAnyChange = (
+ item: FormattedBuilderEntry,
+ newField: string[]
+): { updatedEntry: BuilderEntry; index: number } => {
+ const { nested, parent, entryIndex, field, operator } = item;
+
+ if (nested != null && parent != null) {
+ const fieldName = field != null ? field.name.split('.').slice(-1)[0] : '';
+
+ return {
+ updatedEntry: {
+ ...parent.parent,
+ entries: [
+ ...parent.parent.entries.slice(0, entryIndex),
+ {
+ field: fieldName,
+ type: OperatorTypeEnum.MATCH_ANY,
+ operator: operator.operator,
+ value: newField,
+ },
+ ...parent.parent.entries.slice(entryIndex + 1),
+ ],
+ },
+ index: parent.parentIndex,
+ };
+ } else {
+ return {
+ updatedEntry: {
+ field: field != null ? field.name : '',
+ type: OperatorTypeEnum.MATCH_ANY,
+ operator: operator.operator,
+ value: newField,
+ },
+ index: entryIndex,
+ };
+ }
+};
+
+/**
+ * Determines proper entry update when user updates value
+ * when operator is of type "list"
+ *
+ * @param item - current exception item entry values
+ * @param newField - newly selected list
+ *
+ */
+export const getEntryOnListChange = (
+ item: FormattedBuilderEntry,
+ newField: ListSchema
+): { updatedEntry: BuilderEntry; index: number } => {
+ const { entryIndex, field, operator } = item;
+ const { id, type } = newField;
+
+ return {
+ updatedEntry: {
+ field: field != null ? field.name : '',
+ type: OperatorTypeEnum.LIST,
+ operator: operator.operator,
+ list: { id, type },
+ },
+ index: entryIndex,
+ };
+};
+
+export const getDefaultEmptyEntry = (): EmptyEntry => ({
+ field: '',
+ type: OperatorTypeEnum.MATCH,
+ operator: OperatorEnum.INCLUDED,
+ value: '',
+});
+
+export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({
+ field: '',
+ type: OperatorTypeEnum.NESTED,
+ entries: [],
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx
index f6feca591dc6d..141429f152790 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx
@@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useCallback, useEffect, useState, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
@@ -17,11 +17,14 @@ import {
OperatorEnum,
CreateExceptionListItemSchema,
ExceptionListType,
+ entriesNested,
} from '../../../../../public/lists_plugin_deps';
import { AndOrBadge } from '../../and_or_badge';
import { BuilderButtonOptions } from './builder_button_options';
import { getNewExceptionItem, filterExceptionItems } from '../helpers';
import { ExceptionsBuilderExceptionItem, CreateExceptionListItemBuilderSchema } from '../types';
+import { State, exceptionsBuilderReducer } from './reducer';
+import { getDefaultEmptyEntry, getDefaultNestedEmptyEntry } from './helpers';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import exceptionableFields from '../exceptionable_fields.json';
@@ -39,6 +42,15 @@ const MyButtonsContainer = styled(EuiFlexItem)`
margin: 16px 0;
`;
+const initialState: State = {
+ disableAnd: false,
+ disableOr: false,
+ andLogicIncluded: false,
+ addNested: false,
+ exceptions: [],
+ exceptionsToDelete: [],
+};
+
interface OnChangeProps {
exceptionItems: Array;
exceptionsToDelete: ExceptionListItemSchema[];
@@ -53,6 +65,7 @@ interface ExceptionBuilderProps {
indexPatterns: IIndexPattern;
isOrDisabled: boolean;
isAndDisabled: boolean;
+ isNestedDisabled: boolean;
onChange: (arg: OnChangeProps) => void;
}
@@ -65,74 +78,144 @@ export const ExceptionBuilder = ({
indexPatterns,
isOrDisabled,
isAndDisabled,
+ isNestedDisabled,
onChange,
}: ExceptionBuilderProps) => {
- const [andLogicIncluded, setAndLogicIncluded] = useState(false);
- const [exceptions, setExceptions] = useState(
- exceptionListItems
+ const [
+ { exceptions, exceptionsToDelete, andLogicIncluded, disableAnd, disableOr, addNested },
+ dispatch,
+ ] = useReducer(exceptionsBuilderReducer(), {
+ ...initialState,
+ disableAnd: isAndDisabled,
+ disableOr: isOrDisabled,
+ });
+
+ const setUpdateExceptions = useCallback(
+ (items: ExceptionsBuilderExceptionItem[]): void => {
+ dispatch({
+ type: 'setExceptions',
+ exceptions: items,
+ });
+ },
+ [dispatch]
);
- const [exceptionsToDelete, setExceptionsToDelete] = useState([]);
- const handleCheckAndLogic = (items: ExceptionsBuilderExceptionItem[]): void => {
- setAndLogicIncluded(items.filter(({ entries }) => entries.length > 1).length > 0);
- };
+ const setDefaultExceptions = useCallback(
+ (item: ExceptionsBuilderExceptionItem): void => {
+ dispatch({
+ type: 'setDefault',
+ initialState,
+ lastException: item,
+ });
+ },
+ [dispatch]
+ );
- const handleDeleteExceptionItem = (
- item: ExceptionsBuilderExceptionItem,
- itemIndex: number
- ): void => {
- if (item.entries.length === 0) {
- if (exceptionListItemSchema.is(item)) {
- setExceptionsToDelete((items) => [...items, item]);
- }
+ const setUpdateExceptionsToDelete = useCallback(
+ (items: ExceptionListItemSchema[]): void => {
+ dispatch({
+ type: 'setExceptionsToDelete',
+ exceptions: items,
+ });
+ },
+ [dispatch]
+ );
- setExceptions((existingExceptions) => {
- const updatedExceptions = [
- ...existingExceptions.slice(0, itemIndex),
- ...existingExceptions.slice(itemIndex + 1),
- ];
- handleCheckAndLogic(updatedExceptions);
+ const setUpdateAndDisabled = useCallback(
+ (shouldDisable: boolean): void => {
+ dispatch({
+ type: 'setDisableAnd',
+ shouldDisable,
+ });
+ },
+ [dispatch]
+ );
- return updatedExceptions;
+ const setUpdateOrDisabled = useCallback(
+ (shouldDisable: boolean): void => {
+ dispatch({
+ type: 'setDisableOr',
+ shouldDisable,
});
- } else {
- handleExceptionItemChange(item, itemIndex);
- }
- };
+ },
+ [dispatch]
+ );
- const handleExceptionItemChange = (item: ExceptionsBuilderExceptionItem, index: number): void => {
- const updatedExceptions = [
- ...exceptions.slice(0, index),
- {
- ...item,
- },
- ...exceptions.slice(index + 1),
- ];
-
- handleCheckAndLogic(updatedExceptions);
- setExceptions(updatedExceptions);
- };
+ const setUpdateAddNested = useCallback(
+ (shouldAddNested: boolean): void => {
+ dispatch({
+ type: 'setAddNested',
+ addNested: shouldAddNested,
+ });
+ },
+ [dispatch]
+ );
+
+ const handleExceptionItemChange = useCallback(
+ (item: ExceptionsBuilderExceptionItem, index: number): void => {
+ const updatedExceptions = [
+ ...exceptions.slice(0, index),
+ {
+ ...item,
+ },
+ ...exceptions.slice(index + 1),
+ ];
+
+ setUpdateExceptions(updatedExceptions);
+ },
+ [setUpdateExceptions, exceptions]
+ );
+
+ const handleDeleteExceptionItem = useCallback(
+ (item: ExceptionsBuilderExceptionItem, itemIndex: number): void => {
+ if (item.entries.length === 0) {
+ const updatedExceptions = [
+ ...exceptions.slice(0, itemIndex),
+ ...exceptions.slice(itemIndex + 1),
+ ];
- const handleAddNewExceptionItemEntry = useCallback((): void => {
- setExceptions((existingExceptions): ExceptionsBuilderExceptionItem[] => {
- const lastException = existingExceptions[existingExceptions.length - 1];
+ // if it's the only exception item left, don't delete it
+ // just add a default entry to it
+ if (updatedExceptions.length === 0) {
+ setDefaultExceptions(item);
+ } else if (updatedExceptions.length > 0 && exceptionListItemSchema.is(item)) {
+ setUpdateExceptionsToDelete([...exceptionsToDelete, item]);
+ } else {
+ setUpdateExceptions([
+ ...exceptions.slice(0, itemIndex),
+ ...exceptions.slice(itemIndex + 1),
+ ]);
+ }
+ } else {
+ handleExceptionItemChange(item, itemIndex);
+ }
+ },
+ [
+ handleExceptionItemChange,
+ setUpdateExceptions,
+ setUpdateExceptionsToDelete,
+ exceptions,
+ exceptionsToDelete,
+ setDefaultExceptions,
+ ]
+ );
+
+ const handleAddNewExceptionItemEntry = useCallback(
+ (isNested = false): void => {
+ const lastException = exceptions[exceptions.length - 1];
const { entries } = lastException;
+
const updatedException: ExceptionsBuilderExceptionItem = {
...lastException,
- entries: [
- ...entries,
- { field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '' },
- ],
+ entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()],
};
- setAndLogicIncluded(updatedException.entries.length > 1);
+ // setAndLogicIncluded(updatedException.entries.length > 1);
- return [
- ...existingExceptions.slice(0, existingExceptions.length - 1),
- { ...updatedException },
- ];
- });
- }, [setExceptions, setAndLogicIncluded]);
+ setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]);
+ },
+ [setUpdateExceptions, exceptions]
+ );
const handleAddNewExceptionItem = useCallback((): void => {
// There is a case where there are numerous exception list items, all with
@@ -144,8 +227,8 @@ export const ExceptionBuilder = ({
namespaceType: listNamespaceType,
ruleName,
});
- setExceptions((existingExceptions) => [...existingExceptions, { ...newException }]);
- }, [setExceptions, listType, listId, listNamespaceType, ruleName]);
+ setUpdateExceptions([...exceptions, { ...newException }]);
+ }, [setUpdateExceptions, exceptions, listType, listId, listNamespaceType, ruleName]);
// Filters index pattern fields by exceptionable fields if list type is endpoint
const filterIndexPatterns = useMemo((): IIndexPattern => {
@@ -172,6 +255,55 @@ export const ExceptionBuilder = ({
}
};
+ const handleAddNestedExceptionItemEntry = useCallback((): void => {
+ const lastException = exceptions[exceptions.length - 1];
+ const { entries } = lastException;
+ const lastEntry = entries[entries.length - 1];
+
+ if (entriesNested.is(lastEntry)) {
+ const updatedException: ExceptionsBuilderExceptionItem = {
+ ...lastException,
+ entries: [
+ ...entries.slice(0, entries.length - 1),
+ {
+ ...lastEntry,
+ entries: [
+ ...lastEntry.entries,
+ {
+ field: '',
+ type: OperatorTypeEnum.MATCH,
+ operator: OperatorEnum.INCLUDED,
+ value: '',
+ },
+ ],
+ },
+ ],
+ };
+
+ setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]);
+ } else {
+ setUpdateExceptions(exceptions);
+ }
+ }, [setUpdateExceptions, exceptions]);
+
+ const handleAddNestedClick = useCallback((): void => {
+ setUpdateAddNested(true);
+ setUpdateOrDisabled(true);
+ setUpdateAndDisabled(true);
+ handleAddNewExceptionItemEntry(true);
+ }, [
+ handleAddNewExceptionItemEntry,
+ setUpdateAndDisabled,
+ setUpdateOrDisabled,
+ setUpdateAddNested,
+ ]);
+
+ const handleAddClick = useCallback((): void => {
+ setUpdateAddNested(false);
+ setUpdateOrDisabled(false);
+ handleAddNewExceptionItemEntry();
+ }, [handleAddNewExceptionItemEntry, setUpdateOrDisabled, setUpdateAddNested]);
+
// Bubble up changes to parent
useEffect(() => {
onChange({ exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete });
@@ -188,6 +320,13 @@ export const ExceptionBuilder = ({
}
}, [exceptions, handleAddNewExceptionItem]);
+ useEffect(() => {
+ if (exceptionListItems.length > 0) {
+ setUpdateExceptions(exceptionListItems);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
return (
{exceptions.map((exceptionListItem, index) => (
@@ -216,7 +355,8 @@ export const ExceptionBuilder = ({
exceptionItem={exceptionListItem}
exceptionId={getExceptionListItemId(exceptionListItem, index)}
indexPattern={filterIndexPatterns}
- isLoading={indexPatterns.fields.length === 0}
+ listType={listType}
+ addNested={addNested}
exceptionItemIndex={index}
andLogicIncluded={andLogicIncluded}
isOnlyItem={exceptions.length === 1}
@@ -237,12 +377,15 @@ export const ExceptionBuilder = ({
)}
{}}
+ onAndClicked={handleAddClick}
+ onNestedClicked={handleAddNestedClick}
+ onAddClickWhenNested={handleAddNestedExceptionItemEntry}
/>
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts
new file mode 100644
index 0000000000000..045ff458755b4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts
@@ -0,0 +1,106 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { ExceptionsBuilderExceptionItem } from '../types';
+import { ExceptionListItemSchema } from '../../../../../public/lists_plugin_deps';
+import { getDefaultEmptyEntry } from './helpers';
+
+export type ViewerModalName = 'addModal' | 'editModal' | null;
+
+export interface State {
+ disableAnd: boolean;
+ disableOr: boolean;
+ andLogicIncluded: boolean;
+ addNested: boolean;
+ exceptions: ExceptionsBuilderExceptionItem[];
+ exceptionsToDelete: ExceptionListItemSchema[];
+}
+
+export type Action =
+ | {
+ type: 'setExceptions';
+ exceptions: ExceptionsBuilderExceptionItem[];
+ }
+ | {
+ type: 'setExceptionsToDelete';
+ exceptions: ExceptionListItemSchema[];
+ }
+ | {
+ type: 'setDefault';
+ initialState: State;
+ lastException: ExceptionsBuilderExceptionItem;
+ }
+ | {
+ type: 'setDisableAnd';
+ shouldDisable: boolean;
+ }
+ | {
+ type: 'setDisableOr';
+ shouldDisable: boolean;
+ }
+ | {
+ type: 'setAddNested';
+ addNested: boolean;
+ };
+
+export const exceptionsBuilderReducer = () => (state: State, action: Action): State => {
+ switch (action.type) {
+ case 'setExceptions': {
+ const isAndLogicIncluded =
+ action.exceptions.filter(({ entries }) => entries.length > 1).length > 0;
+ const lastExceptionItem = action.exceptions.slice(-1)[0];
+ const isAddNested =
+ lastExceptionItem != null
+ ? lastExceptionItem.entries.slice(-1).filter(({ type }) => type === 'nested').length > 0
+ : false;
+ const lastEntry = lastExceptionItem != null ? lastExceptionItem.entries.slice(-1)[0] : null;
+ const isAndDisabled =
+ lastEntry != null && lastEntry.type === 'nested' && lastEntry.entries.length === 0;
+ const isOrDisabled = lastEntry != null && lastEntry.type === 'nested';
+
+ return {
+ ...state,
+ andLogicIncluded: isAndLogicIncluded,
+ exceptions: action.exceptions,
+ addNested: isAddNested,
+ disableAnd: isAndDisabled,
+ disableOr: isOrDisabled,
+ };
+ }
+ case 'setDefault': {
+ return {
+ ...state,
+ ...action.initialState,
+ exceptions: [{ ...action.lastException, entries: [getDefaultEmptyEntry()] }],
+ };
+ }
+ case 'setExceptionsToDelete': {
+ return {
+ ...state,
+ exceptionsToDelete: action.exceptions,
+ };
+ }
+ case 'setDisableAnd': {
+ return {
+ ...state,
+ disableAnd: action.shouldDisable,
+ };
+ }
+ case 'setDisableOr': {
+ return {
+ ...state,
+ disableOr: action.shouldDisable,
+ };
+ }
+ case 'setAddNested': {
+ return {
+ ...state,
+ addNested: action.addNested,
+ };
+ }
+ default:
+ return state;
+ }
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts
new file mode 100644
index 0000000000000..82cca2596da61
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const FIELD = i18n.translate('xpack.securitySolution.exceptions.builder.fieldDescription', {
+ defaultMessage: 'Field',
+});
+
+export const OPERATOR = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.operatorDescription',
+ {
+ defaultMessage: 'Operator',
+ }
+);
+
+export const VALUE = i18n.translate('xpack.securitySolution.exceptions.builder.valueDescription', {
+ defaultMessage: 'Value',
+});
+
+export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.exceptionFieldPlaceholderDescription',
+ {
+ defaultMessage: 'Search',
+ }
+);
+
+export const EXCEPTION_FIELD_NESTED_PLACEHOLDER = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.exceptionFieldNestedPlaceholderDescription',
+ {
+ defaultMessage: 'Search nested field',
+ }
+);
+
+export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.exceptionOperatorPlaceholderDescription',
+ {
+ defaultMessage: 'Operator',
+ }
+);
+
+export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.exceptionFieldValuePlaceholderDescription',
+ {
+ defaultMessage: 'Search field value...',
+ }
+);
+
+export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.exceptionListsPlaceholderDescription',
+ {
+ defaultMessage: 'Search for list...',
+ }
+);
+
+export const ADD_NESTED_DESCRIPTION = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.addNestedDescription',
+ {
+ defaultMessage: 'Add nested condition',
+ }
+);
+
+export const ADD_NON_NESTED_DESCRIPTION = i18n.translate(
+ 'xpack.securitySolution.exceptions.builder.addNonNestedDescription',
+ {
+ defaultMessage: 'Add non-nested condition',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx
index 2d12cfbec160a..4ad077edf66ff 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx
@@ -219,6 +219,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({
ruleName={ruleName}
isOrDisabled={false}
isAndDisabled={false}
+ isNestedDisabled={false}
data-test-subj="edit-exception-modal-builder"
id-aria="edit-exception-modal-builder"
onChange={handleBuilderOnChange}
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx
index dace2eb5f0672..78936d5d0da6f 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx
@@ -10,11 +10,8 @@ import moment from 'moment-timezone';
import {
getOperatorType,
getExceptionOperatorSelect,
- getFormattedEntries,
- formatEntry,
getOperatingSystems,
getTagsInclude,
- getDescriptionListContent,
getFormattedComments,
filterExceptionItems,
getNewExceptionItem,
@@ -27,7 +24,7 @@ import {
entryHasNonEcsType,
prepareExceptionItemsForBulkClose,
} from './helpers';
-import { FormattedEntry, DescriptionListItem, EmptyEntry } from './types';
+import { EmptyEntry } from './types';
import {
isOperator,
isNotOperator,
@@ -45,7 +42,6 @@ import { getEntryMatchAnyMock } from '../../../../../lists/common/schemas/types/
import { getEntryExistsMock } from '../../../../../lists/common/schemas/types/entry_exists.mock';
import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock';
import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock';
-import { getEntriesArrayMock } from '../../../../../lists/common/schemas/types/entries.mock';
import { ENTRIES } from '../../../../../lists/common/constants.mock';
import {
CreateExceptionListItemSchema,
@@ -155,112 +151,6 @@ describe('Exception helpers', () => {
});
});
- describe('#getFormattedEntries', () => {
- test('it returns empty array if no entries passed', () => {
- const result = getFormattedEntries([]);
-
- expect(result).toEqual([]);
- });
-
- test('it formats nested entries as expected', () => {
- const payload = [getEntryMatchMock()];
- const result = getFormattedEntries(payload);
- const expected: FormattedEntry[] = [
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'is',
- value: 'some host name',
- },
- ];
- expect(result).toEqual(expected);
- });
-
- test('it formats "exists" entries as expected', () => {
- const payload = [getEntryExistsMock()];
- const result = getFormattedEntries(payload);
- const expected: FormattedEntry[] = [
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'exists',
- value: undefined,
- },
- ];
- expect(result).toEqual(expected);
- });
-
- test('it formats non-nested entries as expected', () => {
- const payload = [getEntryMatchAnyMock(), getEntryMatchMock()];
- const result = getFormattedEntries(payload);
- const expected: FormattedEntry[] = [
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'is one of',
- value: ['some host name'],
- },
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'is',
- value: 'some host name',
- },
- ];
- expect(result).toEqual(expected);
- });
-
- test('it formats a mix of nested and non-nested entries as expected', () => {
- const payload = getEntriesArrayMock();
- const result = getFormattedEntries(payload);
- const expected: FormattedEntry[] = [
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'is',
- value: 'some host name',
- },
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'is one of',
- value: ['some host name'],
- },
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'is in list',
- value: 'some-list-id',
- },
- {
- fieldName: 'host.name',
- isNested: false,
- operator: 'exists',
- value: undefined,
- },
- {
- fieldName: 'host.name',
- isNested: false,
- operator: undefined,
- value: undefined,
- },
- {
- fieldName: 'host.name.host.name',
- isNested: true,
- operator: 'is',
- value: 'some host name',
- },
- {
- fieldName: 'host.name.host.name',
- isNested: true,
- operator: 'is one of',
- value: ['some host name'],
- },
- ];
- expect(result).toEqual(expected);
- });
- });
-
describe('#getEntryValue', () => {
it('returns "match" entry value', () => {
const payload = getEntryMatchMock();
@@ -291,34 +181,6 @@ describe('Exception helpers', () => {
});
});
- describe('#formatEntry', () => {
- test('it formats an entry', () => {
- const payload = getEntryMatchMock();
- const formattedEntry = formatEntry({ isNested: false, item: payload });
- const expected: FormattedEntry = {
- fieldName: 'host.name',
- isNested: false,
- operator: 'is',
- value: 'some host name',
- };
-
- expect(formattedEntry).toEqual(expected);
- });
-
- test('it formats as expected when "isNested" is "true"', () => {
- const payload = getEntryMatchMock();
- const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload });
- const expected: FormattedEntry = {
- fieldName: 'parent.host.name',
- isNested: true,
- operator: 'is',
- value: 'some host name',
- };
-
- expect(formattedEntry).toEqual(expected);
- });
- });
-
describe('#getOperatingSystems', () => {
test('it returns null if no operating system tag specified', () => {
const result = getOperatingSystems(['some tag', 'some other tag']);
@@ -389,72 +251,6 @@ describe('Exception helpers', () => {
});
});
- describe('#getDescriptionListContent', () => {
- test('it returns formatted description list with os if one is specified', () => {
- const payload = getExceptionListItemSchemaMock();
- payload.description = '';
- const result = getDescriptionListContent(payload);
- const expected: DescriptionListItem[] = [
- {
- description: 'Linux',
- title: 'OS',
- },
- {
- description: 'April 20th 2020 @ 15:25:31',
- title: 'Date created',
- },
- {
- description: 'some user',
- title: 'Created by',
- },
- ];
-
- expect(result).toEqual(expected);
- });
-
- test('it returns formatted description list with a description if one specified', () => {
- const payload = getExceptionListItemSchemaMock();
- payload._tags = [];
- payload.description = 'Im a description';
- const result = getDescriptionListContent(payload);
- const expected: DescriptionListItem[] = [
- {
- description: 'April 20th 2020 @ 15:25:31',
- title: 'Date created',
- },
- {
- description: 'some user',
- title: 'Created by',
- },
- {
- description: 'Im a description',
- title: 'Comment',
- },
- ];
-
- expect(result).toEqual(expected);
- });
-
- test('it returns just user and date created if no other fields specified', () => {
- const payload = getExceptionListItemSchemaMock();
- payload._tags = [];
- payload.description = '';
- const result = getDescriptionListContent(payload);
- const expected: DescriptionListItem[] = [
- {
- description: 'April 20th 2020 @ 15:25:31',
- title: 'Date created',
- },
- {
- description: 'some user',
- title: 'Created by',
- },
- ];
-
- expect(result).toEqual(expected);
- });
- });
-
describe('#getFormattedComments', () => {
test('it returns formatted comment object with username and timestamp', () => {
const payload = getCommentsArrayMock();
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
index 4d8fc5f68870b..384badefc34aa 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
@@ -12,10 +12,7 @@ import uuid from 'uuid';
import * as i18n from './translations';
import {
- FormattedEntry,
BuilderEntry,
- DescriptionListItem,
- FormattedBuilderEntry,
CreateExceptionListItemBuilderSchema,
ExceptionsBuilderExceptionItem,
} from './types';
@@ -38,7 +35,7 @@ import {
ExceptionListType,
EntryNested,
} from '../../../lists_plugin_deps';
-import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
+import { IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { validate } from '../../../../common/validate';
import { TimelineNonEcsData } from '../../../graphql/types';
import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard';
@@ -68,7 +65,7 @@ export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => {
* @param item a single ExceptionItem entry
*/
export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => {
- if (entriesNested.is(item)) {
+ if (item.type === 'nested') {
return isOperator;
} else {
const operatorType = getOperatorType(item);
@@ -81,39 +78,10 @@ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption =
};
/**
- * Formats ExceptionItem entries into simple field, operator, value
- * for use in rendering items in table
+ * Returns the fields corresponding value for an entry
*
- * @param entries an ExceptionItem's entries
+ * @param item a single ExceptionItem entry
*/
-export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => {
- const formattedEntries = entries.map((item) => {
- if (entriesNested.is(item)) {
- const parent = {
- fieldName: item.field,
- operator: undefined,
- value: undefined,
- isNested: false,
- };
- return item.entries.reduce(
- (acc, nestedEntry) => {
- const formattedEntry = formatEntry({
- isNested: true,
- parent: item.field,
- item: nestedEntry,
- });
- return [...acc, { ...formattedEntry }];
- },
- [parent]
- );
- } else {
- return formatEntry({ isNested: false, item });
- }
- });
-
- return formattedEntries.flat();
-};
-
export const getEntryValue = (item: BuilderEntry): string | string[] | undefined => {
switch (item.type) {
case OperatorTypeEnum.MATCH:
@@ -128,29 +96,6 @@ export const getEntryValue = (item: BuilderEntry): string | string[] | undefined
}
};
-/**
- * Helper method for `getFormattedEntries`
- */
-export const formatEntry = ({
- isNested,
- parent,
- item,
-}: {
- isNested: boolean;
- parent?: string;
- item: BuilderEntry;
-}): FormattedEntry => {
- const operator = getExceptionOperatorSelect(item);
- const value = getEntryValue(item);
-
- return {
- fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '',
- operator: operator.message,
- value,
- isNested,
- };
-};
-
/**
* Retrieves the values of tags marked as os
*
@@ -189,42 +134,6 @@ export const getTagsInclude = ({
return [matches != null, match];
};
-/**
- * Formats ExceptionItem information for description list component
- *
- * @param exceptionItem an ExceptionItem
- */
-export const getDescriptionListContent = (
- exceptionItem: ExceptionListItemSchema
-): DescriptionListItem[] => {
- const details = [
- {
- title: i18n.OPERATING_SYSTEM,
- value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])),
- },
- {
- title: i18n.DATE_CREATED,
- value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'),
- },
- {
- title: i18n.CREATED_BY,
- value: exceptionItem.created_by,
- },
- {
- title: i18n.COMMENT,
- value: exceptionItem.description,
- },
- ];
-
- return details.reduce((acc, { value, title }) => {
- if (value != null && value.trim() !== '') {
- return [...acc, { title, description: value }];
- } else {
- return acc;
- }
- }, []);
-};
-
/**
* Formats ExceptionItem.comments into EuiCommentList format
*
@@ -246,69 +155,6 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[]
),
}));
-export const getFormattedBuilderEntries = (
- indexPattern: IIndexPattern,
- entries: BuilderEntry[]
-): FormattedBuilderEntry[] => {
- const { fields } = indexPattern;
- return entries.map((item) => {
- if (entriesNested.is(item)) {
- return {
- parent: item.field,
- operator: isOperator,
- nested: getFormattedBuilderEntries(indexPattern, item.entries),
- field: undefined,
- value: undefined,
- };
- } else {
- const [selectedField] = fields.filter(
- ({ name }) => item.field != null && item.field === name
- );
- return {
- field: selectedField,
- operator: getExceptionOperatorSelect(item),
- value: getEntryValue(item),
- };
- }
- });
-};
-
-export const getValueFromOperator = (
- field: IFieldType | undefined,
- selectedOperator: OperatorOption
-): Entry => {
- const fieldValue = field != null ? field.name : '';
- switch (selectedOperator.type) {
- case 'match':
- return {
- field: fieldValue,
- type: OperatorTypeEnum.MATCH,
- operator: selectedOperator.operator,
- value: '',
- };
- case 'match_any':
- return {
- field: fieldValue,
- type: OperatorTypeEnum.MATCH_ANY,
- operator: selectedOperator.operator,
- value: [],
- };
- case 'list':
- return {
- field: fieldValue,
- type: OperatorTypeEnum.LIST,
- operator: selectedOperator.operator,
- list: { id: '', type: 'ip' },
- };
- default:
- return {
- field: fieldValue,
- type: OperatorTypeEnum.EXISTS,
- operator: selectedOperator.operator,
- };
- }
-};
-
export const getNewExceptionItem = ({
listType,
listId,
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts
index 870f98f63ee2c..87d2f9dcda935 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts
@@ -151,34 +151,6 @@ export const VALUE = i18n.translate('xpack.securitySolution.exceptions.valueDesc
defaultMessage: 'Value',
});
-export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate(
- 'xpack.securitySolution.exceptions.exceptionFieldPlaceholderDescription',
- {
- defaultMessage: 'Search',
- }
-);
-
-export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate(
- 'xpack.securitySolution.exceptions.exceptionOperatorPlaceholderDescription',
- {
- defaultMessage: 'Operator',
- }
-);
-
-export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate(
- 'xpack.securitySolution.exceptions.exceptionFieldValuePlaceholderDescription',
- {
- defaultMessage: 'Search field value...',
- }
-);
-
-export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate(
- 'xpack.securitySolution.exceptions.exceptionListsPlaceholderDescription',
- {
- defaultMessage: 'Search for list...',
- }
-);
-
export const AND = i18n.translate('xpack.securitySolution.exceptions.andDescription', {
defaultMessage: 'AND',
});
@@ -187,13 +159,6 @@ export const OR = i18n.translate('xpack.securitySolution.exceptions.orDescriptio
defaultMessage: 'OR',
});
-export const ADD_NESTED_DESCRIPTION = i18n.translate(
- 'xpack.securitySolution.exceptions.addNestedDescription',
- {
- defaultMessage: 'Add nested condition',
- }
-);
-
export const ADD_COMMENT_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.exceptions.viewer.addCommentPlaceholder',
{
@@ -207,3 +172,7 @@ export const ADD_TO_CLIPBOARD = i18n.translate(
defaultMessage: 'Add to clipboard',
}
);
+
+export const DESCRIPTION = i18n.translate('xpack.securitySolution.exceptions.descriptionLabel', {
+ defaultMessage: 'Description',
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts
index 994aed3952cf0..54caab03e615a 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts
@@ -9,6 +9,9 @@ import { OperatorOption } from '../autocomplete/types';
import {
EntryNested,
Entry,
+ EntryMatch,
+ EntryMatchAny,
+ EntryExists,
ExceptionListItemSchema,
CreateExceptionListItemSchema,
NamespaceType,
@@ -52,15 +55,13 @@ export interface ExceptionsPagination {
pageSizeOptions: number[];
}
-export interface FormattedBuilderEntryBase {
+export interface FormattedBuilderEntry {
field: IFieldType | undefined;
operator: OperatorOption;
value: string | string[] | undefined;
-}
-
-export interface FormattedBuilderEntry extends FormattedBuilderEntryBase {
- parent?: string;
- nested?: FormattedBuilderEntryBase[];
+ nested: 'parent' | 'child' | undefined;
+ entryIndex: number;
+ parent: { parent: EntryNested; parentIndex: number } | undefined;
}
export interface EmptyEntry {
@@ -77,7 +78,13 @@ export interface EmptyListEntry {
list: { id: string | undefined; type: string | undefined };
}
-export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested;
+export interface EmptyNestedEntry {
+ field: string | undefined;
+ type: OperatorTypeEnum.NESTED;
+ entries: Array;
+}
+
+export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested | EmptyNestedEntry;
export type ExceptionListItemBuilderSchema = Omit & {
entries: BuilderEntry[];
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx
index bf07ff21823eb..cb1a80abedb27 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx
@@ -144,7 +144,7 @@ describe('useAddOrUpdateException', () => {
await act(async () => {
const { result, waitForNextUpdate } = render();
await waitForNextUpdate();
- expect(result.current).toEqual([{ isLoading: false }, null]);
+ expect(result.current).toEqual([{ isLoading: false }, result.current[1]]);
});
});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx
index 55c3ea35716d5..9d45a411b5130 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState, useCallback } from 'react';
import { HttpStart } from '../../../../../../../src/core/public';
import {
@@ -60,7 +60,19 @@ export const useAddOrUpdateException = ({
onSuccess,
}: UseAddOrUpdateExceptionProps): ReturnUseAddOrUpdateException => {
const [isLoading, setIsLoading] = useState(false);
- const addOrUpdateException = useRef(null);
+ const addOrUpdateExceptionRef = useRef(null);
+ const addOrUpdateException = useCallback(
+ async (exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex) => {
+ if (addOrUpdateExceptionRef.current !== null) {
+ addOrUpdateExceptionRef.current(
+ exceptionItemsToAddOrUpdate,
+ alertIdToClose,
+ bulkCloseIndex
+ );
+ }
+ },
+ []
+ );
useEffect(() => {
let isSubscribed = true;
@@ -114,6 +126,7 @@ export const useAddOrUpdateException = ({
await updateAlertStatus({
query: getUpdateAlertsQuery([alertIdToClose]),
status: 'closed',
+ signal: abortCtrl.signal,
});
}
@@ -131,6 +144,7 @@ export const useAddOrUpdateException = ({
query: filter,
},
status: 'closed',
+ signal: abortCtrl.signal,
});
}
@@ -148,12 +162,12 @@ export const useAddOrUpdateException = ({
}
};
- addOrUpdateException.current = addOrUpdateExceptionItems;
+ addOrUpdateExceptionRef.current = addOrUpdateExceptionItems;
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, [http, onSuccess, onError]);
- return [{ isLoading }, addOrUpdateException.current];
+ return [{ isLoading }, addOrUpdateException];
};
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx
index 4fc744c2c9d01..8df7b51bb9d31 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx
@@ -211,7 +211,7 @@ describe('ExceptionDetails', () => {
);
- expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Comment');
+ expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Description');
expect(wrapper.find('EuiDescriptionListDescription').at(3).text()).toEqual('some description');
});
});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx
index 44632236ea7a0..cca7d76899a19 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx
@@ -16,7 +16,7 @@ import React, { useMemo, Fragment } from 'react';
import styled, { css } from 'styled-components';
import { DescriptionListItem } from '../../types';
-import { getDescriptionListContent } from '../../helpers';
+import { getDescriptionListContent } from '../helpers';
import * as i18n from '../../translations';
import { ExceptionListItemSchema } from '../../../../../../public/lists_plugin_deps';
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx
index 3b85c6741a480..13a90091ba4c8 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx
@@ -17,7 +17,8 @@ import styled from 'styled-components';
import { ExceptionDetails } from './exception_details';
import { ExceptionEntries } from './exception_entries';
-import { getFormattedEntries, getFormattedComments } from '../../helpers';
+import { getFormattedComments } from '../../helpers';
+import { getFormattedEntries } from '../helpers';
import { FormattedEntry, ExceptionListItemIdentifiers } from '../../types';
import { ExceptionListItemSchema } from '../../../../../../public/lists_plugin_deps';
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx
new file mode 100644
index 0000000000000..fe00e3530fa83
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx
@@ -0,0 +1,224 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import moment from 'moment-timezone';
+
+import { getFormattedEntries, formatEntry, getDescriptionListContent } from './helpers';
+import { FormattedEntry, DescriptionListItem } from '../types';
+import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
+import { getEntriesArrayMock } from '../../../../../../lists/common/schemas/types/entries.mock';
+import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock';
+import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock';
+import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock';
+
+describe('Exception viewer helpers', () => {
+ beforeEach(() => {
+ moment.tz.setDefault('UTC');
+ });
+
+ afterEach(() => {
+ moment.tz.setDefault('Browser');
+ });
+
+ describe('#getFormattedEntries', () => {
+ test('it returns empty array if no entries passed', () => {
+ const result = getFormattedEntries([]);
+
+ expect(result).toEqual([]);
+ });
+
+ test('it formats nested entries as expected', () => {
+ const payload = [getEntryMatchMock()];
+ const result = getFormattedEntries(payload);
+ const expected: FormattedEntry[] = [
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'is',
+ value: 'some host name',
+ },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ test('it formats "exists" entries as expected', () => {
+ const payload = [getEntryExistsMock()];
+ const result = getFormattedEntries(payload);
+ const expected: FormattedEntry[] = [
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'exists',
+ value: undefined,
+ },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ test('it formats non-nested entries as expected', () => {
+ const payload = [getEntryMatchAnyMock(), getEntryMatchMock()];
+ const result = getFormattedEntries(payload);
+ const expected: FormattedEntry[] = [
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'is one of',
+ value: ['some host name'],
+ },
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'is',
+ value: 'some host name',
+ },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ test('it formats a mix of nested and non-nested entries as expected', () => {
+ const payload = getEntriesArrayMock();
+ const result = getFormattedEntries(payload);
+ const expected: FormattedEntry[] = [
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'is',
+ value: 'some host name',
+ },
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'is one of',
+ value: ['some host name'],
+ },
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'is in list',
+ value: 'some-list-id',
+ },
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'exists',
+ value: undefined,
+ },
+ {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: undefined,
+ value: undefined,
+ },
+ {
+ fieldName: 'host.name.host.name',
+ isNested: true,
+ operator: 'is',
+ value: 'some host name',
+ },
+ {
+ fieldName: 'host.name.host.name',
+ isNested: true,
+ operator: 'is one of',
+ value: ['some host name'],
+ },
+ ];
+ expect(result).toEqual(expected);
+ });
+ });
+
+ describe('#formatEntry', () => {
+ test('it formats an entry', () => {
+ const payload = getEntryMatchMock();
+ const formattedEntry = formatEntry({ isNested: false, item: payload });
+ const expected: FormattedEntry = {
+ fieldName: 'host.name',
+ isNested: false,
+ operator: 'is',
+ value: 'some host name',
+ };
+
+ expect(formattedEntry).toEqual(expected);
+ });
+
+ test('it formats as expected when "isNested" is "true"', () => {
+ const payload = getEntryMatchMock();
+ const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload });
+ const expected: FormattedEntry = {
+ fieldName: 'parent.host.name',
+ isNested: true,
+ operator: 'is',
+ value: 'some host name',
+ };
+
+ expect(formattedEntry).toEqual(expected);
+ });
+ });
+
+ describe('#getDescriptionListContent', () => {
+ test('it returns formatted description list with os if one is specified', () => {
+ const payload = getExceptionListItemSchemaMock();
+ payload.description = '';
+ const result = getDescriptionListContent(payload);
+ const expected: DescriptionListItem[] = [
+ {
+ description: 'Linux',
+ title: 'OS',
+ },
+ {
+ description: 'April 20th 2020 @ 15:25:31',
+ title: 'Date created',
+ },
+ {
+ description: 'some user',
+ title: 'Created by',
+ },
+ ];
+
+ expect(result).toEqual(expected);
+ });
+
+ test('it returns formatted description list with a description if one specified', () => {
+ const payload = getExceptionListItemSchemaMock();
+ payload._tags = [];
+ payload.description = 'Im a description';
+ const result = getDescriptionListContent(payload);
+ const expected: DescriptionListItem[] = [
+ {
+ description: 'April 20th 2020 @ 15:25:31',
+ title: 'Date created',
+ },
+ {
+ description: 'some user',
+ title: 'Created by',
+ },
+ {
+ description: 'Im a description',
+ title: 'Description',
+ },
+ ];
+
+ expect(result).toEqual(expected);
+ });
+
+ test('it returns just user and date created if no other fields specified', () => {
+ const payload = getExceptionListItemSchemaMock();
+ payload._tags = [];
+ payload.description = '';
+ const result = getDescriptionListContent(payload);
+ const expected: DescriptionListItem[] = [
+ {
+ description: 'April 20th 2020 @ 15:25:31',
+ title: 'Date created',
+ },
+ {
+ description: 'some user',
+ title: 'Created by',
+ },
+ ];
+
+ expect(result).toEqual(expected);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx
new file mode 100644
index 0000000000000..345db5bf1e75e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx
@@ -0,0 +1,109 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import moment from 'moment';
+
+import { entriesNested, ExceptionListItemSchema } from '../../../../lists_plugin_deps';
+import {
+ getEntryValue,
+ getExceptionOperatorSelect,
+ formatOperatingSystems,
+ getOperatingSystems,
+} from '../helpers';
+import { FormattedEntry, BuilderEntry, DescriptionListItem } from '../types';
+import * as i18n from '../translations';
+
+/**
+ * Helper method for `getFormattedEntries`
+ */
+export const formatEntry = ({
+ isNested,
+ parent,
+ item,
+}: {
+ isNested: boolean;
+ parent?: string;
+ item: BuilderEntry;
+}): FormattedEntry => {
+ const operator = getExceptionOperatorSelect(item);
+ const value = getEntryValue(item);
+
+ return {
+ fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '',
+ operator: operator.message,
+ value,
+ isNested,
+ };
+};
+
+/**
+ * Formats ExceptionItem entries into simple field, operator, value
+ * for use in rendering items in table
+ *
+ * @param entries an ExceptionItem's entries
+ */
+export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => {
+ const formattedEntries = entries.map((item) => {
+ if (entriesNested.is(item)) {
+ const parent = {
+ fieldName: item.field,
+ operator: undefined,
+ value: undefined,
+ isNested: false,
+ };
+ return item.entries.reduce(
+ (acc, nestedEntry) => {
+ const formattedEntry = formatEntry({
+ isNested: true,
+ parent: item.field,
+ item: nestedEntry,
+ });
+ return [...acc, { ...formattedEntry }];
+ },
+ [parent]
+ );
+ } else {
+ return formatEntry({ isNested: false, item });
+ }
+ });
+
+ return formattedEntries.flat();
+};
+
+/**
+ * Formats ExceptionItem details for description list component
+ *
+ * @param exceptionItem an ExceptionItem
+ */
+export const getDescriptionListContent = (
+ exceptionItem: ExceptionListItemSchema
+): DescriptionListItem[] => {
+ const details = [
+ {
+ title: i18n.OPERATING_SYSTEM,
+ value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])),
+ },
+ {
+ title: i18n.DATE_CREATED,
+ value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'),
+ },
+ {
+ title: i18n.CREATED_BY,
+ value: exceptionItem.created_by,
+ },
+ {
+ title: i18n.DESCRIPTION,
+ value: exceptionItem.description,
+ },
+ ];
+
+ return details.reduce((acc, { value, title }) => {
+ if (value != null && value.trim() !== '') {
+ return [...acc, { title, description: value }];
+ } else {
+ return acc;
+ }
+ }, []);
+};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts
index 19fcf65ec0c5e..513d6a93d1b5b 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts
@@ -69,7 +69,8 @@ export const sampleDocNoSortIdNoVersion = (someUuid: string = sampleIdGuid): Sig
export const sampleDocWithSortId = (
someUuid: string = sampleIdGuid,
- ip?: string
+ ip?: string,
+ destIp?: string
): SignalSourceHit => ({
_index: 'myFakeSignalIndex',
_type: 'doc',
@@ -82,6 +83,9 @@ export const sampleDocWithSortId = (
source: {
ip: ip ?? '127.0.0.1',
},
+ destination: {
+ ip: destIp ?? '127.0.0.1',
+ },
},
sort: ['1234567891111'],
});
@@ -307,7 +311,8 @@ export const repeatedSearchResultsWithSortId = (
total: number,
pageSize: number,
guids: string[],
- ips?: string[]
+ ips?: string[],
+ destIps?: string[]
) => ({
took: 10,
timed_out: false,
@@ -321,7 +326,11 @@ export const repeatedSearchResultsWithSortId = (
total,
max_score: 100,
hits: Array.from({ length: pageSize }).map((x, index) => ({
- ...sampleDocWithSortId(guids[index], ips ? ips[index] : '127.0.0.1'),
+ ...sampleDocWithSortId(
+ guids[index],
+ ips ? ips[index] : '127.0.0.1',
+ destIps ? destIps[index] : '127.0.0.1'
+ ),
})),
},
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts
index 9eebb91c32652..8c39a254e4261 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts
@@ -44,6 +44,25 @@ describe('filterEventsAgainstList', () => {
expect(res.hits.hits.length).toEqual(4);
});
+ it('should respond with eventSearchResult if exceptionList does not contain value list exceptions', async () => {
+ const res = await filterEventsAgainstList({
+ logger: mockLogger,
+ listClient,
+ exceptionsList: [getExceptionListItemSchemaMock()],
+ eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [
+ '1.1.1.1',
+ '2.2.2.2',
+ '3.3.3.3',
+ '7.7.7.7',
+ ]),
+ buildRuleMessage,
+ });
+ expect(res.hits.hits.length).toEqual(4);
+ expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[0][0]).toContain(
+ 'no exception items of type list found - returning original search result'
+ );
+ });
+
describe('operator_type is included', () => {
it('should respond with same list if no items match value list', async () => {
const exceptionItem = getExceptionListItemSchemaMock();
@@ -106,6 +125,280 @@ describe('filterEventsAgainstList', () => {
'ci-badguys.txt'
);
expect(res.hits.hits.length).toEqual(2);
+
+ // @ts-ignore
+ const ipVals = res.hits.hits.map((item) => item._source.source.ip);
+ expect(['3.3.3.3', '7.7.7.7']).toEqual(ipVals);
+ });
+
+ it('should respond with less items in the list given two exception items with entries of type list if some values match', async () => {
+ const exceptionItem = getExceptionListItemSchemaMock();
+ exceptionItem.entries = [
+ {
+ field: 'source.ip',
+ operator: 'included',
+ type: 'list',
+ list: {
+ id: 'ci-badguys.txt',
+ type: 'ip',
+ },
+ },
+ ];
+
+ const exceptionItemAgain = getExceptionListItemSchemaMock();
+ exceptionItemAgain.entries = [
+ {
+ field: 'source.ip',
+ operator: 'included',
+ type: 'list',
+ list: {
+ id: 'ci-badguys-again.txt',
+ type: 'ip',
+ },
+ },
+ ];
+
+ // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4']
+ (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([
+ { ...getListItemResponseMock(), value: '2.2.2.2' },
+ { ...getListItemResponseMock(), value: '4.4.4.4' },
+ ]);
+ // this call represents an exception list with a value list containing ['6.6.6.6']
+ (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([
+ { ...getListItemResponseMock(), value: '6.6.6.6' },
+ ]);
+
+ const res = await filterEventsAgainstList({
+ logger: mockLogger,
+ listClient,
+ exceptionsList: [exceptionItem, exceptionItemAgain],
+ eventSearchResult: repeatedSearchResultsWithSortId(9, 9, someGuids.slice(0, 9), [
+ '1.1.1.1',
+ '2.2.2.2',
+ '3.3.3.3',
+ '4.4.4.4',
+ '5.5.5.5',
+ '6.6.6.6',
+ '7.7.7.7',
+ '8.8.8.8',
+ '9.9.9.9',
+ ]),
+ buildRuleMessage,
+ });
+ expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2);
+ expect(res.hits.hits.length).toEqual(6);
+
+ // @ts-ignore
+ const ipVals = res.hits.hits.map((item) => item._source.source.ip);
+ expect(['1.1.1.1', '3.3.3.3', '5.5.5.5', '7.7.7.7', '8.8.8.8', '9.9.9.9']).toEqual(ipVals);
+ });
+
+ it('should respond with less items in the list given two exception items, each with one entry of type list if some values match', async () => {
+ const exceptionItem = getExceptionListItemSchemaMock();
+ exceptionItem.entries = [
+ {
+ field: 'source.ip',
+ operator: 'included',
+ type: 'list',
+ list: {
+ id: 'ci-badguys.txt',
+ type: 'ip',
+ },
+ },
+ ];
+
+ const exceptionItemAgain = getExceptionListItemSchemaMock();
+ exceptionItemAgain.entries = [
+ {
+ field: 'source.ip',
+ operator: 'included',
+ type: 'list',
+ list: {
+ id: 'ci-badguys-again.txt',
+ type: 'ip',
+ },
+ },
+ ];
+
+ // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4']
+ (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([
+ { ...getListItemResponseMock(), value: '2.2.2.2' },
+ ]);
+ // this call represents an exception list with a value list containing ['6.6.6.6']
+ (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([
+ { ...getListItemResponseMock(), value: '6.6.6.6' },
+ ]);
+
+ const res = await filterEventsAgainstList({
+ logger: mockLogger,
+ listClient,
+ exceptionsList: [exceptionItem, exceptionItemAgain],
+ eventSearchResult: repeatedSearchResultsWithSortId(9, 9, someGuids.slice(0, 9), [
+ '1.1.1.1',
+ '2.2.2.2',
+ '3.3.3.3',
+ '4.4.4.4',
+ '5.5.5.5',
+ '6.6.6.6',
+ '7.7.7.7',
+ '8.8.8.8',
+ '9.9.9.9',
+ ]),
+ buildRuleMessage,
+ });
+ expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2);
+ // @ts-ignore
+ const ipVals = res.hits.hits.map((item) => item._source.source.ip);
+ expect(res.hits.hits.length).toEqual(7);
+
+ expect(['1.1.1.1', '3.3.3.3', '4.4.4.4', '5.5.5.5', '7.7.7.7', '8.8.8.8', '9.9.9.9']).toEqual(
+ ipVals
+ );
+ });
+
+ it('should respond with less items in the list given one exception item with two entries of type list only if source.ip and destination.ip are in the events', async () => {
+ const exceptionItem = getExceptionListItemSchemaMock();
+ exceptionItem.entries = [
+ {
+ field: 'source.ip',
+ operator: 'included',
+ type: 'list',
+ list: {
+ id: 'ci-badguys.txt',
+ type: 'ip',
+ },
+ },
+ {
+ field: 'destination.ip',
+ operator: 'included',
+ type: 'list',
+ list: {
+ id: 'ci-badguys-again.txt',
+ type: 'ip',
+ },
+ },
+ ];
+
+ // this call represents an exception list with a value list containing ['2.2.2.2']
+ (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([
+ { ...getListItemResponseMock(), value: '2.2.2.2' },
+ ]);
+ // this call represents an exception list with a value list containing ['4.4.4.4']
+ (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([
+ { ...getListItemResponseMock(), value: '4.4.4.4' },
+ ]);
+
+ const res = await filterEventsAgainstList({
+ logger: mockLogger,
+ listClient,
+ exceptionsList: [exceptionItem],
+ eventSearchResult: repeatedSearchResultsWithSortId(
+ 9,
+ 9,
+ someGuids.slice(0, 9),
+ [
+ '1.1.1.1',
+ '2.2.2.2',
+ '3.3.3.3',
+ '4.4.4.4',
+ '5.5.5.5',
+ '6.6.6.6',
+ '2.2.2.2',
+ '8.8.8.8',
+ '9.9.9.9',
+ ],
+ [
+ '2.2.2.2',
+ '2.2.2.2',
+ '2.2.2.2',
+ '2.2.2.2',
+ '2.2.2.2',
+ '2.2.2.2',
+ '4.4.4.4',
+ '2.2.2.2',
+ '2.2.2.2',
+ ]
+ ),
+ buildRuleMessage,
+ });
+ expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2);
+ expect(res.hits.hits.length).toEqual(8);
+
+ // @ts-ignore
+ const ipVals = res.hits.hits.map((item) => item._source.source.ip);
+ expect([
+ '1.1.1.1',
+ '2.2.2.2',
+ '3.3.3.3',
+ '4.4.4.4',
+ '5.5.5.5',
+ '6.6.6.6',
+ '8.8.8.8',
+ '9.9.9.9',
+ ]).toEqual(ipVals);
+ });
+
+ it('should respond with the same items in the list given one exception item with two entries of type list where the entries are included and excluded', async () => {
+ const exceptionItem = getExceptionListItemSchemaMock();
+ exceptionItem.entries = [
+ {
+ field: 'source.ip',
+ operator: 'included',
+ type: 'list',
+ list: {
+ id: 'ci-badguys.txt',
+ type: 'ip',
+ },
+ },
+ {
+ field: 'source.ip',
+ operator: 'excluded',
+ type: 'list',
+ list: {
+ id: 'ci-badguys-again.txt',
+ type: 'ip',
+ },
+ },
+ ];
+
+ // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4']
+ (listClient.getListItemByValues as jest.Mock).mockResolvedValue([
+ { ...getListItemResponseMock(), value: '2.2.2.2' },
+ ]);
+
+ const res = await filterEventsAgainstList({
+ logger: mockLogger,
+ listClient,
+ exceptionsList: [exceptionItem],
+ eventSearchResult: repeatedSearchResultsWithSortId(9, 9, someGuids.slice(0, 9), [
+ '1.1.1.1',
+ '2.2.2.2',
+ '3.3.3.3',
+ '4.4.4.4',
+ '5.5.5.5',
+ '6.6.6.6',
+ '7.7.7.7',
+ '8.8.8.8',
+ '9.9.9.9',
+ ]),
+ buildRuleMessage,
+ });
+ expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2);
+ expect(res.hits.hits.length).toEqual(9);
+
+ // @ts-ignore
+ const ipVals = res.hits.hits.map((item) => item._source.source.ip);
+ expect([
+ '1.1.1.1',
+ '2.2.2.2',
+ '3.3.3.3',
+ '4.4.4.4',
+ '5.5.5.5',
+ '6.6.6.6',
+ '7.7.7.7',
+ '8.8.8.8',
+ '9.9.9.9',
+ ]).toEqual(ipVals);
});
});
describe('operator type is excluded', () => {
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts
index ea52aecb379fa..262af5d88e227 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts
@@ -10,9 +10,10 @@ import { ListClient } from '../../../../../lists/server';
import { SignalSearchResponse, SearchTypes } from './types';
import { BuildRuleMessage } from './rule_messages';
import {
- entriesList,
EntryList,
ExceptionListItemSchema,
+ entriesList,
+ Type,
} from '../../../../../lists/common/schemas';
import { hasLargeValueList } from '../../../../common/detection_engine/utils';
@@ -24,6 +25,51 @@ interface FilterEventsAgainstList {
buildRuleMessage: BuildRuleMessage;
}
+export const createSetToFilterAgainst = async ({
+ events,
+ field,
+ listId,
+ listType,
+ listClient,
+ logger,
+ buildRuleMessage,
+}: {
+ events: SignalSearchResponse['hits']['hits'];
+ field: string;
+ listId: string;
+ listType: Type;
+ listClient: ListClient;
+ logger: Logger;
+ buildRuleMessage: BuildRuleMessage;
+}): Promise> => {
+ // narrow unioned type to be single
+ const isStringableType = (val: SearchTypes) =>
+ ['string', 'number', 'boolean'].includes(typeof val);
+ const valuesFromSearchResultField = events.reduce((acc, searchResultItem) => {
+ const valueField = get(field, searchResultItem._source);
+ if (valueField != null && isStringableType(valueField)) {
+ acc.add(valueField.toString());
+ }
+ return acc;
+ }, new Set());
+ logger.debug(
+ `number of distinct values from ${field}: ${[...valuesFromSearchResultField].length}`
+ );
+
+ // matched will contain any list items that matched with the
+ // values passed in from the Set.
+ const matchedListItems = await listClient.getListItemByValues({
+ listId,
+ type: listType,
+ value: [...valuesFromSearchResultField],
+ });
+
+ logger.debug(`number of matched items from list with id ${listId}: ${matchedListItems.length}`);
+ // create a set of list values that were a hit - easier to work with
+ const matchedListItemsSet = new Set(matchedListItems.map((item) => item.value));
+ return matchedListItemsSet;
+};
+
export const filterEventsAgainstList = async ({
listClient,
exceptionsList,
@@ -32,7 +78,6 @@ export const filterEventsAgainstList = async ({
buildRuleMessage,
}: FilterEventsAgainstList): Promise => {
try {
- logger.debug(buildRuleMessage(`exceptionsList: ${JSON.stringify(exceptionsList, null, 2)}`));
if (exceptionsList == null || exceptionsList.length === 0) {
logger.debug(buildRuleMessage('about to return original search result'));
return eventSearchResult;
@@ -51,87 +96,97 @@ export const filterEventsAgainstList = async ({
);
if (exceptionItemsWithLargeValueLists.length === 0) {
- logger.debug(buildRuleMessage('about to return original search result'));
+ logger.debug(
+ buildRuleMessage('no exception items of type list found - returning original search result')
+ );
return eventSearchResult;
}
- // narrow unioned type to be single
- const isStringableType = (val: SearchTypes) =>
- ['string', 'number', 'boolean'].includes(typeof val);
- // grab the signals with values found in the given exception lists.
- const filteredHitsPromises = exceptionItemsWithLargeValueLists.map(
- async (exceptionItem: ExceptionListItemSchema) => {
- const { entries } = exceptionItem;
-
- const filteredHitsEntries = entries
- .filter((t): t is EntryList => entriesList.is(t))
- .map(async (entry) => {
+ const valueListExceptionItems = exceptionsList.filter((listItem: ExceptionListItemSchema) => {
+ return listItem.entries.every((entry) => entriesList.is(entry));
+ });
+
+ // now that we have all the exception items which are value lists (whether single entry or have multiple entries)
+ const res = await valueListExceptionItems.reduce>(
+ async (
+ filteredAccum: Promise,
+ exceptionItem: ExceptionListItemSchema
+ ) => {
+ // 1. acquire the values from the specified fields to check
+ // e.g. if the value list is checking against source.ip, gather
+ // all the values for source.ip from the search response events.
+
+ // 2. search against the value list with the values found in the search result
+ // and see if there are any matches. For every match, add that value to a set
+ // that represents the "matched" values
+
+ // 3. filter the search result against the set from step 2 using the
+ // given operator (included vs excluded).
+ // acquire the list values we are checking for in the field.
+ const filtered = await filteredAccum;
+ const typedEntries = exceptionItem.entries.filter((entry): entry is EntryList =>
+ entriesList.is(entry)
+ );
+ const fieldAndSetTuples = await Promise.all(
+ typedEntries.map(async (entry) => {
const { list, field, operator } = entry;
const { id, type } = list;
-
- // acquire the list values we are checking for.
- const valuesOfGivenType = eventSearchResult.hits.hits.reduce(
- (acc, searchResultItem) => {
- const valueField = get(field, searchResultItem._source);
-
- if (valueField != null && isStringableType(valueField)) {
- acc.add(valueField.toString());
- }
- return acc;
- },
- new Set()
- );
-
- // matched will contain any list items that matched with the
- // values passed in from the Set.
- const matchedListItems = await listClient.getListItemByValues({
+ const matchedSet = await createSetToFilterAgainst({
+ events: filtered,
+ field,
listId: id,
- type,
- value: [...valuesOfGivenType],
+ listType: type,
+ listClient,
+ logger,
+ buildRuleMessage,
});
- // create a set of list values that were a hit - easier to work with
- const matchedListItemsSet = new Set(
- matchedListItems.map((item) => item.value)
- );
-
- // do a single search after with these values.
- // painless script to do nested query in elasticsearch
- // filter out the search results that match with the values found in the list.
- const filteredEvents = eventSearchResult.hits.hits.filter((item) => {
- const eventItem = get(entry.field, item._source);
- if (operator === 'included') {
- if (eventItem != null) {
- return !matchedListItemsSet.has(eventItem);
- }
- } else if (operator === 'excluded') {
- if (eventItem != null) {
- return matchedListItemsSet.has(eventItem);
- }
+ return Promise.resolve({ field, operator, matchedSet });
+ })
+ );
+
+ // check if for each tuple, the entry is not in both for when two value list entries exist.
+ // need to re-write this as a reduce.
+ const filteredEvents = filtered.filter((item) => {
+ const vals = fieldAndSetTuples.map((tuple) => {
+ const eventItem = get(tuple.field, item._source);
+ if (tuple.operator === 'included') {
+ // only create a signal if the event is not in the value list
+ if (eventItem != null) {
+ return !tuple.matchedSet.has(eventItem);
}
- return false;
- });
- const diff = eventSearchResult.hits.hits.length - filteredEvents.length;
- logger.debug(buildRuleMessage(`Lists filtered out ${diff} events`));
- return filteredEvents;
+ return true;
+ } else if (tuple.operator === 'excluded') {
+ // only create a signal if the event is in the value list
+ if (eventItem != null) {
+ return tuple.matchedSet.has(eventItem);
+ }
+ return true;
+ }
+ return false;
});
-
- return (await Promise.all(filteredHitsEntries)).flat();
- }
+ return vals.some((value) => value);
+ });
+ const diff = eventSearchResult.hits.hits.length - filteredEvents.length;
+ logger.debug(
+ buildRuleMessage(`Exception with id ${exceptionItem.id} filtered out ${diff} events`)
+ );
+ const toReturn = filteredEvents;
+ return toReturn;
+ },
+ Promise.resolve(eventSearchResult.hits.hits)
);
- const filteredHits = await Promise.all(filteredHitsPromises);
const toReturn: SignalSearchResponse = {
took: eventSearchResult.took,
timed_out: eventSearchResult.timed_out,
_shards: eventSearchResult._shards,
hits: {
- total: filteredHits.length,
+ total: res.length,
max_score: eventSearchResult.hits.max_score,
- hits: filteredHits.flat(),
+ hits: res,
},
};
-
return toReturn;
} catch (exc) {
throw new Error(`Failed to query lists index. Reason: ${exc.message}`);
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
index 3312191c3b41b..58dcd7f6bd1c1 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts
@@ -475,7 +475,7 @@ describe('searchAfterAndBulkCreate', () => {
expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000'));
// I don't like testing log statements since logs change but this is the best
// way I can think of to ensure this section is getting hit with this test case.
- expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[7][0]).toContain(
+ expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[8][0]).toContain(
'sortIds was empty on searchResult'
);
});
@@ -558,7 +558,7 @@ describe('searchAfterAndBulkCreate', () => {
expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000'));
// I don't like testing log statements since logs change but this is the best
// way I can think of to ensure this section is getting hit with this test case.
- expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[12][0]).toContain(
+ expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[15][0]).toContain(
'sortIds was empty on filteredEvents'
);
});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts
index 3d4e7384714eb..74709f31563ee 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts
@@ -83,6 +83,7 @@ export const singleBulkCreate = async ({
throttle,
}: SingleBulkCreateParams): Promise => {
filteredEvents.hits.hits = filterDuplicateRules(id, filteredEvents);
+ logger.debug(`about to bulk create ${filteredEvents.hits.hits.length} events`);
if (filteredEvents.hits.hits.length === 0) {
logger.debug(`all events were duplicates`);
return { success: true, createdItemsCount: 0 };
@@ -135,6 +136,8 @@ export const singleBulkCreate = async ({
logger.debug(`took property says bulk took: ${response.took} milliseconds`);
if (response.errors) {
+ const duplicateSignalsCount = countBy(response.items, 'create.status')['409'];
+ logger.debug(`ignored ${duplicateSignalsCount} duplicate signals`);
const errorCountByMessage = errorAggregator(response, [409]);
if (!isEmpty(errorCountByMessage)) {
logger.error(
@@ -144,6 +147,6 @@ export const singleBulkCreate = async ({
}
const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0;
-
+ logger.debug(`bulk created ${createdItemsCount} signals`);
return { success: true, bulkCreateDuration: makeFloatString(end - start), createdItemsCount };
};
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 1d8d93e7c961d..846330146cf07 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -360,6 +360,21 @@
"core.fatalErrors.tryRefreshingPageDescription": "ページを更新してみてください。うまくいかない場合は、前のページに戻るか、セッションデータを消去してください。",
"core.notifications.errorToast.closeModal": "閉じる",
"core.notifications.unableUpdateUISettingNotificationMessageTitle": "UI 設定を更新できません",
+ "core.statusPage.loadStatus.serverIsDownErrorMessage": "サーバーステータスのリクエストに失敗しました。サーバーがダウンしている可能性があります。",
+ "core.statusPage.loadStatus.serverStatusCodeErrorMessage": "サーバーステータスのリクエストに失敗しました。ステータスコード: {responseStatus}",
+ "core.statusPage.metricsTiles.columns.heapTotalHeader": "ヒープ合計",
+ "core.statusPage.metricsTiles.columns.heapUsedHeader": "使用ヒープ",
+ "core.statusPage.metricsTiles.columns.loadHeader": "読み込み",
+ "core.statusPage.metricsTiles.columns.requestsPerSecHeader": "1 秒あたりのリクエスト",
+ "core.statusPage.metricsTiles.columns.resTimeAvgHeader": "平均応答時間",
+ "core.statusPage.metricsTiles.columns.resTimeMaxHeader": "最長応答時間",
+ "core.statusPage.serverStatus.statusTitle": "Kibana のステータス: {kibanaStatus}",
+ "core.statusPage.statusApp.loadingErrorText": "ステータスの読み込み中にエラーが発生しました",
+ "core.statusPage.statusApp.statusActions.buildText": "{buildNum} を作成",
+ "core.statusPage.statusApp.statusActions.commitText": "{buildSha} を確定",
+ "core.statusPage.statusApp.statusTitle": "プラグインステータス",
+ "core.statusPage.statusTable.columns.idHeader": "ID",
+ "core.statusPage.statusTable.columns.statusHeader": "ステータス",
"core.toasts.errorToast.seeFullError": "完全なエラーを表示",
"core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel": "ホームページに移動",
"core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle": "Elasticに確認する",
@@ -2470,21 +2485,6 @@
"server.status.redTitle": "赤",
"server.status.uninitializedTitle": "アンインストールしました",
"server.status.yellowTitle": "黄色",
- "statusPage.loadStatus.serverIsDownErrorMessage": "サーバーステータスのリクエストに失敗しました。サーバーがダウンしている可能性があります。",
- "statusPage.loadStatus.serverStatusCodeErrorMessage": "サーバーステータスのリクエストに失敗しました。ステータスコード: {responseStatus}",
- "statusPage.metricsTiles.columns.heapTotalHeader": "ヒープ合計",
- "statusPage.metricsTiles.columns.heapUsedHeader": "使用ヒープ",
- "statusPage.metricsTiles.columns.loadHeader": "読み込み",
- "statusPage.metricsTiles.columns.requestsPerSecHeader": "1 秒あたりのリクエスト",
- "statusPage.metricsTiles.columns.resTimeAvgHeader": "平均応答時間",
- "statusPage.metricsTiles.columns.resTimeMaxHeader": "最長応答時間",
- "statusPage.serverStatus.statusTitle": "Kibana のステータス: {kibanaStatus}",
- "statusPage.statusApp.loadingErrorText": "ステータスの読み込み中にエラーが発生しました",
- "statusPage.statusApp.statusActions.buildText": "{buildNum} を作成",
- "statusPage.statusApp.statusActions.commitText": "{buildSha} を確定",
- "statusPage.statusApp.statusTitle": "プラグインステータス",
- "statusPage.statusTable.columns.idHeader": "ID",
- "statusPage.statusTable.columns.statusHeader": "ステータス",
"telemetry.callout.appliesSettingTitle": "この設定に加えた変更は {allOfKibanaText} に適用され、自動的に保存されます。",
"telemetry.callout.appliesSettingTitle.allOfKibanaText": "Kibana のすべて",
"telemetry.callout.clusterStatisticsDescription": "これは収集される基本的なクラスター統計の例です。インデックス、シャード、ノードの数が含まれます。監視がオンになっているかどうかなどのハイレベルの使用統計も含まれます。",
@@ -7415,10 +7415,6 @@
"xpack.infra.logs.analysis.anomalySectionNoDataTitle": "表示するデータがありません。",
"xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML ジョブが手動またはリソース不足により停止しました。新しいログエントリーはジョブが再起動するまで処理されません。",
"xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML ジョブが停止しました",
- "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "本機能は機械学習ジョブを利用し、そのステータスと結果にアクセスするためには、少なくとも{machineLearningUserRole}ロールが必要です。",
- "xpack.infra.logs.analysis.missingMlResultsPrivilegesTitle": "追加の機械学習の権限が必要です",
- "xpack.infra.logs.analysis.missingMlSetupPrivilegesBody": "本機能は機械学習ジョブを利用し、設定には{machineLearningAdminRole}ロールが必要です。",
- "xpack.infra.logs.analysis.missingMlSetupPrivilegesTitle": "追加の機械学習の権限が必要です",
"xpack.infra.logs.analysis.mlAppButton": "機械学習を開く",
"xpack.infra.logs.analysis.mlUnavailableBody": "詳細は{machineLearningAppLink}をご覧ください。",
"xpack.infra.logs.analysis.mlUnavailableTitle": "この機能には機械学習が必要です",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 0ea2c9f17e257..477858d2e74d1 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -360,6 +360,21 @@
"core.fatalErrors.tryRefreshingPageDescription": "请尝试刷新页面。如果无效,请返回上一页或清除您的会话数据。",
"core.notifications.errorToast.closeModal": "关闭",
"core.notifications.unableUpdateUISettingNotificationMessageTitle": "无法更新 UI 设置",
+ "core.statusPage.loadStatus.serverIsDownErrorMessage": "无法请求服务器状态。也许您的服务器已关闭?",
+ "core.statusPage.loadStatus.serverStatusCodeErrorMessage": "无法使用状态代码 {responseStatus} 请求服务器状态",
+ "core.statusPage.metricsTiles.columns.heapTotalHeader": "堆总计",
+ "core.statusPage.metricsTiles.columns.heapUsedHeader": "已使用堆",
+ "core.statusPage.metricsTiles.columns.loadHeader": "负载",
+ "core.statusPage.metricsTiles.columns.requestsPerSecHeader": "每秒请求数",
+ "core.statusPage.metricsTiles.columns.resTimeAvgHeader": "响应时间平均值",
+ "core.statusPage.metricsTiles.columns.resTimeMaxHeader": "响应时间最大值",
+ "core.statusPage.serverStatus.statusTitle": "Kibana 状态为“{kibanaStatus}”",
+ "core.statusPage.statusApp.loadingErrorText": "加载状态时出错",
+ "core.statusPage.statusApp.statusActions.buildText": "BUILD {buildNum}",
+ "core.statusPage.statusApp.statusActions.commitText": "COMMIT {buildSha}",
+ "core.statusPage.statusApp.statusTitle": "插件状态",
+ "core.statusPage.statusTable.columns.idHeader": "ID",
+ "core.statusPage.statusTable.columns.statusHeader": "状态",
"core.toasts.errorToast.seeFullError": "请参阅完整的错误信息",
"core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel": "前往主页",
"core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle": "问询 Elastic",
@@ -2473,21 +2488,6 @@
"server.status.redTitle": "红",
"server.status.uninitializedTitle": "未初始化",
"server.status.yellowTitle": "黄",
- "statusPage.loadStatus.serverIsDownErrorMessage": "无法请求服务器状态。也许您的服务器已关闭?",
- "statusPage.loadStatus.serverStatusCodeErrorMessage": "无法使用状态代码 {responseStatus} 请求服务器状态",
- "statusPage.metricsTiles.columns.heapTotalHeader": "堆总计",
- "statusPage.metricsTiles.columns.heapUsedHeader": "已使用堆",
- "statusPage.metricsTiles.columns.loadHeader": "负载",
- "statusPage.metricsTiles.columns.requestsPerSecHeader": "每秒请求数",
- "statusPage.metricsTiles.columns.resTimeAvgHeader": "响应时间平均值",
- "statusPage.metricsTiles.columns.resTimeMaxHeader": "响应时间最大值",
- "statusPage.serverStatus.statusTitle": "Kibana 状态为“{kibanaStatus}”",
- "statusPage.statusApp.loadingErrorText": "加载状态时出错",
- "statusPage.statusApp.statusActions.buildText": "BUILD {buildNum}",
- "statusPage.statusApp.statusActions.commitText": "COMMIT {buildSha}",
- "statusPage.statusApp.statusTitle": "插件状态",
- "statusPage.statusTable.columns.idHeader": "ID",
- "statusPage.statusTable.columns.statusHeader": "状态",
"telemetry.callout.appliesSettingTitle": "对此设置的更改将应用到{allOfKibanaText} 且会自动保存。",
"telemetry.callout.appliesSettingTitle.allOfKibanaText": "整个 Kibana",
"telemetry.callout.clusterStatisticsDescription": "这是我们将收集的基本集群统计信息的示例。其包括索引、分片和节点的数目。还包括概括性的使用情况统计信息,例如监测是否打开。",
@@ -7420,10 +7420,6 @@
"xpack.infra.logs.analysis.anomalySectionNoDataTitle": "没有可显示的数据。",
"xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML 作业已手动停止或由于缺乏资源而停止。作业重新启动后,才会处理新的日志条目。",
"xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML 作业已停止",
- "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "此功能使用 Machine Learning 作业,要访问这些作业的状态和结果,至少需要 {machineLearningUserRole} 角色。",
- "xpack.infra.logs.analysis.missingMlResultsPrivilegesTitle": "需要额外的 Machine Learning 权限",
- "xpack.infra.logs.analysis.missingMlSetupPrivilegesBody": "此功能使用 Machine Learning 作业,这需要 {machineLearningAdminRole} 角色才能设置。",
- "xpack.infra.logs.analysis.missingMlSetupPrivilegesTitle": "需要额外的 Machine Learning 权限",
"xpack.infra.logs.analysis.mlAppButton": "打开 Machine Learning",
"xpack.infra.logs.analysis.mlUnavailableBody": "查看 {machineLearningAppLink} 以获取更多信息。",
"xpack.infra.logs.analysis.mlUnavailableTitle": "此功能需要 Machine Learning",
diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap
index fcf68ad97c8ce..1b5856bf1f9e2 100644
--- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap
+++ b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap
@@ -80,28 +80,25 @@ Array [
class="euiFlexItem euiFlexItem--flexGrowZero"
>
-
+
{
}, [dispatch, page, search, sort.direction, sort.field, lastRefresh]);
const { data: certificates } = useSelector(certificatesSelector);
+ const history = useHistory();
return (
<>
-
-
- {labels.RETURN_TO_OVERVIEW}
-
-
+
+ {labels.RETURN_TO_OVERVIEW}
+
-
-
- {labels.SETTINGS_ON_CERT}
-
-
+
+ {labels.SETTINGS_ON_CERT}
+
diff --git a/x-pack/plugins/uptime/public/pages/not_found.tsx b/x-pack/plugins/uptime/public/pages/not_found.tsx
index 0576a79999a50..264a2b6b682c8 100644
--- a/x-pack/plugins/uptime/public/pages/not_found.tsx
+++ b/x-pack/plugins/uptime/public/pages/not_found.tsx
@@ -13,38 +13,39 @@ import {
EuiButton,
} from '@elastic/eui';
import React from 'react';
-import { Link } from 'react-router-dom';
+import { useHistory } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
-export const NotFoundPage = () => (
-
-
-
-
-
-
-
-
- }
- body={
-
-
+export const NotFoundPage = () => {
+ const history = useHistory();
+ return (
+
+
+
+
+
+
+
+
+ }
+ body={
+
-
- }
- />
-
-
-
-);
+ }
+ />
+
+
+
+ );
+};
diff --git a/x-pack/plugins/uptime/public/pages/page_header.tsx b/x-pack/plugins/uptime/public/pages/page_header.tsx
index 421e0e3a4ebde..16279a63b5f40 100644
--- a/x-pack/plugins/uptime/public/pages/page_header.tsx
+++ b/x-pack/plugins/uptime/public/pages/page_header.tsx
@@ -7,7 +7,7 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer, EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { Link } from 'react-router-dom';
+import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { UptimeDatePicker } from '../components/common/uptime_date_picker';
import { SETTINGS_ROUTE } from '../../common/constants';
@@ -58,6 +58,7 @@ export const PageHeader = React.memo(
) : null;
const kibana = useKibana();
+ const history = useHistory();
const extraLinkComponents = !extraLinks ? null : (
@@ -65,11 +66,13 @@ export const PageHeader = React.memo(
-
-
- {SETTINGS_LINK_TEXT}
-
-
+
+ {SETTINGS_LINK_TEXT}
+
{
>
);
+ const history = useHistory();
+
return (
<>
-
-
- {Translations.settings.returnToOverviewLinkLabel}
-
-
+
+ {Translations.settings.returnToOverviewLinkLabel}
+
diff --git a/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap b/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap
index 6fe2c8eaa362d..1e7ea536bae79 100644
--- a/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap
+++ b/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap
@@ -2,6 +2,7 @@
exports[`overview filters action creators creates a fail action 1`] = `
Object {
+ "error": true,
"payload": [Error: There was an error retrieving the overview filters],
"type": "FETCH_OVERVIEW_FILTERS_FAIL",
}
diff --git a/x-pack/plugins/uptime/public/state/actions/overview_filters.ts b/x-pack/plugins/uptime/public/state/actions/overview_filters.ts
index 8eefa701a240a..1dcf49414c413 100644
--- a/x-pack/plugins/uptime/public/state/actions/overview_filters.ts
+++ b/x-pack/plugins/uptime/public/state/actions/overview_filters.ts
@@ -4,13 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { createAction } from 'redux-actions';
import { OverviewFilters } from '../../../common/runtime_types';
-export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS';
-export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL';
-export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS';
-export const SET_OVERVIEW_FILTERS = 'SET_OVERVIEW_FILTERS';
-
export interface GetOverviewFiltersPayload {
dateRangeStart: string;
dateRangeEnd: string;
@@ -22,52 +18,16 @@ export interface GetOverviewFiltersPayload {
tags: string[];
}
-interface GetOverviewFiltersFetchAction {
- type: typeof FETCH_OVERVIEW_FILTERS;
- payload: GetOverviewFiltersPayload;
-}
-
-interface GetOverviewFiltersSuccessAction {
- type: typeof FETCH_OVERVIEW_FILTERS_SUCCESS;
- payload: OverviewFilters;
-}
-
-interface GetOverviewFiltersFailAction {
- type: typeof FETCH_OVERVIEW_FILTERS_FAIL;
- payload: Error;
-}
-
-interface SetOverviewFiltersAction {
- type: typeof SET_OVERVIEW_FILTERS;
- payload: OverviewFilters;
-}
-
-export type OverviewFiltersAction =
- | GetOverviewFiltersFetchAction
- | GetOverviewFiltersSuccessAction
- | GetOverviewFiltersFailAction
- | SetOverviewFiltersAction;
+export type OverviewFiltersPayload = GetOverviewFiltersPayload & Error & OverviewFilters;
-export const fetchOverviewFilters = (
- payload: GetOverviewFiltersPayload
-): GetOverviewFiltersFetchAction => ({
- type: FETCH_OVERVIEW_FILTERS,
- payload,
-});
+export const fetchOverviewFilters = createAction(
+ 'FETCH_OVERVIEW_FILTERS'
+);
-export const fetchOverviewFiltersFail = (error: Error): GetOverviewFiltersFailAction => ({
- type: FETCH_OVERVIEW_FILTERS_FAIL,
- payload: error,
-});
+export const fetchOverviewFiltersFail = createAction('FETCH_OVERVIEW_FILTERS_FAIL');
-export const fetchOverviewFiltersSuccess = (
- filters: OverviewFilters
-): GetOverviewFiltersSuccessAction => ({
- type: FETCH_OVERVIEW_FILTERS_SUCCESS,
- payload: filters,
-});
+export const fetchOverviewFiltersSuccess = createAction(
+ 'FETCH_OVERVIEW_FILTERS_SUCCESS'
+);
-export const setOverviewFilters = (filters: OverviewFilters): SetOverviewFiltersAction => ({
- type: SET_OVERVIEW_FILTERS,
- payload: filters,
-});
+export const setOverviewFilters = createAction('SET_OVERVIEW_FILTERS');
diff --git a/x-pack/plugins/uptime/public/state/effects/overview_filters.ts b/x-pack/plugins/uptime/public/state/effects/overview_filters.ts
index 92b578bafed2d..9149f20f233c6 100644
--- a/x-pack/plugins/uptime/public/state/effects/overview_filters.ts
+++ b/x-pack/plugins/uptime/public/state/effects/overview_filters.ts
@@ -6,7 +6,7 @@
import { takeLatest } from 'redux-saga/effects';
import {
- FETCH_OVERVIEW_FILTERS,
+ fetchOverviewFilters as fetchAction,
fetchOverviewFiltersFail,
fetchOverviewFiltersSuccess,
} from '../actions';
@@ -15,7 +15,7 @@ import { fetchEffectFactory } from './fetch_effect';
export function* fetchOverviewFiltersEffect() {
yield takeLatest(
- FETCH_OVERVIEW_FILTERS,
+ String(fetchAction),
fetchEffectFactory(fetchOverviewFilters, fetchOverviewFiltersSuccess, fetchOverviewFiltersFail)
);
}
diff --git a/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts b/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts
index 4548627d9dcb8..702518b69cba5 100644
--- a/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts
+++ b/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts
@@ -4,13 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { handleActions, Action } from 'redux-actions';
import { OverviewFilters } from '../../../common/runtime_types';
import {
- FETCH_OVERVIEW_FILTERS,
- FETCH_OVERVIEW_FILTERS_FAIL,
- FETCH_OVERVIEW_FILTERS_SUCCESS,
- OverviewFiltersAction,
- SET_OVERVIEW_FILTERS,
+ fetchOverviewFilters,
+ fetchOverviewFiltersFail,
+ fetchOverviewFiltersSuccess,
+ setOverviewFilters,
+ GetOverviewFiltersPayload,
+ OverviewFiltersPayload,
} from '../actions';
export interface OverviewFiltersState {
@@ -30,34 +32,29 @@ const initialState: OverviewFiltersState = {
loading: false,
};
-export function overviewFiltersReducer(
- state = initialState,
- action: OverviewFiltersAction
-): OverviewFiltersState {
- switch (action.type) {
- case FETCH_OVERVIEW_FILTERS:
- return {
- ...state,
- loading: true,
- };
- case FETCH_OVERVIEW_FILTERS_SUCCESS:
- return {
- ...state,
- filters: action.payload,
- loading: false,
- };
- case FETCH_OVERVIEW_FILTERS_FAIL:
- return {
- ...state,
- errors: [...state.errors, action.payload],
- loading: false,
- };
- case SET_OVERVIEW_FILTERS:
- return {
- ...state,
- filters: action.payload,
- };
- default:
- return state;
- }
-}
+export const overviewFiltersReducer = handleActions(
+ {
+ [String(fetchOverviewFilters)]: (state, _action: Action) => ({
+ ...state,
+ loading: true,
+ }),
+
+ [String(fetchOverviewFiltersSuccess)]: (state, action: Action) => ({
+ ...state,
+ filters: action.payload,
+ loading: false,
+ }),
+
+ [String(fetchOverviewFiltersFail)]: (state, action: Action) => ({
+ ...state,
+ errors: [...state.errors, action.payload],
+ loading: false,
+ }),
+
+ [String(setOverviewFilters)]: (state, action: Action) => ({
+ ...state,
+ filters: action.payload,
+ }),
+ },
+ initialState
+);
diff --git a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts
index 69eeaafbf64fa..99f51ff244546 100644
--- a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts
+++ b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts
@@ -22,6 +22,7 @@ interface GetEventLogParams {
export async function getEventLog(params: GetEventLogParams): Promise {
const { getService, spaceId, type, id, provider, actions } = params;
const supertest = getService('supertest');
+ const actionsSet = new Set(actions);
const spacePrefix = getUrlPrefix(spaceId);
const url = `${spacePrefix}/api/event_log/${type}/${id}/_find`;
@@ -31,11 +32,13 @@ export async function getEventLog(params: GetEventLogParams): Promise event?.event?.provider === provider
- );
+ // filter events to matching provider and requested actions
+ const events: IValidatedEvent[] = (result.data as IValidatedEvent[])
+ .filter((event) => event?.event?.provider === provider)
+ .filter((event) => event?.event?.action)
+ .filter((event) => actionsSet.has(event?.event?.action!));
const foundActions = new Set(
- events.map((event) => event?.event?.action).filter((event) => !!event)
+ events.map((event) => event?.event?.action).filter((action) => !!action)
);
for (const action of actions) {
diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts
index ffa9855478a05..8d8bc066a9b1a 100644
--- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts
+++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts
@@ -31,8 +31,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
const esTestIndexTool = new ESTestIndexTool(es, retry);
const taskManagerUtils = new TaskManagerUtils(es, retry);
- // FLAKY: https://github.com/elastic/kibana/issues/72207
- describe.skip('alerts', () => {
+ describe('alerts', () => {
const authorizationIndex = '.kibana-test-authorization';
const objectRemover = new ObjectRemover(supertest);
diff --git a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts
index ba68b9b7ba6ee..b37522ed52b5c 100644
--- a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts
+++ b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts
@@ -9,7 +9,10 @@ import { createHash } from 'crypto';
import { inflateSync } from 'zlib';
import { FtrProviderContext } from '../../../ftr_provider_context';
-import { getSupertestWithoutAuth, setupIngest } from '../../fleet/agents/services';
+import {
+ getSupertestWithoutAuth,
+ setupIngest,
+} from '../../../../ingest_manager_api_integration/apis/fleet/agents/services';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js
index ce0e534d8a750..05b305ccd833f 100644
--- a/x-pack/test/api_integration/apis/index.js
+++ b/x-pack/test/api_integration/apis/index.js
@@ -26,11 +26,9 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./security_solution'));
loadTestFile(require.resolve('./short_urls'));
loadTestFile(require.resolve('./lens'));
- loadTestFile(require.resolve('./fleet'));
loadTestFile(require.resolve('./ml'));
loadTestFile(require.resolve('./transform'));
loadTestFile(require.resolve('./endpoint'));
- loadTestFile(require.resolve('./ingest_manager'));
loadTestFile(require.resolve('./lists'));
loadTestFile(require.resolve('./upgrade_assistant'));
});
diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts
index 6693561076fdd..99549be8c1868 100644
--- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts
+++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts
@@ -10,5 +10,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('data frame analytics', function () {
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./delete'));
+ loadTestFile(require.resolve('./update'));
});
}
diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts
new file mode 100644
index 0000000000000..5dc781657619d
--- /dev/null
+++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts
@@ -0,0 +1,275 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import expect from '@kbn/expect';
+import { FtrProviderContext } from '../../../ftr_provider_context';
+import { USER } from '../../../../functional/services/ml/security_common';
+import { DataFrameAnalyticsConfig } from '../../../../../plugins/ml/public/application/data_frame_analytics/common';
+import { DeepPartial } from '../../../../../plugins/ml/common/types/common';
+import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common';
+
+export default ({ getService }: FtrProviderContext) => {
+ const esArchiver = getService('esArchiver');
+ const supertest = getService('supertestWithoutAuth');
+ const ml = getService('ml');
+
+ const jobId = `bm_${Date.now()}`;
+ const generateDestinationIndex = (analyticsId: string) => `user-${analyticsId}`;
+ const commonJobConfig = {
+ source: {
+ index: ['ft_bank_marketing'],
+ query: {
+ match_all: {},
+ },
+ },
+ analysis: {
+ classification: {
+ dependent_variable: 'y',
+ training_percent: 20,
+ },
+ },
+ analyzed_fields: {
+ includes: [],
+ excludes: [],
+ },
+ model_memory_limit: '60mb',
+ allow_lazy_start: false, // default value
+ max_num_threads: 1, // default value
+ };
+
+ const testJobConfigs: Array> = [
+ 'Test update job',
+ 'Test update job description only',
+ 'Test update job allow_lazy_start only',
+ 'Test update job model_memory_limit only',
+ 'Test update job max_num_threads only',
+ ].map((description, idx) => {
+ const analyticsId = `${jobId}_${idx}`;
+ return {
+ id: analyticsId,
+ description,
+ dest: {
+ index: generateDestinationIndex(analyticsId),
+ results_field: 'ml',
+ },
+ ...commonJobConfig,
+ };
+ });
+
+ const editedDescription = 'Edited description';
+
+ async function createJobs(mockJobConfigs: Array>) {
+ for (const jobConfig of mockJobConfigs) {
+ await ml.api.createDataFrameAnalyticsJob(jobConfig as DataFrameAnalyticsConfig);
+ }
+ }
+
+ async function getDFAJob(id: string) {
+ const { body } = await supertest
+ .get(`/api/ml/data_frame/analytics/${id}`)
+ .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
+ .set(COMMON_REQUEST_HEADERS);
+
+ return body.data_frame_analytics[0];
+ }
+
+ describe('UPDATE data_frame/analytics', () => {
+ before(async () => {
+ await esArchiver.loadIfNeeded('ml/bm_classification');
+ await ml.testResources.setKibanaTimeZoneToUTC();
+ await createJobs(testJobConfigs);
+ });
+
+ after(async () => {
+ await ml.api.cleanMlIndices();
+ });
+
+ describe('UpdateDataFrameAnalytics', () => {
+ it('should update all editable fields of analytics job for specified id', async () => {
+ const analyticsId = `${jobId}_0`;
+
+ const requestBody = {
+ description: editedDescription,
+ model_memory_limit: '61mb',
+ allow_lazy_start: true,
+ max_num_threads: 2,
+ };
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`)
+ .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(200);
+
+ expect(body).not.to.be(undefined);
+
+ const fetchedJob = await getDFAJob(analyticsId);
+
+ expect(fetchedJob.description).to.eql(requestBody.description);
+ expect(fetchedJob.allow_lazy_start).to.eql(requestBody.allow_lazy_start);
+ expect(fetchedJob.model_memory_limit).to.eql(requestBody.model_memory_limit);
+ expect(fetchedJob.max_num_threads).to.eql(requestBody.max_num_threads);
+ });
+
+ it('should only update description field of analytics job when description is sent in request', async () => {
+ const analyticsId = `${jobId}_1`;
+
+ const requestBody = {
+ description: 'Edited description for job 1',
+ };
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`)
+ .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(200);
+
+ expect(body).not.to.be(undefined);
+
+ const fetchedJob = await getDFAJob(analyticsId);
+
+ expect(fetchedJob.description).to.eql(requestBody.description);
+ expect(fetchedJob.allow_lazy_start).to.eql(commonJobConfig.allow_lazy_start);
+ expect(fetchedJob.model_memory_limit).to.eql(commonJobConfig.model_memory_limit);
+ expect(fetchedJob.max_num_threads).to.eql(commonJobConfig.max_num_threads);
+ });
+
+ it('should only update allow_lazy_start field of analytics job when allow_lazy_start is sent in request', async () => {
+ const analyticsId = `${jobId}_2`;
+
+ const requestBody = {
+ allow_lazy_start: true,
+ };
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`)
+ .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(200);
+
+ expect(body).not.to.be(undefined);
+
+ const fetchedJob = await getDFAJob(analyticsId);
+
+ expect(fetchedJob.allow_lazy_start).to.eql(requestBody.allow_lazy_start);
+ expect(fetchedJob.description).to.eql(testJobConfigs[2].description);
+ expect(fetchedJob.model_memory_limit).to.eql(commonJobConfig.model_memory_limit);
+ expect(fetchedJob.max_num_threads).to.eql(commonJobConfig.max_num_threads);
+ });
+
+ it('should only update model_memory_limit field of analytics job when model_memory_limit is sent in request', async () => {
+ const analyticsId = `${jobId}_3`;
+
+ const requestBody = {
+ model_memory_limit: '61mb',
+ };
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`)
+ .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(200);
+
+ expect(body).not.to.be(undefined);
+
+ const fetchedJob = await getDFAJob(analyticsId);
+
+ expect(fetchedJob.model_memory_limit).to.eql(requestBody.model_memory_limit);
+ expect(fetchedJob.allow_lazy_start).to.eql(commonJobConfig.allow_lazy_start);
+ expect(fetchedJob.description).to.eql(testJobConfigs[3].description);
+ expect(fetchedJob.max_num_threads).to.eql(commonJobConfig.max_num_threads);
+ });
+
+ it('should only update max_num_threads field of analytics job when max_num_threads is sent in request', async () => {
+ const analyticsId = `${jobId}_4`;
+
+ const requestBody = {
+ max_num_threads: 2,
+ };
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`)
+ .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(200);
+
+ expect(body).not.to.be(undefined);
+
+ const fetchedJob = await getDFAJob(analyticsId);
+
+ expect(fetchedJob.max_num_threads).to.eql(requestBody.max_num_threads);
+ expect(fetchedJob.model_memory_limit).to.eql(commonJobConfig.model_memory_limit);
+ expect(fetchedJob.allow_lazy_start).to.eql(commonJobConfig.allow_lazy_start);
+ expect(fetchedJob.description).to.eql(testJobConfigs[4].description);
+ });
+
+ it('should not allow to update analytics job for unauthorized user', async () => {
+ const analyticsId = `${jobId}_0`;
+ const requestBody = {
+ description: 'Unauthorized',
+ };
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`)
+ .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(404);
+
+ expect(body.error).to.eql('Not Found');
+ expect(body.message).to.eql('Not Found');
+
+ const fetchedJob = await getDFAJob(analyticsId);
+ // Description should not have changed
+ expect(fetchedJob.description).to.eql(editedDescription);
+ });
+
+ it('should not allow to update analytics job for the user with only view permission', async () => {
+ const analyticsId = `${jobId}_0`;
+ const requestBody = {
+ description: 'View only',
+ };
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`)
+ .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(404);
+
+ expect(body.error).to.eql('Not Found');
+ expect(body.message).to.eql('Not Found');
+
+ const fetchedJob = await getDFAJob(analyticsId);
+ // Description should not have changed
+ expect(fetchedJob.description).to.eql(editedDescription);
+ });
+
+ it('should show 404 error if job does not exist', async () => {
+ const requestBody = {
+ description: 'Not found',
+ };
+ const id = `${jobId}_invalid`;
+ const message = `[resource_not_found_exception] No known data frame analytics with id [${id}]`;
+
+ const { body } = await supertest
+ .post(`/api/ml/data_frame/analytics/${id}/_update`)
+ .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
+ .set(COMMON_REQUEST_HEADERS)
+ .send(requestBody)
+ .expect(404);
+
+ expect(body.error).to.eql('Not Found');
+ expect(body.message).to.eql(message);
+ });
+ });
+ });
+};
diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts
index 2c6edeba2129f..4566e9aed61b4 100644
--- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts
+++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts
@@ -115,7 +115,13 @@ export default function ({ getService }: FtrProviderContext) {
await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription);
});
- it('inputs the destination index', async () => {
+ it('should default the set destination index to job id switch to true', async () => {
+ await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists();
+ await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true);
+ });
+
+ it('should input the destination index', async () => {
+ await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false);
await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists();
await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex);
});
diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts
index 4ae93296f9be0..0320354b99ff0 100644
--- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts
+++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts
@@ -133,7 +133,13 @@ export default function ({ getService }: FtrProviderContext) {
await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription);
});
- it('inputs the destination index', async () => {
+ it('should default the set destination index to job id switch to true', async () => {
+ await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists();
+ await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true);
+ });
+
+ it('should input the destination index', async () => {
+ await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false);
await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists();
await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex);
});
diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts
index 03117d4cc419d..1aa505e26e1e9 100644
--- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts
+++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts
@@ -115,7 +115,13 @@ export default function ({ getService }: FtrProviderContext) {
await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription);
});
- it('inputs the destination index', async () => {
+ it('should default the set destination index to job id switch to true', async () => {
+ await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists();
+ await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true);
+ });
+
+ it('should input the destination index', async () => {
+ await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false);
await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists();
await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex);
});
diff --git a/x-pack/test/functional/apps/security/doc_level_security_roles.js b/x-pack/test/functional/apps/security/doc_level_security_roles.js
index d8a3e40ccc010..72f463be48fd5 100644
--- a/x-pack/test/functional/apps/security/doc_level_security_roles.js
+++ b/x-pack/test/functional/apps/security/doc_level_security_roles.js
@@ -15,8 +15,7 @@ export default function ({ getService, getPageObjects }) {
const screenshot = getService('screenshots');
const PageObjects = getPageObjects(['security', 'common', 'header', 'discover', 'settings']);
- // Skipped as failing on ES Promotion: https://github.com/elastic/kibana/issues/70818
- describe.skip('dls', function () {
+ describe('dls', function () {
before('initialize tests', async () => {
await esArchiver.load('empty_kibana');
await esArchiver.loadIfNeeded('security/dlstest');
diff --git a/x-pack/test/functional/apps/security/field_level_security.js b/x-pack/test/functional/apps/security/field_level_security.js
index 20b13ad935f93..7b22d72885c9d 100644
--- a/x-pack/test/functional/apps/security/field_level_security.js
+++ b/x-pack/test/functional/apps/security/field_level_security.js
@@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }) {
const log = getService('log');
const PageObjects = getPageObjects(['security', 'settings', 'common', 'discover', 'header']);
- // Skipped as it was failing on ES Promotion: https://github.com/elastic/kibana/issues/70880
- describe.skip('field_level_security', () => {
+ describe('field_level_security', () => {
before('initialize tests', async () => {
await esArchiver.loadIfNeeded('security/flstest/data'); //( data)
await esArchiver.load('security/flstest/kibana'); //(savedobject)
diff --git a/x-pack/test/functional/page_objects/gis_page.js b/x-pack/test/functional/page_objects/gis_page.js
index 8a0b4aaefa888..b8f4faf3ebfd8 100644
--- a/x-pack/test/functional/page_objects/gis_page.js
+++ b/x-pack/test/functional/page_objects/gis_page.js
@@ -17,6 +17,7 @@ export function GisPageProvider({ getService, getPageObjects }) {
const find = getService('find');
const queryBar = getService('queryBar');
const comboBox = getService('comboBox');
+ const renderable = getService('renderable');
function escapeLayerName(layerName) {
return layerName.split(' ').join('_');
@@ -135,6 +136,7 @@ export function GisPageProvider({ getService, getPageObjects }) {
// Navigate directly because we don't need to go through the map listing
// page. The listing page is skipped if there are no saved objects
await PageObjects.common.navigateToUrlWithBrowserHistory(APP_ID, '/map');
+ await renderable.waitForRender();
}
async saveMap(name) {
diff --git a/x-pack/test/functional/page_objects/status_page.js b/x-pack/test/functional/page_objects/status_page.js
index 68fc931a9140f..eba5e7dd18496 100644
--- a/x-pack/test/functional/page_objects/status_page.js
+++ b/x-pack/test/functional/page_objects/status_page.js
@@ -29,7 +29,7 @@ export function StatusPagePageProvider({ getService, getPageObjects }) {
async expectStatusPage() {
return await retry.try(async () => {
log.debug(`expectStatusPage()`);
- await find.byCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide) ', 20000);
+ await find.byCssSelector('[data-test-subj="statusPageRoot"]', 20000);
const url = await browser.getCurrentUrl();
expect(url).to.contain(`/status`);
});
diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts
index e36855a4e769e..5f3d21b80a830 100644
--- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts
+++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts
@@ -199,7 +199,9 @@ export function MachineLearningDataFrameAnalyticsCreationProvider(
// },
async assertDestIndexInputExists() {
- await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutDestinationIndexInput');
+ await retry.tryForTime(4000, async () => {
+ await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutDestinationIndexInput');
+ });
},
async assertDestIndexValue(expectedValue: string) {
@@ -417,6 +419,35 @@ export function MachineLearningDataFrameAnalyticsCreationProvider(
);
},
+ async getDestIndexSameAsIdSwitchCheckState(): Promise {
+ const state = await testSubjects.getAttribute(
+ 'mlAnalyticsCreateJobWizardDestIndexSameAsIdSwitch',
+ 'aria-checked'
+ );
+ return state === 'true';
+ },
+
+ async assertDestIndexSameAsIdCheckState(expectedCheckState: boolean) {
+ const actualCheckState = await this.getDestIndexSameAsIdSwitchCheckState();
+ expect(actualCheckState).to.eql(
+ expectedCheckState,
+ `Destination index same as job id check state should be '${expectedCheckState}' (got '${actualCheckState}')`
+ );
+ },
+
+ async assertDestIndexSameAsIdSwitchExists() {
+ await testSubjects.existOrFail(`mlAnalyticsCreateJobWizardDestIndexSameAsIdSwitch`, {
+ allowHidden: true,
+ });
+ },
+
+ async setDestIndexSameAsIdCheckState(checkState: boolean) {
+ if ((await this.getDestIndexSameAsIdSwitchCheckState()) !== checkState) {
+ await testSubjects.click('mlAnalyticsCreateJobWizardDestIndexSameAsIdSwitch');
+ }
+ await this.assertDestIndexSameAsIdCheckState(checkState);
+ },
+
async setCreateIndexPatternSwitchState(checkState: boolean) {
if ((await this.getCreateIndexPatternSwitchCheckState()) !== checkState) {
await testSubjects.click('mlAnalyticsCreateJobWizardCreateIndexPatternSwitch');
diff --git a/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts b/x-pack/test/ingest_manager_api_integration/apis/agent_config/agent_config.ts
similarity index 97%
rename from x-pack/test/api_integration/apis/ingest_manager/agent_config.ts
rename to x-pack/test/ingest_manager_api_integration/apis/agent_config/agent_config.ts
index 8bf3efbdaf501..89258600c85e1 100644
--- a/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/agent_config/agent_config.ts
@@ -5,7 +5,7 @@
*/
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../ftr_provider_context';
+import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
diff --git a/x-pack/test/api_integration/apis/ingest_manager/index.js b/x-pack/test/ingest_manager_api_integration/apis/agent_config/index.js
similarity index 100%
rename from x-pack/test/api_integration/apis/ingest_manager/index.js
rename to x-pack/test/ingest_manager_api_integration/apis/agent_config/index.js
diff --git a/x-pack/test/api_integration/apis/fleet/agents/acks.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts
similarity index 98%
rename from x-pack/test/api_integration/apis/fleet/agents/acks.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts
index a040ef20081a8..c9fa80c88762b 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/acks.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts
@@ -6,8 +6,7 @@
import expect from '@kbn/expect';
import uuid from 'uuid';
-
-import { FtrProviderContext } from '../../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
import { getSupertestWithoutAuth } from './services';
export default function (providerContext: FtrProviderContext) {
diff --git a/x-pack/test/api_integration/apis/fleet/agents/actions.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/actions.ts
similarity index 96%
rename from x-pack/test/api_integration/apis/fleet/agents/actions.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/actions.ts
index c0b2aedf5c244..8dc4e5c232b80 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/actions.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/actions.ts
@@ -5,8 +5,7 @@
*/
import expect from '@kbn/expect';
-
-import { FtrProviderContext } from '../../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
diff --git a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/checkin.ts
similarity index 94%
rename from x-pack/test/api_integration/apis/fleet/agents/checkin.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/checkin.ts
index 70147f602e9c7..79f6cfae175e1 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/checkin.ts
@@ -7,8 +7,9 @@
import expect from '@kbn/expect';
import uuid from 'uuid';
-import { FtrProviderContext } from '../../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
import { getSupertestWithoutAuth, setupIngest } from './services';
+import { skipIfNoDockerRegistry } from '../../../helpers';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
@@ -19,6 +20,7 @@ export default function (providerContext: FtrProviderContext) {
let apiKey: { id: string; api_key: string };
describe('fleet_agents_checkin', () => {
+ skipIfNoDockerRegistry(providerContext);
before(async () => {
await esArchiver.loadIfNeeded('fleet/agents');
diff --git a/x-pack/test/api_integration/apis/fleet/agent_flow.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts
similarity index 96%
rename from x-pack/test/api_integration/apis/fleet/agent_flow.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts
index da472ca912d40..8d7472f0ecd8b 100644
--- a/x-pack/test/api_integration/apis/fleet/agent_flow.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts
@@ -6,8 +6,9 @@
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../ftr_provider_context';
-import { setupIngest, getSupertestWithoutAuth } from './agents/services';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
+import { setupIngest, getSupertestWithoutAuth } from './services';
+import { skipIfNoDockerRegistry } from '../../../helpers';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
@@ -19,6 +20,7 @@ export default function (providerContext: FtrProviderContext) {
const esClient = getService('es');
describe('fleet_agent_flow', () => {
+ skipIfNoDockerRegistry(providerContext);
before(async () => {
await esArchiver.load('empty_kibana');
});
diff --git a/x-pack/test/api_integration/apis/fleet/delete_agent.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/delete.ts
similarity index 97%
rename from x-pack/test/api_integration/apis/fleet/delete_agent.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/delete.ts
index eefdc35338cb4..dc05b7a4dd792 100644
--- a/x-pack/test/api_integration/apis/fleet/delete_agent.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/delete.ts
@@ -5,7 +5,7 @@
*/
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
diff --git a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts
similarity index 96%
rename from x-pack/test/api_integration/apis/fleet/agents/enroll.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts
index 58440a34457d0..ef9f2b2e61500 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts
@@ -7,8 +7,9 @@
import expect from '@kbn/expect';
import uuid from 'uuid';
-import { FtrProviderContext } from '../../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
import { getSupertestWithoutAuth, setupIngest, getEsClientForAPIKey } from './services';
+import { skipIfNoDockerRegistry } from '../../../helpers';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
@@ -21,8 +22,8 @@ export default function (providerContext: FtrProviderContext) {
let apiKey: { id: string; api_key: string };
let kibanaVersion: string;
- // Flaky: https://github.com/elastic/kibana/issues/60865
- describe.skip('fleet_agents_enroll', () => {
+ describe('fleet_agents_enroll', () => {
+ skipIfNoDockerRegistry(providerContext);
before(async () => {
await esArchiver.loadIfNeeded('fleet/agents');
diff --git a/x-pack/test/api_integration/apis/fleet/agents/events.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/events.ts
similarity index 93%
rename from x-pack/test/api_integration/apis/fleet/agents/events.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/events.ts
index 44fc4389cab3c..93147091dc430 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/events.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/events.ts
@@ -6,7 +6,7 @@
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
diff --git a/x-pack/test/api_integration/apis/fleet/list_agent.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/list.ts
similarity index 97%
rename from x-pack/test/api_integration/apis/fleet/list_agent.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/list.ts
index 59ecb8f2579b1..23563c6f43bbe 100644
--- a/x-pack/test/api_integration/apis/fleet/list_agent.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/list.ts
@@ -6,7 +6,7 @@
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
diff --git a/x-pack/test/api_integration/apis/fleet/agents/services.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/services.ts
similarity index 94%
rename from x-pack/test/api_integration/apis/fleet/agents/services.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/services.ts
index 86c5fb5032c7f..70d59ecc0b0da 100644
--- a/x-pack/test/api_integration/apis/fleet/agents/services.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/services.ts
@@ -8,7 +8,7 @@ import supertestAsPromised from 'supertest-as-promised';
import { Client } from '@elastic/elasticsearch';
import { format as formatUrl } from 'url';
-import { FtrProviderContext } from '../../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
export function getSupertestWithoutAuth({ getService }: FtrProviderContext) {
const config = getService('config');
diff --git a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/unenroll.ts
similarity index 93%
rename from x-pack/test/api_integration/apis/fleet/unenroll_agent.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/unenroll.ts
index bbbce3314e4cc..d1ff8731183ba 100644
--- a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/unenroll.ts
@@ -7,8 +7,9 @@
import expect from '@kbn/expect';
import uuid from 'uuid';
-import { FtrProviderContext } from '../../ftr_provider_context';
-import { setupIngest } from './agents/services';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
+import { setupIngest } from './services';
+import { skipIfNoDockerRegistry } from '../../../helpers';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
@@ -17,6 +18,7 @@ export default function (providerContext: FtrProviderContext) {
const esClient = getService('es');
describe('fleet_unenroll_agent', () => {
+ skipIfNoDockerRegistry(providerContext);
let accessAPIKeyId: string;
let outputAPIKeyId: string;
before(async () => {
diff --git a/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/enrollment_api_keys/crud.ts
similarity index 95%
rename from x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/enrollment_api_keys/crud.ts
index e9685d663aac6..bc9182627326b 100644
--- a/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/enrollment_api_keys/crud.ts
@@ -6,8 +6,9 @@
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../../ftr_provider_context';
+import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context';
import { setupIngest, getEsClientForAPIKey } from '../agents/services';
+import { skipIfNoDockerRegistry } from '../../../helpers';
const ENROLLMENT_KEY_ID = 'ed22ca17-e178-4cfe-8b02-54ea29fbd6d0';
@@ -21,11 +22,14 @@ export default function (providerContext: FtrProviderContext) {
before(async () => {
await esArchiver.loadIfNeeded('fleet/agents');
});
- setupIngest({ getService } as FtrProviderContext);
+
after(async () => {
await esArchiver.unload('fleet/agents');
});
+ skipIfNoDockerRegistry(providerContext);
+ setupIngest(providerContext);
+
describe('GET /fleet/enrollment-api-keys', async () => {
it('should list existing api keys', async () => {
const { body: apiResponse } = await supertest
diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/ingest_manager_api_integration/apis/fleet/index.js
similarity index 77%
rename from x-pack/test/api_integration/apis/fleet/index.js
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/index.js
index df81b826132a9..3a72fe6d9f12b 100644
--- a/x-pack/test/api_integration/apis/fleet/index.js
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/index.js
@@ -7,16 +7,16 @@
export default function loadTests({ loadTestFile }) {
describe('Fleet Endpoints', () => {
loadTestFile(require.resolve('./setup'));
- loadTestFile(require.resolve('./delete_agent'));
- loadTestFile(require.resolve('./list_agent'));
- loadTestFile(require.resolve('./unenroll_agent'));
+ loadTestFile(require.resolve('./agents/delete'));
+ loadTestFile(require.resolve('./agents/list'));
loadTestFile(require.resolve('./agents/enroll'));
+ loadTestFile(require.resolve('./agents/unenroll'));
loadTestFile(require.resolve('./agents/checkin'));
loadTestFile(require.resolve('./agents/events'));
loadTestFile(require.resolve('./agents/acks'));
+ loadTestFile(require.resolve('./agents/complete_flow'));
loadTestFile(require.resolve('./enrollment_api_keys/crud'));
loadTestFile(require.resolve('./install'));
loadTestFile(require.resolve('./agents/actions'));
- loadTestFile(require.resolve('./agent_flow'));
});
}
diff --git a/x-pack/test/api_integration/apis/fleet/install.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/install.ts
similarity index 92%
rename from x-pack/test/api_integration/apis/fleet/install.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/install.ts
index 59b040e30fb48..98758ae3ac65e 100644
--- a/x-pack/test/api_integration/apis/fleet/install.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/install.ts
@@ -5,7 +5,7 @@
*/
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../ftr_provider_context';
+import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
import { setupIngest } from './agents/services';
export default function (providerContext: FtrProviderContext) {
diff --git a/x-pack/test/api_integration/apis/fleet/setup.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/setup.ts
similarity index 92%
rename from x-pack/test/api_integration/apis/fleet/setup.ts
rename to x-pack/test/ingest_manager_api_integration/apis/fleet/setup.ts
index 4fcf39886e202..64c014dc6fb3d 100644
--- a/x-pack/test/api_integration/apis/fleet/setup.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/setup.ts
@@ -5,13 +5,16 @@
*/
import expect from '@kbn/expect';
-import { FtrProviderContext } from '../../ftr_provider_context';
+import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
+import { skipIfNoDockerRegistry } from '../../helpers';
-export default function ({ getService }: FtrProviderContext) {
+export default function (providerContext: FtrProviderContext) {
+ const { getService } = providerContext;
const supertest = getService('supertest');
const es = getService('es');
describe('fleet_setup', () => {
+ skipIfNoDockerRegistry(providerContext);
beforeEach(async () => {
try {
await es.security.deleteUser({
diff --git a/x-pack/test/ingest_manager_api_integration/apis/index.js b/x-pack/test/ingest_manager_api_integration/apis/index.js
index 81848917f9b05..c0c8ce3ff082c 100644
--- a/x-pack/test/ingest_manager_api_integration/apis/index.js
+++ b/x-pack/test/ingest_manager_api_integration/apis/index.js
@@ -8,6 +8,9 @@ export default function ({ loadTestFile }) {
describe('Ingest Manager Endpoints', function () {
this.tags('ciGroup7');
+ // Fleet
+ loadTestFile(require.resolve('./fleet/index'));
+
// EPM
loadTestFile(require.resolve('./epm/list'));
loadTestFile(require.resolve('./epm/file'));
@@ -18,5 +21,7 @@ export default function ({ loadTestFile }) {
// Package configs
loadTestFile(require.resolve('./package_config/create'));
loadTestFile(require.resolve('./package_config/update'));
+ // Agent config
+ loadTestFile(require.resolve('./agent_config/index'));
});
}
diff --git a/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts b/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts
index 0251fef5f767c..7b0ad4f524bad 100644
--- a/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts
+++ b/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts
@@ -6,10 +6,10 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
-import { warnAndSkipTest } from '../../helpers';
+import { skipIfNoDockerRegistry } from '../../helpers';
-export default function ({ getService }: FtrProviderContext) {
- const log = getService('log');
+export default function (providerContext: FtrProviderContext) {
+ const { getService } = providerContext;
const supertest = getService('supertest');
const dockerServers = getService('dockerServers');
@@ -19,11 +19,15 @@ export default function ({ getService }: FtrProviderContext) {
// see https://mochajs.org/#arrow-functions
describe('Package Config - update', async function () {
+ skipIfNoDockerRegistry(providerContext);
let agentConfigId: string;
let packageConfigId: string;
let packageConfigId2: string;
before(async function () {
+ if (!server.enabled) {
+ return;
+ }
const { body: agentConfigResponse } = await supertest
.post(`/api/ingest_manager/agent_configs`)
.set('kbn-xsrf', 'xxxx')
@@ -73,55 +77,47 @@ export default function ({ getService }: FtrProviderContext) {
});
it('should work with valid values', async function () {
- if (server.enabled) {
- const { body: apiResponse } = await supertest
- .put(`/api/ingest_manager/package_configs/${packageConfigId}`)
- .set('kbn-xsrf', 'xxxx')
- .send({
- name: 'filetest-1',
- description: '',
- namespace: 'updated_namespace',
- config_id: agentConfigId,
- enabled: true,
- output_id: '',
- inputs: [],
- package: {
- name: 'filetest',
- title: 'For File Tests',
- version: '0.1.0',
- },
- })
- .expect(200);
+ const { body: apiResponse } = await supertest
+ .put(`/api/ingest_manager/package_configs/${packageConfigId}`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ name: 'filetest-1',
+ description: '',
+ namespace: 'updated_namespace',
+ config_id: agentConfigId,
+ enabled: true,
+ output_id: '',
+ inputs: [],
+ package: {
+ name: 'filetest',
+ title: 'For File Tests',
+ version: '0.1.0',
+ },
+ })
+ .expect(200);
- expect(apiResponse.success).to.be(true);
- } else {
- warnAndSkipTest(this, log);
- }
+ expect(apiResponse.success).to.be(true);
});
it('should return a 500 if there is another package config with the same name', async function () {
- if (server.enabled) {
- await supertest
- .put(`/api/ingest_manager/package_configs/${packageConfigId2}`)
- .set('kbn-xsrf', 'xxxx')
- .send({
- name: 'filetest-1',
- description: '',
- namespace: 'updated_namespace',
- config_id: agentConfigId,
- enabled: true,
- output_id: '',
- inputs: [],
- package: {
- name: 'filetest',
- title: 'For File Tests',
- version: '0.1.0',
- },
- })
- .expect(500);
- } else {
- warnAndSkipTest(this, log);
- }
+ await supertest
+ .put(`/api/ingest_manager/package_configs/${packageConfigId2}`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ name: 'filetest-1',
+ description: '',
+ namespace: 'updated_namespace',
+ config_id: agentConfigId,
+ enabled: true,
+ output_id: '',
+ inputs: [],
+ package: {
+ name: 'filetest',
+ title: 'For File Tests',
+ version: '0.1.0',
+ },
+ })
+ .expect(500);
});
});
}
diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts
index e3cdf0eff4b3a..6f5d8eed43519 100644
--- a/x-pack/test/ingest_manager_api_integration/config.ts
+++ b/x-pack/test/ingest_manager_api_integration/config.ts
@@ -8,6 +8,7 @@ import path from 'path';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
import { defineDockerServersConfig } from '@kbn/test';
+import { services } from '../api_integration/services';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts'));
@@ -46,7 +47,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
waitForLogLine: 'package manifests loaded',
},
}),
+ esArchiver: xPackAPITestsConfig.get('esArchiver'),
services: {
+ ...services,
supertest: xPackAPITestsConfig.get('services.supertest'),
es: xPackAPITestsConfig.get('services.es'),
},
diff --git a/x-pack/test/ingest_manager_api_integration/helpers.ts b/x-pack/test/ingest_manager_api_integration/helpers.ts
index 121630249621b..b1755e30f61f5 100644
--- a/x-pack/test/ingest_manager_api_integration/helpers.ts
+++ b/x-pack/test/ingest_manager_api_integration/helpers.ts
@@ -6,6 +6,7 @@
import { Context } from 'mocha';
import { ToolingLog } from '@kbn/dev-utils';
+import { FtrProviderContext } from '../api_integration/ftr_provider_context';
export function warnAndSkipTest(mochaContext: Context, log: ToolingLog) {
log.warning(
@@ -13,3 +14,17 @@ export function warnAndSkipTest(mochaContext: Context, log: ToolingLog) {
);
mochaContext.skip();
}
+
+export function skipIfNoDockerRegistry(providerContext: FtrProviderContext) {
+ const { getService } = providerContext;
+ const dockerServers = getService('dockerServers');
+
+ const server = dockerServers.get('registry');
+ const log = getService('log');
+
+ beforeEach(function beforeSetupWithDockerRegistyry() {
+ if (!server.enabled) {
+ warnAndSkipTest(this, log);
+ }
+ });
+}