[Security Solution][Detection Alerts] Alert tagging follow-up (#160305)

This commit is contained in:
Davis Plumlee 2023-07-11 17:11:23 -04:00 committed by GitHub
parent 9d711fb944
commit 88fc4a6627
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 829 additions and 242 deletions

View file

@ -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>;

View file

@ -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 });

View file

@ -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;

View file

@ -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');

View file

@ -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 }
);
};

View file

@ -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
);
});
});

View file

@ -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>
</>
);

View file

@ -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,
},
},
},
},
};
};

View file

@ -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;
}
};

View file

@ -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.',
}
);

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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;
};

View file

@ -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,
});
};

View file

@ -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);
});
});
});

View file

@ -224,7 +224,6 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({
closePopover,
ecsRowData,
scopeId,
refetch: refetchAll,
});

View file

@ -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();
});
});

View file

@ -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(
() =>

View file

@ -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', () => {

View file

@ -185,7 +185,6 @@ export const TakeActionDropdown = React.memo(
const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({
closePopover: closePopoverHandler,
ecsRowData: ecsData ?? { _id: actionsData.eventId },
scopeId,
refetch,
});

View file

@ -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;
};

View file

@ -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({

View file

@ -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) {

View file

@ -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',
}
);

View file

@ -594,7 +594,7 @@ export interface BulkActionsConfig {
interface PanelConfig {
id: number;
title?: string;
title?: JSX.Element | string;
'data-test-subj'?: string;
}

View file

@ -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);

View file

@ -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,
});