mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution][Detection Alerts] Alert tagging follow-up (#160305)
This commit is contained in:
parent
9d711fb944
commit
88fc4a6627
27 changed files with 829 additions and 242 deletions
|
@ -45,8 +45,8 @@ export type SignalIds = t.TypeOf<typeof signal_ids>;
|
|||
// TODO: Can this be more strict or is this is the set of all Elastic Queries?
|
||||
export const signal_status_query = t.object;
|
||||
|
||||
export const alert_tag_query = t.record(t.string, t.unknown);
|
||||
export type AlertTagQuery = t.TypeOf<typeof alert_tag_query>;
|
||||
export const alert_tag_ids = t.array(t.string);
|
||||
export type AlertTagIds = t.TypeOf<typeof alert_tag_ids>;
|
||||
|
||||
export const fields = t.array(t.string);
|
||||
export type Fields = t.TypeOf<typeof fields>;
|
||||
|
|
|
@ -9,5 +9,6 @@ import type { SetAlertTagsSchema } from './set_alert_tags_schema';
|
|||
|
||||
export const getSetAlertTagsRequestMock = (
|
||||
tagsToAdd: string[] = [],
|
||||
tagsToRemove: string[] = []
|
||||
): SetAlertTagsSchema => ({ tags: { tags_to_add: tagsToAdd, tags_to_remove: tagsToRemove } });
|
||||
tagsToRemove: string[] = [],
|
||||
ids: string[] = []
|
||||
): SetAlertTagsSchema => ({ tags: { tags_to_add: tagsToAdd, tags_to_remove: tagsToRemove }, ids });
|
||||
|
|
|
@ -7,16 +7,14 @@
|
|||
|
||||
import * as t from 'io-ts';
|
||||
|
||||
import { alert_tag_query, alert_tags } from '../common/schemas';
|
||||
import { alert_tag_ids, alert_tags } from '../common/schemas';
|
||||
|
||||
export const setAlertTagsSchema = t.intersection([
|
||||
export const setAlertTagsSchema = t.exact(
|
||||
t.type({
|
||||
tags: alert_tags,
|
||||
}),
|
||||
t.partial({
|
||||
query: alert_tag_query,
|
||||
}),
|
||||
]);
|
||||
ids: alert_tag_ids,
|
||||
})
|
||||
);
|
||||
|
||||
export type SetAlertTagsSchema = t.TypeOf<typeof setAlertTagsSchema>;
|
||||
export type SetAlertTagsSchemaDecoded = SetAlertTagsSchema;
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
} from '../../screens/alerts';
|
||||
import { esArchiverLoad, esArchiverResetKibana, esArchiverUnload } from '../../tasks/es_archiver';
|
||||
|
||||
describe.skip('Alert tagging', () => {
|
||||
describe('Alert tagging', () => {
|
||||
before(() => {
|
||||
cleanKibana();
|
||||
esArchiverResetKibana();
|
||||
|
@ -51,7 +51,6 @@ describe.skip('Alert tagging', () => {
|
|||
clickAlertTag('Duplicate');
|
||||
updateAlertTags();
|
||||
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
|
||||
waitForAlertsToPopulate();
|
||||
selectNumberOfAlerts(1);
|
||||
openAlertTaggingBulkActionMenu();
|
||||
cy.get(SELECTED_ALERT_TAG).contains('Duplicate');
|
||||
|
@ -59,7 +58,6 @@ describe.skip('Alert tagging', () => {
|
|||
clickAlertTag('Duplicate');
|
||||
updateAlertTags();
|
||||
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
|
||||
waitForAlertsToPopulate();
|
||||
selectNumberOfAlerts(1);
|
||||
openAlertTaggingBulkActionMenu();
|
||||
cy.get(UNSELECTED_ALERT_TAG).first().contains('Duplicate');
|
||||
|
@ -72,7 +70,6 @@ describe.skip('Alert tagging', () => {
|
|||
clickAlertTag('Duplicate');
|
||||
updateAlertTags();
|
||||
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
|
||||
waitForAlertsToPopulate();
|
||||
// Then add tags to both alerts
|
||||
selectNumberOfAlerts(2);
|
||||
openAlertTaggingBulkActionMenu();
|
||||
|
@ -80,7 +77,6 @@ describe.skip('Alert tagging', () => {
|
|||
clickAlertTag('Duplicate');
|
||||
updateAlertTags();
|
||||
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
|
||||
waitForAlertsToPopulate();
|
||||
selectNumberOfAlerts(2);
|
||||
openAlertTaggingBulkActionMenu();
|
||||
cy.get(SELECTED_ALERT_TAG).contains('Duplicate');
|
||||
|
@ -102,7 +98,6 @@ describe.skip('Alert tagging', () => {
|
|||
clickAlertTag('Duplicate'); // Clicking twice will return to unselected state
|
||||
updateAlertTags();
|
||||
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
|
||||
waitForAlertsToPopulate();
|
||||
selectNumberOfAlerts(2);
|
||||
openAlertTaggingBulkActionMenu();
|
||||
cy.get(UNSELECTED_ALERT_TAG).first().contains('Duplicate');
|
||||
|
|
|
@ -51,3 +51,21 @@ export const waitForNewDocumentToBeIndexed = (index: string, initialNumberOfDocu
|
|||
{ interval: 500, timeout: 12000 }
|
||||
);
|
||||
};
|
||||
|
||||
export const refreshIndex = (index: string) => {
|
||||
cy.waitUntil(
|
||||
() =>
|
||||
rootRequest({
|
||||
method: 'POST',
|
||||
url: `${Cypress.env('ELASTICSEARCH_URL')}/${index}/_refresh`,
|
||||
headers: { 'kbn-xsrf': 'cypress-creds' },
|
||||
failOnStatusCode: false,
|
||||
}).then((response) => {
|
||||
if (response.status !== 200) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
{ interval: 500, timeout: 12000 }
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,21 +5,17 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import type { TimelineItem } from '@kbn/timelines-plugin/common';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { useUiSetting$ } from '../../../lib/kibana';
|
||||
import * as helpers from './helpers';
|
||||
|
||||
import { BulkAlertTagsPanel } from './alert_bulk_tags';
|
||||
import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
|
||||
import { useAppToasts } from '../../../hooks/use_app_toasts';
|
||||
import { useSetAlertTags } from './use_set_alert_tags';
|
||||
import { getUpdateAlertsQuery } from './helpers';
|
||||
|
||||
jest.mock('../../../lib/kibana');
|
||||
jest.mock('../../../hooks/use_app_toasts');
|
||||
jest.mock('./use_set_alert_tags');
|
||||
jest.mock('./helpers');
|
||||
|
||||
const mockTagItems = [
|
||||
{
|
||||
|
@ -29,27 +25,172 @@ const mockTagItems = [
|
|||
},
|
||||
];
|
||||
|
||||
(useUiSetting$ as jest.Mock).mockReturnValue(['default-test-tag']);
|
||||
(useAppToasts as jest.Mock).mockReturnValue({
|
||||
addError: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
addWarning: jest.fn(),
|
||||
});
|
||||
(useSetAlertTags as jest.Mock).mockReturnValue([false, jest.fn()]);
|
||||
(getUpdateAlertsQuery as jest.Mock).mockReturnValue({ query: {} });
|
||||
(useUiSetting$ as jest.Mock).mockReturnValue([['default-test-tag-1', 'default-test-tag-2']]);
|
||||
const createInitialTagsState = jest.spyOn(helpers, 'createInitialTagsState');
|
||||
|
||||
const renderTagsMenu = (
|
||||
tags: TimelineItem[],
|
||||
closePopover: () => void = jest.fn(),
|
||||
onSubmit: () => Promise<void> = jest.fn(),
|
||||
setIsLoading: () => void = jest.fn()
|
||||
) => {
|
||||
return render(
|
||||
<TestProviders>
|
||||
<BulkAlertTagsPanel
|
||||
alertItems={tags}
|
||||
setIsLoading={setIsLoading}
|
||||
closePopoverMenu={closePopover}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
describe('BulkAlertTagsPanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('it renders', () => {
|
||||
const wrapper = render(
|
||||
<TestProviders>
|
||||
<BulkAlertTagsPanel
|
||||
alertItems={mockTagItems}
|
||||
setIsLoading={() => {}}
|
||||
closePopoverMenu={() => {}}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
const wrapper = renderTagsMenu(mockTagItems);
|
||||
|
||||
expect(wrapper.getByTestId('alert-tags-selectable-menu')).toBeInTheDocument();
|
||||
expect(createInitialTagsState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it renders a valid state when existing alert tags are passed', () => {
|
||||
const mockTags = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
];
|
||||
const wrapper = renderTagsMenu(mockTags);
|
||||
|
||||
expect(wrapper.getByTestId('selected-alert-tag')).toHaveTextContent('default-test-tag-1');
|
||||
expect(wrapper.getByTestId('unselected-alert-tag')).toHaveTextContent('default-test-tag-2');
|
||||
});
|
||||
|
||||
test('it renders a valid state when multiple alerts with tags are passed', () => {
|
||||
const mockTags = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1', 'default-test-tag-2'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
];
|
||||
const wrapper = renderTagsMenu(mockTags);
|
||||
|
||||
expect(wrapper.getByTestId('selected-alert-tag')).toHaveTextContent('default-test-tag-1');
|
||||
expect(wrapper.getByTestId('mixed-alert-tag')).toHaveTextContent('default-test-tag-2');
|
||||
});
|
||||
|
||||
test('it calls expected functions on submit when nothing has changed', () => {
|
||||
const mockedClosePopover = jest.fn();
|
||||
const mockedOnSubmit = jest.fn();
|
||||
const mockedSetIsLoading = jest.fn();
|
||||
|
||||
const mockTags = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1', 'default-test-tag-2'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
];
|
||||
const wrapper = renderTagsMenu(
|
||||
mockTags,
|
||||
mockedClosePopover,
|
||||
mockedOnSubmit,
|
||||
mockedSetIsLoading
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByTestId('alert-tags-update-button'));
|
||||
});
|
||||
expect(mockedClosePopover).toHaveBeenCalled();
|
||||
expect(mockedOnSubmit).not.toHaveBeenCalled();
|
||||
expect(mockedSetIsLoading).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it updates state correctly', () => {
|
||||
const mockTags = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1', 'default-test-tag-2'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
];
|
||||
const wrapper = renderTagsMenu(mockTags);
|
||||
|
||||
expect(wrapper.getByTitle('default-test-tag-1')).toBeChecked();
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('default-test-tag-1'));
|
||||
});
|
||||
expect(wrapper.getByTitle('default-test-tag-1')).not.toBeChecked();
|
||||
|
||||
expect(wrapper.getByTitle('default-test-tag-2')).not.toBeChecked();
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('default-test-tag-2'));
|
||||
});
|
||||
expect(wrapper.getByTitle('default-test-tag-2')).toBeChecked();
|
||||
});
|
||||
|
||||
test('it calls expected functions on submit when alerts have changed', () => {
|
||||
const mockedClosePopover = jest.fn();
|
||||
const mockedOnSubmit = jest.fn();
|
||||
const mockedSetIsLoading = jest.fn();
|
||||
|
||||
const mockTags = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['default-test-tag-1', 'default-test-tag-2'] }],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
];
|
||||
const wrapper = renderTagsMenu(
|
||||
mockTags,
|
||||
mockedClosePopover,
|
||||
mockedOnSubmit,
|
||||
mockedSetIsLoading
|
||||
);
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('default-test-tag-1'));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('default-test-tag-2'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByTestId('alert-tags-update-button'));
|
||||
});
|
||||
expect(mockedClosePopover).toHaveBeenCalled();
|
||||
expect(mockedOnSubmit).toHaveBeenCalled();
|
||||
expect(mockedOnSubmit).toHaveBeenCalledWith(
|
||||
{ tags_to_add: ['default-test-tag-2'], tags_to_remove: ['default-test-tag-1'] },
|
||||
['test-id', 'test-id'],
|
||||
expect.anything(), // An anonymous callback defined in the onSubmit function
|
||||
mockedSetIsLoading
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,14 +8,15 @@
|
|||
import type { EuiSelectableOption } from '@elastic/eui';
|
||||
import { EuiPopoverTitle, EuiSelectable, EuiButton } from '@elastic/eui';
|
||||
import type { TimelineItem } from '@kbn/timelines-plugin/common';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useReducer } from 'react';
|
||||
import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
|
||||
import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable';
|
||||
import { DEFAULT_ALERT_TAGS_KEY } from '../../../../../common/constants';
|
||||
import { useUiSetting$ } from '../../../lib/kibana';
|
||||
import { useSetAlertTags } from './use_set_alert_tags';
|
||||
import * as i18n from './translations';
|
||||
import { createInitialTagsState } from './helpers';
|
||||
import { createAlertTagsReducer, initialState } from './reducer';
|
||||
import type { SetAlertTagsFunc } from './use_set_alert_tags';
|
||||
|
||||
interface BulkAlertTagsPanelComponentProps {
|
||||
alertItems: TimelineItem[];
|
||||
|
@ -24,6 +25,7 @@ interface BulkAlertTagsPanelComponentProps {
|
|||
refresh?: () => void;
|
||||
clearSelection?: () => void;
|
||||
closePopoverMenu: () => void;
|
||||
onSubmit: SetAlertTagsFunc;
|
||||
}
|
||||
const BulkAlertTagsPanelComponent: React.FC<BulkAlertTagsPanelComponentProps> = ({
|
||||
alertItems,
|
||||
|
@ -32,10 +34,10 @@ const BulkAlertTagsPanelComponent: React.FC<BulkAlertTagsPanelComponentProps> =
|
|||
setIsLoading,
|
||||
clearSelection,
|
||||
closePopoverMenu,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [defaultAlertTagOptions] = useUiSetting$<string[]>(DEFAULT_ALERT_TAGS_KEY);
|
||||
|
||||
const [, setAlertTags] = useSetAlertTags();
|
||||
const existingTags = useMemo(
|
||||
() =>
|
||||
alertItems.map(
|
||||
|
@ -43,20 +45,40 @@ const BulkAlertTagsPanelComponent: React.FC<BulkAlertTagsPanelComponentProps> =
|
|||
),
|
||||
[alertItems]
|
||||
);
|
||||
const initialTagsState = useMemo(
|
||||
() => createInitialTagsState(existingTags, defaultAlertTagOptions),
|
||||
[existingTags, defaultAlertTagOptions]
|
||||
const [{ selectableAlertTags, tagsToAdd, tagsToRemove }, dispatch] = useReducer(
|
||||
createAlertTagsReducer(),
|
||||
{
|
||||
...initialState,
|
||||
selectableAlertTags: createInitialTagsState(existingTags, defaultAlertTagOptions),
|
||||
tagsToAdd: new Set<string>(),
|
||||
tagsToRemove: new Set<string>(),
|
||||
}
|
||||
);
|
||||
|
||||
const tagsToAdd: Set<string> = useMemo(() => new Set(), []);
|
||||
const tagsToRemove: Set<string> = useMemo(() => new Set(), []);
|
||||
const addAlertTag = useCallback(
|
||||
(value: string) => {
|
||||
dispatch({ type: 'addAlertTag', value });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const [selectableAlertTags, setSelectableAlertTags] =
|
||||
useState<EuiSelectableOption[]>(initialTagsState);
|
||||
const removeAlertTag = useCallback(
|
||||
(value: string) => {
|
||||
dispatch({ type: 'removeAlertTag', value });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onTagsUpdate = useCallback(() => {
|
||||
closePopoverMenu();
|
||||
const setSelectableAlertTags = useCallback(
|
||||
(value: EuiSelectableOption[]) => {
|
||||
dispatch({ type: 'setSelectableAlertTags', value });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onTagsUpdate = useCallback(async () => {
|
||||
if (tagsToAdd.size === 0 && tagsToRemove.size === 0) {
|
||||
closePopoverMenu();
|
||||
return;
|
||||
}
|
||||
const tagsToAddArray = Array.from(tagsToAdd);
|
||||
|
@ -68,35 +90,37 @@ const BulkAlertTagsPanelComponent: React.FC<BulkAlertTagsPanelComponentProps> =
|
|||
if (refresh) refresh();
|
||||
if (clearSelection) clearSelection();
|
||||
};
|
||||
if (setAlertTags != null) {
|
||||
setAlertTags(tags, ids, onSuccess, setIsLoading);
|
||||
if (onSubmit != null) {
|
||||
closePopoverMenu();
|
||||
await onSubmit(tags, ids, onSuccess, setIsLoading);
|
||||
}
|
||||
}, [
|
||||
closePopoverMenu,
|
||||
tagsToAdd,
|
||||
tagsToRemove,
|
||||
alertItems,
|
||||
setAlertTags,
|
||||
refetchQuery,
|
||||
refresh,
|
||||
clearSelection,
|
||||
setIsLoading,
|
||||
onSubmit,
|
||||
]);
|
||||
|
||||
const handleTagsOnChange = (
|
||||
newOptions: EuiSelectableOption[],
|
||||
event: EuiSelectableOnChangeEvent,
|
||||
changedOption: EuiSelectableOption
|
||||
) => {
|
||||
if (changedOption.checked === 'on') {
|
||||
tagsToAdd.add(changedOption.label);
|
||||
tagsToRemove.delete(changedOption.label);
|
||||
} else if (!changedOption.checked) {
|
||||
tagsToRemove.add(changedOption.label);
|
||||
tagsToAdd.delete(changedOption.label);
|
||||
}
|
||||
setSelectableAlertTags(newOptions);
|
||||
};
|
||||
const handleTagsOnChange = useCallback(
|
||||
(
|
||||
newOptions: EuiSelectableOption[],
|
||||
event: EuiSelectableOnChangeEvent,
|
||||
changedOption: EuiSelectableOption
|
||||
) => {
|
||||
if (changedOption.checked === 'on') {
|
||||
addAlertTag(changedOption.label);
|
||||
} else if (!changedOption.checked) {
|
||||
removeAlertTag(changedOption.label);
|
||||
}
|
||||
setSelectableAlertTags(newOptions);
|
||||
},
|
||||
[addAlertTag, removeAlertTag, setSelectableAlertTags]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -125,7 +149,7 @@ const BulkAlertTagsPanelComponent: React.FC<BulkAlertTagsPanelComponentProps> =
|
|||
size="s"
|
||||
onClick={onTagsUpdate}
|
||||
>
|
||||
{i18n.ALERT_TAGS_UPDATE_BUTTON_MESSAGE}
|
||||
{i18n.ALERT_TAGS_APPLY_BUTTON_MESSAGE}
|
||||
</EuiButton>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -45,21 +45,3 @@ export const createInitialTagsState = (existingTags: string[][], defaultTags: st
|
|||
})
|
||||
.sort(checkedSortCallback);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Please avoid using update_by_query API with `refresh:true` on serverless, use `_bulk` update by id instead.
|
||||
*/
|
||||
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
|
||||
return {
|
||||
query: {
|
||||
bool: {
|
||||
filter: {
|
||||
terms: {
|
||||
_id: eventIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { EuiSelectableOption } from '@elastic/eui';
|
||||
|
||||
export interface State {
|
||||
selectableAlertTags: EuiSelectableOption[];
|
||||
tagsToAdd: Set<string>;
|
||||
tagsToRemove: Set<string>;
|
||||
}
|
||||
|
||||
export const initialState: State = {
|
||||
selectableAlertTags: [],
|
||||
tagsToAdd: new Set<string>(),
|
||||
tagsToRemove: new Set<string>(),
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: 'addAlertTag';
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: 'removeAlertTag';
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: 'setSelectableAlertTags';
|
||||
value: EuiSelectableOption[];
|
||||
};
|
||||
|
||||
export const createAlertTagsReducer =
|
||||
() =>
|
||||
(state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'addAlertTag': {
|
||||
const { value } = action;
|
||||
state.tagsToAdd.add(value);
|
||||
state.tagsToRemove.delete(value);
|
||||
return state;
|
||||
}
|
||||
case 'removeAlertTag': {
|
||||
const { value } = action;
|
||||
state.tagsToRemove.add(value);
|
||||
state.tagsToAdd.delete(value);
|
||||
return state;
|
||||
}
|
||||
case 'setSelectableAlertTags': {
|
||||
const { value } = action;
|
||||
return { ...state, selectableAlertTags: value };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -187,20 +187,27 @@ export const ALERT_TAGS_MENU_SEARCH_NO_TAGS_FOUND = i18n.translate(
|
|||
export const ALERT_TAGS_MENU_EMPTY = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.alertTagsMenuEmptyMessage',
|
||||
{
|
||||
defaultMessage: 'No tag options exist, add tag options in Advanced Settings.',
|
||||
defaultMessage: 'No alert tag options exist, add tag options in Kibana Advanced Settings.',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_TAGS_UPDATE_BUTTON_MESSAGE = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.alertTagsUpdateButtonMessage',
|
||||
export const ALERT_TAGS_APPLY_BUTTON_MESSAGE = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.alertTagsApplyButtonMessage',
|
||||
{
|
||||
defaultMessage: 'Update tags',
|
||||
defaultMessage: 'Apply tags',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.alertTagsContextMenuItemTitle',
|
||||
{
|
||||
defaultMessage: 'Manage alert tags',
|
||||
defaultMessage: 'Apply alert tags',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_TAGS_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.alertTagsContextMenuItemTooltip',
|
||||
{
|
||||
defaultMessage: 'Change alert tag options in Kibana Advanced Settings.',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
|
||||
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type {
|
||||
UseBulkAlertTagsItemsProps,
|
||||
UseBulkAlertTagsPanel,
|
||||
} from './use_bulk_alert_tags_items';
|
||||
import { useBulkAlertTagsItems } from './use_bulk_alert_tags_items';
|
||||
import { useSetAlertTags } from './use_set_alert_tags';
|
||||
import { useUiSetting$ } from '../../../lib/kibana';
|
||||
|
||||
jest.mock('./use_set_alert_tags');
|
||||
jest.mock('../../../lib/kibana');
|
||||
|
||||
const defaultProps: UseBulkAlertTagsItemsProps = {
|
||||
refetch: () => {},
|
||||
};
|
||||
|
||||
const mockTagItems = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['tag-1', 'tag-2'] }],
|
||||
ecs: { _id: 'test-id', _index: 'test-index' },
|
||||
},
|
||||
];
|
||||
|
||||
const renderPanel = (panel: UseBulkAlertTagsPanel) => {
|
||||
const content = panel.renderContent({
|
||||
closePopoverMenu: jest.fn(),
|
||||
setIsBulkActionsLoading: jest.fn(),
|
||||
alertItems: mockTagItems,
|
||||
});
|
||||
return render(content);
|
||||
};
|
||||
|
||||
describe('useBulkAlertTagsItems', () => {
|
||||
beforeEach(() => {
|
||||
(useSetAlertTags as jest.Mock).mockReturnValue(jest.fn());
|
||||
(useUiSetting$ as jest.Mock).mockReturnValue([['default-test-tag-1']]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render alert tagging actions', () => {
|
||||
const { result } = renderHook(() => useBulkAlertTagsItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertTagsItems.length).toEqual(1);
|
||||
expect(result.current.alertTagsPanels.length).toEqual(1);
|
||||
|
||||
expect(result.current.alertTagsItems[0]['data-test-subj']).toEqual(
|
||||
'alert-tags-context-menu-item'
|
||||
);
|
||||
expect(result.current.alertTagsPanels[0]['data-test-subj']).toEqual(
|
||||
'alert-tags-context-menu-panel'
|
||||
);
|
||||
});
|
||||
|
||||
it('should still render alert tagging panel when useSetAlertTags is null', () => {
|
||||
(useSetAlertTags as jest.Mock).mockReturnValue(null);
|
||||
const { result } = renderHook(() => useBulkAlertTagsItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(result.current.alertTagsPanels[0]['data-test-subj']).toEqual(
|
||||
'alert-tags-context-menu-panel'
|
||||
);
|
||||
const wrapper = renderPanel(result.current.alertTagsPanels[0]);
|
||||
expect(wrapper.getByTestId('alert-tags-selectable-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call setAlertTags on submit', () => {
|
||||
const mockSetAlertTags = jest.fn();
|
||||
(useSetAlertTags as jest.Mock).mockReturnValue(mockSetAlertTags);
|
||||
const { result } = renderHook(() => useBulkAlertTagsItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
const wrapper = renderPanel(result.current.alertTagsPanels[0]);
|
||||
expect(wrapper.getByTestId('alert-tags-selectable-menu')).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByTestId('unselected-alert-tag')); // Won't fire unless component tags selection has been changed
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByTestId('alert-tags-update-button'));
|
||||
});
|
||||
expect(mockSetAlertTags).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -5,16 +5,35 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiIconTip, EuiFlexItem } from '@elastic/eui';
|
||||
import type { RenderContentPanelProps } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { BulkAlertTagsPanel } from './alert_bulk_tags';
|
||||
import * as i18n from './translations';
|
||||
import { useSetAlertTags } from './use_set_alert_tags';
|
||||
|
||||
interface UseBulkAlertTagsItemsProps {
|
||||
export interface UseBulkAlertTagsItemsProps {
|
||||
refetch?: () => void;
|
||||
}
|
||||
|
||||
export interface UseBulkAlertTagsPanel {
|
||||
id: number;
|
||||
title: JSX.Element;
|
||||
'data-test-subj': string;
|
||||
renderContent: (props: RenderContentPanelProps) => JSX.Element;
|
||||
}
|
||||
|
||||
export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) => {
|
||||
const setAlertTags = useSetAlertTags();
|
||||
const handleOnAlertTagsSubmit = useCallback(
|
||||
async (tags, ids, onSuccess, setIsLoading) => {
|
||||
if (setAlertTags) {
|
||||
await setAlertTags(tags, ids, onSuccess, setIsLoading);
|
||||
}
|
||||
},
|
||||
[setAlertTags]
|
||||
);
|
||||
|
||||
const alertTagsItems = [
|
||||
{
|
||||
key: 'manage-alert-tags',
|
||||
|
@ -26,28 +45,50 @@ export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) =
|
|||
},
|
||||
];
|
||||
|
||||
const alertTagsPanels = [
|
||||
{
|
||||
id: 1,
|
||||
title: i18n.ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
|
||||
renderContent: ({
|
||||
alertItems,
|
||||
refresh,
|
||||
setIsBulkActionsLoading,
|
||||
clearSelection,
|
||||
closePopoverMenu,
|
||||
}: RenderContentPanelProps) => (
|
||||
<BulkAlertTagsPanel
|
||||
alertItems={alertItems}
|
||||
refresh={refresh}
|
||||
refetchQuery={refetch}
|
||||
setIsLoading={setIsBulkActionsLoading}
|
||||
clearSelection={clearSelection}
|
||||
closePopoverMenu={closePopoverMenu}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
const TitleContent = useMemo(
|
||||
() => (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>{i18n.ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip content={i18n.ALERT_TAGS_CONTEXT_MENU_ITEM_TOOLTIP_INFO} position="right" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const renderContent = useCallback(
|
||||
({
|
||||
alertItems,
|
||||
refresh,
|
||||
setIsBulkActionsLoading,
|
||||
clearSelection,
|
||||
closePopoverMenu,
|
||||
}: RenderContentPanelProps) => (
|
||||
<BulkAlertTagsPanel
|
||||
alertItems={alertItems}
|
||||
refresh={refresh}
|
||||
refetchQuery={refetch}
|
||||
setIsLoading={setIsBulkActionsLoading}
|
||||
clearSelection={clearSelection}
|
||||
closePopoverMenu={closePopoverMenu}
|
||||
onSubmit={handleOnAlertTagsSubmit}
|
||||
/>
|
||||
),
|
||||
[handleOnAlertTagsSubmit, refetch]
|
||||
);
|
||||
|
||||
const alertTagsPanels: UseBulkAlertTagsPanel[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 1,
|
||||
title: TitleContent,
|
||||
'data-test-subj': 'alert-tags-context-menu-panel',
|
||||
renderContent,
|
||||
},
|
||||
],
|
||||
[TitleContent, renderContent]
|
||||
);
|
||||
|
||||
return {
|
||||
alertTagsItems,
|
||||
|
|
|
@ -5,16 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { AlertTags } from '../../../../../common/detection_engine/schemas/common';
|
||||
import { DETECTION_ENGINE_ALERT_TAGS_URL } from '../../../../../common/constants';
|
||||
import { useAppToasts } from '../../../hooks/use_app_toasts';
|
||||
import * as i18n from './translations';
|
||||
import { getUpdateAlertsQuery } from './helpers';
|
||||
import { setAlertTags } from '../../../containers/alert_tags/api';
|
||||
|
||||
export type SetAlertTagsFunc = (
|
||||
tags: AlertTags,
|
||||
|
@ -22,7 +19,7 @@ export type SetAlertTagsFunc = (
|
|||
onSuccess: () => void,
|
||||
setTableLoading: (param: boolean) => void
|
||||
) => Promise<void>;
|
||||
export type ReturnSetAlertTags = [boolean, SetAlertTagsFunc | null];
|
||||
export type ReturnSetAlertTags = SetAlertTagsFunc | null;
|
||||
|
||||
/**
|
||||
* Update alert tags by query
|
||||
|
@ -37,22 +34,12 @@ export type ReturnSetAlertTags = [boolean, SetAlertTagsFunc | null];
|
|||
*/
|
||||
export const useSetAlertTags = (): ReturnSetAlertTags => {
|
||||
const { http } = useKibana<CoreStart>().services;
|
||||
const { addSuccess, addError, addWarning } = useAppToasts();
|
||||
const { addSuccess, addError } = useAppToasts();
|
||||
const setAlertTagsRef = useRef<SetAlertTagsFunc | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onUpdateSuccess = useCallback(
|
||||
(updated: number, conflicts: number) => {
|
||||
if (conflicts > 0) {
|
||||
addWarning({
|
||||
title: i18n.UPDATE_ALERT_TAGS_FAILED(conflicts),
|
||||
text: i18n.UPDATE_ALERT_TAGS_FAILED_DETAILED(updated, conflicts),
|
||||
});
|
||||
} else {
|
||||
addSuccess(i18n.UPDATE_ALERT_TAGS_SUCCESS_TOAST(updated));
|
||||
}
|
||||
},
|
||||
[addSuccess, addWarning]
|
||||
(updated: number) => addSuccess(i18n.UPDATE_ALERT_TAGS_SUCCESS_TOAST(updated)),
|
||||
[addSuccess]
|
||||
);
|
||||
|
||||
const onUpdateFailure = useCallback(
|
||||
|
@ -67,30 +54,16 @@ export const useSetAlertTags = (): ReturnSetAlertTags => {
|
|||
const abortCtrl = new AbortController();
|
||||
|
||||
const onSetAlertTags: SetAlertTagsFunc = async (tags, ids, onSuccess, setTableLoading) => {
|
||||
const query: Record<string, unknown> = getUpdateAlertsQuery(ids).query;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setTableLoading(true);
|
||||
const response = await http.fetch<estypes.UpdateByQueryResponse>(
|
||||
DETECTION_ENGINE_ALERT_TAGS_URL,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tags, query }),
|
||||
signal: abortCtrl.signal,
|
||||
}
|
||||
);
|
||||
const response = await setAlertTags({ tags, ids, signal: abortCtrl.signal });
|
||||
if (!ignore) {
|
||||
setTableLoading(false);
|
||||
onSuccess();
|
||||
if (response.version_conflicts && ids.length === 1) {
|
||||
throw new Error(i18n.BULK_ACTION_FAILED_SINGLE_ALERT);
|
||||
}
|
||||
setIsLoading(false);
|
||||
onUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0);
|
||||
setTableLoading(false);
|
||||
onUpdateSuccess(response.items.length);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
setIsLoading(false);
|
||||
setTableLoading(false);
|
||||
onUpdateFailure(error);
|
||||
}
|
||||
|
@ -104,5 +77,5 @@ export const useSetAlertTags = (): ReturnSetAlertTags => {
|
|||
};
|
||||
}, [http, onUpdateFailure, onUpdateSuccess]);
|
||||
|
||||
return [isLoading, setAlertTagsRef.current];
|
||||
return setAlertTagsRef.current;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { estypes } from '@elastic/elasticsearch';
|
||||
import { DETECTION_ENGINE_ALERT_TAGS_URL } from '../../../../common/constants';
|
||||
import type { AlertTags } from '../../../../common/detection_engine/schemas/common';
|
||||
import { KibanaServices } from '../../lib/kibana';
|
||||
|
||||
export const setAlertTags = async ({
|
||||
tags,
|
||||
ids,
|
||||
signal,
|
||||
}: {
|
||||
tags: AlertTags;
|
||||
ids: string[];
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<estypes.BulkResponse> => {
|
||||
return KibanaServices.get().http.fetch<estypes.BulkResponse>(DETECTION_ENGINE_ALERT_TAGS_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tags, ids }),
|
||||
signal,
|
||||
});
|
||||
};
|
|
@ -95,6 +95,7 @@ const markAsAcknowledgedButton = '[data-test-subj="acknowledged-alert-status"]';
|
|||
const markAsClosedButton = '[data-test-subj="close-alert-status"]';
|
||||
const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]';
|
||||
const openAlertDetailsPageButton = '[data-test-subj="open-alert-details-page-menu-item"]';
|
||||
const applyAlertTagsButton = '[data-test-subj="alert-tags-context-menu-item"]';
|
||||
|
||||
describe('Alert table context menu', () => {
|
||||
describe('Case actions', () => {
|
||||
|
@ -283,7 +284,7 @@ describe('Alert table context menu', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Open alert details action', () => {
|
||||
describe('Open alert details action', () => {
|
||||
test('it does not render the open alert details page action if kibana.alert.rule.uuid is not set', () => {
|
||||
const nonAlertProps = {
|
||||
...props,
|
||||
|
@ -320,4 +321,16 @@ describe('Alert table context menu', () => {
|
|||
expect(wrapper.find(openAlertDetailsPageButton).first().exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Apply alert tags action', () => {
|
||||
test('it renders the apply alert tags action button', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
|
||||
expect(wrapper.find(applyAlertTagsButton).first().exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -224,7 +224,6 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
|
|||
const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({
|
||||
closePopover,
|
||||
ecsRowData,
|
||||
scopeId,
|
||||
refetch: refetchAll,
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type { UseAlertTagsActionsProps } from './use_alert_tags_actions';
|
||||
import { useAlertTagsActions } from './use_alert_tags_actions';
|
||||
import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges';
|
||||
import type { AlertTableContextMenuItem } from '../types';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import type { EuiContextMenuPanelDescriptor } from '@elastic/eui';
|
||||
import { EuiPopover, EuiContextMenu } from '@elastic/eui';
|
||||
import { useSetAlertTags } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_tags';
|
||||
import { useUiSetting$ } from '../../../../common/lib/kibana';
|
||||
|
||||
jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges');
|
||||
jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_tags');
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
const defaultProps: UseAlertTagsActionsProps = {
|
||||
closePopover: jest.fn(),
|
||||
ecsRowData: {
|
||||
_id: '123',
|
||||
kibana: {
|
||||
alert: {
|
||||
workflow_tags: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
const renderContextMenu = (
|
||||
items: AlertTableContextMenuItem[],
|
||||
panels: EuiContextMenuPanelDescriptor[]
|
||||
) => {
|
||||
const panelsToRender = [{ id: 0, items }, ...panels];
|
||||
return render(
|
||||
<EuiPopover
|
||||
isOpen={true}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
closePopover={() => {}}
|
||||
button={<></>}
|
||||
>
|
||||
<EuiContextMenu size="s" initialPanelId={1} panels={panelsToRender} />
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useAlertTagsActions', () => {
|
||||
beforeEach(() => {
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({
|
||||
hasIndexWrite: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render alert tagging actions', () => {
|
||||
const { result } = renderHook(() => useAlertTagsActions(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertTagsItems.length).toEqual(1);
|
||||
expect(result.current.alertTagsPanels.length).toEqual(1);
|
||||
|
||||
expect(result.current.alertTagsItems[0]['data-test-subj']).toEqual(
|
||||
'alert-tags-context-menu-item'
|
||||
);
|
||||
expect(result.current.alertTagsPanels[0].content).toMatchInlineSnapshot(`
|
||||
<Memo(BulkAlertTagsPanelComponent)
|
||||
alertItems={
|
||||
Array [
|
||||
Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
"data": Array [
|
||||
Object {
|
||||
"field": "kibana.alert.workflow_tags",
|
||||
"value": Array [],
|
||||
},
|
||||
],
|
||||
"ecs": Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
closePopoverMenu={[MockFunction]}
|
||||
onSubmit={[Function]}
|
||||
refetchQuery={[MockFunction]}
|
||||
setIsLoading={[Function]}
|
||||
/>
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not render alert tagging actions if user doesn't have write permissions", () => {
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({
|
||||
hasIndexWrite: false,
|
||||
});
|
||||
const { result } = renderHook(() => useAlertTagsActions(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertTagsItems.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should still render if workflow_tags field does not exist', () => {
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
ecsRowData: {
|
||||
_id: '123',
|
||||
},
|
||||
};
|
||||
const { result } = renderHook(() => useAlertTagsActions(newProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertTagsItems.length).toEqual(1);
|
||||
expect(result.current.alertTagsPanels.length).toEqual(1);
|
||||
expect(result.current.alertTagsPanels[0].content).toMatchInlineSnapshot(`
|
||||
<Memo(BulkAlertTagsPanelComponent)
|
||||
alertItems={
|
||||
Array [
|
||||
Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
"data": Array [
|
||||
Object {
|
||||
"field": "kibana.alert.workflow_tags",
|
||||
"value": Array [],
|
||||
},
|
||||
],
|
||||
"ecs": Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
closePopoverMenu={[MockFunction]}
|
||||
onSubmit={[Function]}
|
||||
refetchQuery={[MockFunction]}
|
||||
setIsLoading={[Function]}
|
||||
/>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should render the nested panel', async () => {
|
||||
(useSetAlertTags as jest.Mock).mockReturnValue(jest.fn());
|
||||
(useUiSetting$ as jest.Mock).mockReturnValue([['default-test-tag-1', 'default-test-tag-2']]);
|
||||
|
||||
const { result } = renderHook(() => useAlertTagsActions(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
const alertTagsItems = result.current.alertTagsItems;
|
||||
const alertTagsPanels = result.current.alertTagsPanels;
|
||||
const { getByTestId } = renderContextMenu(alertTagsItems, alertTagsPanels);
|
||||
|
||||
expect(getByTestId('alert-tags-selectable-menu')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -14,14 +14,17 @@ import { useBulkAlertTagsItems } from '../../../../common/components/toolbar/bul
|
|||
import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges';
|
||||
import type { AlertTableContextMenuItem } from '../types';
|
||||
|
||||
interface Props {
|
||||
export interface UseAlertTagsActionsProps {
|
||||
closePopover: () => void;
|
||||
ecsRowData: Ecs;
|
||||
scopeId: string;
|
||||
refetch?: () => void;
|
||||
}
|
||||
|
||||
export const useAlertTagsActions = ({ closePopover, ecsRowData, scopeId, refetch }: Props) => {
|
||||
export const useAlertTagsActions = ({
|
||||
closePopover,
|
||||
ecsRowData,
|
||||
refetch,
|
||||
}: UseAlertTagsActionsProps) => {
|
||||
const { hasIndexWrite } = useAlertsPrivileges();
|
||||
const alertId = ecsRowData._id;
|
||||
const alertTagData = useMemo(() => {
|
||||
|
@ -40,7 +43,9 @@ export const useAlertTagsActions = ({ closePopover, ecsRowData, scopeId, refetch
|
|||
];
|
||||
}, [alertId, ecsRowData._index, ecsRowData?.kibana?.alert.workflow_tags]);
|
||||
|
||||
const { alertTagsItems, alertTagsPanels } = useBulkAlertTagsItems({ refetch });
|
||||
const { alertTagsItems, alertTagsPanels } = useBulkAlertTagsItems({
|
||||
refetch,
|
||||
});
|
||||
|
||||
const itemsToReturn: AlertTableContextMenuItem[] = useMemo(
|
||||
() =>
|
||||
|
|
|
@ -36,6 +36,7 @@ import { getUserPrivilegesMockDefaultValue } from '../../../common/components/us
|
|||
import { allCasesPermissions } from '../../../cases_test_utils';
|
||||
import { HostStatus } from '../../../../common/endpoint/types';
|
||||
import { ENDPOINT_CAPABILITIES } from '../../../../common/endpoint/service/response_actions/constants';
|
||||
import { ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE } from '../../../common/components/toolbar/bulk_actions/translations';
|
||||
|
||||
jest.mock('../../../common/components/user_privileges');
|
||||
|
||||
|
@ -240,6 +241,13 @@ describe('take action dropdown', () => {
|
|||
).toEqual('Respond');
|
||||
});
|
||||
});
|
||||
test('should render "Apply alert tags"', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="alert-tags-context-menu-item"]').first().text()
|
||||
).toEqual(ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for Endpoint related actions', () => {
|
||||
|
|
|
@ -185,7 +185,6 @@ export const TakeActionDropdown = React.memo(
|
|||
const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({
|
||||
closePopover: closePopoverHandler,
|
||||
ecsRowData: ecsData ?? { _id: actionsData.eventId },
|
||||
scopeId,
|
||||
refetch,
|
||||
});
|
||||
|
||||
|
|
|
@ -8,11 +8,15 @@
|
|||
import type { AlertTags } from '../../../../../common/detection_engine/schemas/common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const validateAlertTagsArrays = (tags: AlertTags) => {
|
||||
export const validateAlertTagsArrays = (tags: AlertTags, ids: string[]) => {
|
||||
const validationErrors = [];
|
||||
if (ids.length === 0) {
|
||||
validationErrors.push(i18n.NO_IDS_VALIDATION_ERROR);
|
||||
}
|
||||
const { tags_to_add: tagsToAdd, tags_to_remove: tagsToRemove } = tags;
|
||||
const duplicates = tagsToAdd.filter((tag) => tagsToRemove.includes(tag));
|
||||
if (duplicates.length) {
|
||||
return [i18n.ALERT_TAGS_VALIDATION_ERROR(JSON.stringify(duplicates))];
|
||||
validationErrors.push(i18n.ALERT_TAGS_VALIDATION_ERROR(JSON.stringify(duplicates)));
|
||||
}
|
||||
return [];
|
||||
return validationErrors;
|
||||
};
|
||||
|
|
|
@ -27,12 +27,14 @@ describe('setAlertTagsRoute', () => {
|
|||
request = requestMock.create({
|
||||
method: 'post',
|
||||
path: DETECTION_ENGINE_ALERT_TAGS_URL,
|
||||
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-2']),
|
||||
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-2'], ['test-id']),
|
||||
});
|
||||
|
||||
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse(
|
||||
getSuccessfulSignalUpdateResponse()
|
||||
);
|
||||
context.core.elasticsearch.client.asCurrentUser.bulk.mockResponse({
|
||||
errors: false,
|
||||
took: 0,
|
||||
items: [{ update: { result: 'updated', status: 200, _index: 'test-index' } }],
|
||||
});
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
|
@ -45,7 +47,7 @@ describe('setAlertTagsRoute', () => {
|
|||
request = requestMock.create({
|
||||
method: 'post',
|
||||
path: DETECTION_ENGINE_ALERT_TAGS_URL,
|
||||
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-1']),
|
||||
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-1'], ['test-id']),
|
||||
});
|
||||
|
||||
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse(
|
||||
|
@ -65,20 +67,43 @@ describe('setAlertTagsRoute', () => {
|
|||
status_code: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('500s', () => {
|
||||
test('returns 500 if ', async () => {
|
||||
test('returns 400 if no alert ids are provided', async () => {
|
||||
request = requestMock.create({
|
||||
method: 'post',
|
||||
path: DETECTION_ENGINE_ALERT_TAGS_URL,
|
||||
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-2']),
|
||||
});
|
||||
|
||||
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse(
|
||||
getSuccessfulSignalUpdateResponse()
|
||||
);
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue(
|
||||
new Error('Test error')
|
||||
);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: [`No alert ids were provided`],
|
||||
status_code: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('500s', () => {
|
||||
test('returns 500 if asCurrentUser throws error', async () => {
|
||||
request = requestMock.create({
|
||||
method: 'post',
|
||||
path: DETECTION_ENGINE_ALERT_TAGS_URL,
|
||||
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-2'], ['test-id']),
|
||||
});
|
||||
|
||||
context.core.elasticsearch.client.asCurrentUser.bulk.mockRejectedValue(
|
||||
new Error('Test error')
|
||||
);
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.body).toEqual({
|
||||
|
|
|
@ -32,13 +32,13 @@ export const setAlertTagsRoute = (router: SecuritySolutionPluginRouter) => {
|
|||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const { tags, query } = request.body;
|
||||
const { tags, ids } = request.body;
|
||||
const core = await context.core;
|
||||
const securitySolution = await context.securitySolution;
|
||||
const esClient = core.elasticsearch.client.asCurrentUser;
|
||||
const siemClient = securitySolution?.getAppClient();
|
||||
const siemResponse = buildSiemResponse(response);
|
||||
const validationErrors = validateAlertTagsArrays(tags);
|
||||
const validationErrors = validateAlertTagsArrays(tags, ids);
|
||||
const spaceId = securitySolution?.getSpaceId() ?? 'default';
|
||||
|
||||
if (validationErrors.length) {
|
||||
|
@ -49,45 +49,50 @@ export const setAlertTagsRoute = (router: SecuritySolutionPluginRouter) => {
|
|||
return siemResponse.error({ statusCode: 404 });
|
||||
}
|
||||
|
||||
let queryObject;
|
||||
if (query) {
|
||||
queryObject = {
|
||||
bool: {
|
||||
filter: query,
|
||||
},
|
||||
};
|
||||
}
|
||||
const tagsToAdd = uniq(tags.tags_to_add);
|
||||
const tagsToRemove = uniq(tags.tags_to_remove);
|
||||
try {
|
||||
const body = await esClient.updateByQuery({
|
||||
index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`,
|
||||
refresh: false,
|
||||
body: {
|
||||
script: {
|
||||
params: { tagsToAdd, tagsToRemove },
|
||||
source: `List newTagsArray = [];
|
||||
if (ctx._source["kibana.alert.workflow_tags"] != null) {
|
||||
for (tag in ctx._source["kibana.alert.workflow_tags"]) {
|
||||
if (!params.tagsToRemove.contains(tag)) {
|
||||
newTagsArray.add(tag);
|
||||
}
|
||||
}
|
||||
for (tag in params.tagsToAdd) {
|
||||
if (!newTagsArray.contains(tag)) {
|
||||
newTagsArray.add(tag)
|
||||
}
|
||||
}
|
||||
ctx._source["kibana.alert.workflow_tags"] = newTagsArray;
|
||||
} else {
|
||||
ctx._source["kibana.alert.workflow_tags"] = params.tagsToAdd;
|
||||
}
|
||||
`,
|
||||
lang: 'painless',
|
||||
|
||||
const painlessScript = {
|
||||
params: { tagsToAdd, tagsToRemove },
|
||||
source: `List newTagsArray = [];
|
||||
if (ctx._source["kibana.alert.workflow_tags"] != null) {
|
||||
for (tag in ctx._source["kibana.alert.workflow_tags"]) {
|
||||
if (!params.tagsToRemove.contains(tag)) {
|
||||
newTagsArray.add(tag);
|
||||
}
|
||||
}
|
||||
for (tag in params.tagsToAdd) {
|
||||
if (!newTagsArray.contains(tag)) {
|
||||
newTagsArray.add(tag)
|
||||
}
|
||||
}
|
||||
ctx._source["kibana.alert.workflow_tags"] = newTagsArray;
|
||||
} else {
|
||||
ctx._source["kibana.alert.workflow_tags"] = params.tagsToAdd;
|
||||
}
|
||||
`,
|
||||
lang: 'painless',
|
||||
};
|
||||
|
||||
const bulkUpdateRequest = [];
|
||||
for (const id of ids) {
|
||||
bulkUpdateRequest.push(
|
||||
{
|
||||
update: {
|
||||
_index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`,
|
||||
_id: id,
|
||||
},
|
||||
query: queryObject,
|
||||
},
|
||||
ignore_unavailable: true,
|
||||
{
|
||||
script: painlessScript,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await esClient.bulk({
|
||||
refresh: 'wait_for',
|
||||
body: bulkUpdateRequest,
|
||||
});
|
||||
return response.ok({ body });
|
||||
} catch (err) {
|
||||
|
|
|
@ -13,3 +13,10 @@ export const ALERT_TAGS_VALIDATION_ERROR = (duplicates: string) =>
|
|||
defaultMessage:
|
||||
'Duplicate tags { duplicates } were found in the tags_to_add and tags_to_remove parameters.',
|
||||
});
|
||||
|
||||
export const NO_IDS_VALIDATION_ERROR = i18n.translate(
|
||||
'xpack.securitySolution.api.alertTags.noAlertIds',
|
||||
{
|
||||
defaultMessage: 'No alert ids were provided',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -594,7 +594,7 @@ export interface BulkActionsConfig {
|
|||
|
||||
interface PanelConfig {
|
||||
id: number;
|
||||
title?: string;
|
||||
title?: JSX.Element | string;
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -24,9 +24,8 @@ import {
|
|||
getSignalsByIds,
|
||||
waitForRuleSuccess,
|
||||
getRuleForSignalTesting,
|
||||
getAlertUpdateByQueryEmptyResponse,
|
||||
} from '../../utils';
|
||||
import { buildAlertTagsQuery, setAlertTags } from '../../utils/set_alert_tags';
|
||||
import { setAlertTags } from '../../utils/set_alert_tags';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ({ getService }: FtrProviderContext) => {
|
||||
|
@ -37,24 +36,24 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
|
||||
describe('set_alert_tags', () => {
|
||||
describe('validation checks', () => {
|
||||
it('should not give errors when querying and the signals index does not exist yet', async () => {
|
||||
it('should give errors when no alert ids are provided', async () => {
|
||||
const { body } = await supertest
|
||||
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(setAlertTags({ tagsToAdd: [], tagsToRemove: [] }))
|
||||
.expect(200);
|
||||
.send(setAlertTags({ tagsToAdd: [], tagsToRemove: [], ids: [] }))
|
||||
.expect(400);
|
||||
|
||||
// remove any server generated items that are indeterministic
|
||||
delete body.took;
|
||||
|
||||
expect(body).to.eql(getAlertUpdateByQueryEmptyResponse());
|
||||
expect(body).to.eql({
|
||||
message: ['No alert ids were provided'],
|
||||
status_code: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should give errors when duplicate tags exist in both tags_to_add and tags_to_remove', async () => {
|
||||
const { body } = await supertest
|
||||
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send(setAlertTags({ tagsToAdd: ['test-1'], tagsToRemove: ['test-1'] }))
|
||||
.send(setAlertTags({ tagsToAdd: ['test-1'], tagsToRemove: ['test-1'], ids: ['123'] }))
|
||||
.expect(400);
|
||||
|
||||
expect(body).to.eql({
|
||||
|
@ -66,7 +65,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
});
|
||||
});
|
||||
|
||||
describe.skip('tests with auditbeat data', () => {
|
||||
describe('tests with auditbeat data', () => {
|
||||
before(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
|
||||
});
|
||||
|
@ -102,7 +101,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
setAlertTags({
|
||||
tagsToAdd: ['tag-1'],
|
||||
tagsToRemove: [],
|
||||
query: buildAlertTagsQuery(alertIds),
|
||||
ids: alertIds,
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
@ -136,7 +135,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
setAlertTags({
|
||||
tagsToAdd: ['tag-1'],
|
||||
tagsToRemove: [],
|
||||
query: buildAlertTagsQuery(alertIds.slice(0, 4)),
|
||||
ids: alertIds.slice(0, 4),
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
@ -148,7 +147,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
setAlertTags({
|
||||
tagsToAdd: ['tag-1'],
|
||||
tagsToRemove: [],
|
||||
query: buildAlertTagsQuery(alertIds),
|
||||
ids: alertIds,
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
@ -182,7 +181,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
setAlertTags({
|
||||
tagsToAdd: ['tag-1', 'tag-2'],
|
||||
tagsToRemove: [],
|
||||
query: buildAlertTagsQuery(alertIds),
|
||||
ids: alertIds,
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
@ -194,7 +193,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
setAlertTags({
|
||||
tagsToAdd: [],
|
||||
tagsToRemove: ['tag-2'],
|
||||
query: buildAlertTagsQuery(alertIds),
|
||||
ids: alertIds,
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
@ -228,7 +227,7 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
setAlertTags({
|
||||
tagsToAdd: [],
|
||||
tagsToRemove: ['tag-1'],
|
||||
query: buildAlertTagsQuery(alertIds),
|
||||
ids: alertIds,
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
|
|
@ -5,31 +5,21 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { AlertTagQuery } from '@kbn/security-solution-plugin/common/detection_engine/schemas/common';
|
||||
import { AlertTagIds } from '@kbn/security-solution-plugin/common/detection_engine/schemas/common';
|
||||
import { SetAlertTagsSchema } from '@kbn/security-solution-plugin/common/detection_engine/schemas/request/set_alert_tags_schema';
|
||||
|
||||
export const setAlertTags = ({
|
||||
tagsToAdd,
|
||||
tagsToRemove,
|
||||
query,
|
||||
ids,
|
||||
}: {
|
||||
tagsToAdd: string[];
|
||||
tagsToRemove: string[];
|
||||
query?: AlertTagQuery;
|
||||
ids: AlertTagIds;
|
||||
}): SetAlertTagsSchema => ({
|
||||
tags: {
|
||||
tags_to_add: tagsToAdd,
|
||||
tags_to_remove: tagsToRemove,
|
||||
},
|
||||
query,
|
||||
});
|
||||
|
||||
export const buildAlertTagsQuery = (alertIds: string[]) => ({
|
||||
bool: {
|
||||
filter: {
|
||||
terms: {
|
||||
_id: alertIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
ids,
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue