mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Cases] Remove case info from alerts when deleting an alert attachment (#154024)
## Summary Users can remove alerts from a case by deleting the whole alert attachment. This PR removes the case id from the alerts when deleting an attachment of type alert. It does not remove the case info from all alerts attached to a case when deleting a case. It also fixes a bug where the success toaster will not show when deleting an attachment. Related: https://github.com/elastic/kibana/issues/146864, https://github.com/elastic/kibana/issues/140800 ## Testing 1. Create a case and attach some alerts to the case. 2. Verify that the alerts table (in security or in o11y) shows the case the alert is attached to. You can enable the cases column by pressing "Fields", searching for "Cases", and then selecting the field. 3. Go to the case and find the alerts' user activity. 4. Press the `...` and press "Remove alerts(s)" 5. Go back to the alert table and verify that the case is not shown in the Cases column for each alert. Please check that when you remove alert(s), attachments (ml, etc), and comments you get a success toaster with the correct text. ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
parent
41fb8f3b50
commit
3a6f7211b4
22 changed files with 1041 additions and 53 deletions
59
x-pack/plugins/cases/common/utils/attachments.test.ts
Normal file
59
x-pack/plugins/cases/common/utils/attachments.test.ts
Normal 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 { CommentAttributes } from '../api';
|
||||
import { CommentType } from '../api';
|
||||
import {
|
||||
isCommentRequestTypeExternalReference,
|
||||
isCommentRequestTypePersistableState,
|
||||
} from './attachments';
|
||||
|
||||
describe('attachments utils', () => {
|
||||
describe('isCommentRequestTypeExternalReference', () => {
|
||||
const externalReferenceAttachment = {
|
||||
type: CommentType.externalReference as const,
|
||||
} as CommentAttributes;
|
||||
|
||||
const commentTypeWithoutAlert = Object.values(CommentType).filter(
|
||||
(type) => type !== CommentType.externalReference
|
||||
);
|
||||
|
||||
it('returns false for type: externalReference', () => {
|
||||
expect(isCommentRequestTypeExternalReference(externalReferenceAttachment)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(commentTypeWithoutAlert)('returns false for type: %s', (type) => {
|
||||
const attachment = {
|
||||
type,
|
||||
} as CommentAttributes;
|
||||
|
||||
expect(isCommentRequestTypeExternalReference(attachment)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCommentRequestTypePersistableState', () => {
|
||||
const persistableStateAttachment = {
|
||||
type: CommentType.persistableState as const,
|
||||
} as CommentAttributes;
|
||||
|
||||
const commentTypeWithoutAlert = Object.values(CommentType).filter(
|
||||
(type) => type !== CommentType.persistableState
|
||||
);
|
||||
|
||||
it('returns false for type: persistableState', () => {
|
||||
expect(isCommentRequestTypePersistableState(persistableStateAttachment)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(commentTypeWithoutAlert)('returns false for type: %s', (type) => {
|
||||
const attachment = {
|
||||
type,
|
||||
} as CommentAttributes;
|
||||
|
||||
expect(isCommentRequestTypePersistableState(attachment)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -21,6 +21,7 @@ import { ShowAlertTableLink } from './show_alert_table_link';
|
|||
import { HoverableUserWithAvatarResolver } from '../../user_profiles/hoverable_user_with_avatar_resolver';
|
||||
import { UserActionContentToolbar } from '../content_toolbar';
|
||||
import { AlertPropertyActions } from '../property_actions/alert_property_actions';
|
||||
import { DELETE_ALERTS_SUCCESS_TITLE } from './translations';
|
||||
|
||||
type BuilderArgs = Pick<
|
||||
UserActionBuilderArgs,
|
||||
|
@ -88,7 +89,7 @@ const getSingleAlertUserAction = ({
|
|||
/>
|
||||
</EuiFlexItem>
|
||||
<AlertPropertyActions
|
||||
onDelete={() => handleDeleteComment(comment.id)}
|
||||
onDelete={() => handleDeleteComment(comment.id, DELETE_ALERTS_SUCCESS_TITLE(1))}
|
||||
isLoading={loadingCommentIds.includes(comment.id)}
|
||||
totalAlerts={1}
|
||||
/>
|
||||
|
@ -142,7 +143,9 @@ const getMultipleAlertsUserAction = ({
|
|||
<ShowAlertTableLink />
|
||||
</EuiFlexItem>
|
||||
<AlertPropertyActions
|
||||
onDelete={() => handleDeleteComment(comment.id)}
|
||||
onDelete={() =>
|
||||
handleDeleteComment(comment.id, DELETE_ALERTS_SUCCESS_TITLE(totalAlerts))
|
||||
}
|
||||
isLoading={loadingCommentIds.includes(comment.id)}
|
||||
totalAlerts={totalAlerts}
|
||||
/>
|
||||
|
|
|
@ -201,7 +201,10 @@ describe('createCommentUserActionBuilder', () => {
|
|||
await deleteAttachment(result, 'trash', 'Delete');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith('basic-comment-id');
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
|
||||
'basic-comment-id',
|
||||
'Deleted comment'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -319,7 +322,10 @@ describe('createCommentUserActionBuilder', () => {
|
|||
await deleteAttachment(res, 'minusInCircle', 'Remove');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith('alert-comment-id');
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
|
||||
'alert-comment-id',
|
||||
'Deleted one alert'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -414,7 +420,10 @@ describe('createCommentUserActionBuilder', () => {
|
|||
await deleteAttachment(res, 'minusInCircle', 'Remove');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith('alert-comment-id');
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
|
||||
'alert-comment-id',
|
||||
'Deleted 2 alerts'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -590,7 +599,8 @@ describe('createCommentUserActionBuilder', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
|
||||
'external-reference-comment-id'
|
||||
'external-reference-comment-id',
|
||||
'Deleted attachment'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -761,7 +771,8 @@ describe('createCommentUserActionBuilder', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
|
||||
'persistable-state-comment-id'
|
||||
'persistable-state-comment-id',
|
||||
'Deleted attachment'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,7 +21,11 @@ import type { CommentResponse } from '../../../../common/api';
|
|||
import type { UserActionBuilder, UserActionBuilderArgs } from '../types';
|
||||
import { UserActionTimestamp } from '../timestamp';
|
||||
import type { SnakeToCamelCase } from '../../../../common/types';
|
||||
import { ATTACHMENT_NOT_REGISTERED_ERROR, DEFAULT_EVENT_ATTACHMENT_TITLE } from './translations';
|
||||
import {
|
||||
ATTACHMENT_NOT_REGISTERED_ERROR,
|
||||
DEFAULT_EVENT_ATTACHMENT_TITLE,
|
||||
DELETE_REGISTERED_ATTACHMENT,
|
||||
} from './translations';
|
||||
import { UserActionContentToolbar } from '../content_toolbar';
|
||||
import { HoverableUserWithAvatarResolver } from '../../user_profiles/hoverable_user_with_avatar_resolver';
|
||||
import { RegisteredAttachmentsPropertyActions } from '../property_actions/registered_attachments_property_actions';
|
||||
|
@ -127,7 +131,7 @@ export const createRegisteredAttachmentUserActionBuilder = <
|
|||
{attachmentViewObject.actions}
|
||||
<RegisteredAttachmentsPropertyActions
|
||||
isLoading={isLoading}
|
||||
onDelete={() => handleDeleteComment(comment.id)}
|
||||
onDelete={() => handleDeleteComment(comment.id, DELETE_REGISTERED_ATTACHMENT)}
|
||||
/>
|
||||
</UserActionContentToolbar>
|
||||
),
|
||||
|
|
|
@ -39,3 +39,24 @@ export const UNSAVED_DRAFT_COMMENT = i18n.translate(
|
|||
defaultMessage: 'You have unsaved edits for this comment',
|
||||
}
|
||||
);
|
||||
|
||||
export const DELETE_ALERTS_SUCCESS_TITLE = (totalAlerts: number) =>
|
||||
i18n.translate('xpack.cases.userActions.attachments.alerts.successToasterTitle', {
|
||||
defaultMessage:
|
||||
'Deleted {totalAlerts, plural, =1 {one} other {{totalAlerts}}} {totalAlerts, plural, =1 {alert} other {alerts}}',
|
||||
values: { totalAlerts },
|
||||
});
|
||||
|
||||
export const DELETE_COMMENT_SUCCESS_TITLE = i18n.translate(
|
||||
'xpack.cases.userActions.attachments.comment.successToasterTitle',
|
||||
{
|
||||
defaultMessage: 'Deleted comment',
|
||||
}
|
||||
);
|
||||
|
||||
export const DELETE_REGISTERED_ATTACHMENT = i18n.translate(
|
||||
'xpack.cases.userActions.attachments.registeredAttachment.successToasterTitle',
|
||||
{
|
||||
defaultMessage: 'Deleted attachment',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -115,7 +115,7 @@ export const createUserAttachmentUserActionBuilder = ({
|
|||
isLoading={isLoading}
|
||||
commentContent={comment.comment}
|
||||
onEdit={() => handleManageMarkdownEditId(comment.id)}
|
||||
onDelete={() => handleDeleteComment(comment.id)}
|
||||
onDelete={() => handleDeleteComment(comment.id, i18n.DELETE_COMMENT_SUCCESS_TITLE)}
|
||||
onQuote={() => handleManageQuote(comment.comment)}
|
||||
/>
|
||||
</UserActionContentToolbar>
|
||||
|
|
|
@ -69,7 +69,7 @@ export interface UserActionBuilderArgs {
|
|||
handleOutlineComment: (id: string) => void;
|
||||
handleManageMarkdownEditId: (id: string) => void;
|
||||
handleSaveComment: ({ id, version }: { id: string; version: string }, content: string) => void;
|
||||
handleDeleteComment: (id: string) => void;
|
||||
handleDeleteComment: (id: string, successToasterTitle: string) => void;
|
||||
handleManageQuote: (quote: string) => void;
|
||||
onShowAlertDetails: (alertId: string, index: string) => void;
|
||||
getRuleDetailsHref?: RuleDetailsNavigation['href'];
|
||||
|
|
|
@ -76,8 +76,8 @@ export const useUserActionsHandler = (): UseUserActionsHandler => {
|
|||
);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
(id: string) => {
|
||||
deleteComment({ caseId, commentId: id });
|
||||
(id: string, successToasterTitle: string) => {
|
||||
deleteComment({ caseId, commentId: id, successToasterTitle });
|
||||
},
|
||||
[caseId, deleteComment]
|
||||
);
|
||||
|
|
|
@ -5,23 +5,21 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import type { UseDeleteComment } from './use_delete_comment';
|
||||
import { useDeleteComment } from './use_delete_comment';
|
||||
import * as api from './api';
|
||||
import { basicCaseId } from './mock';
|
||||
import { TestProviders } from '../common/mock';
|
||||
import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page';
|
||||
import { useToasts } from '../common/lib/kibana';
|
||||
import type { AppMockRenderer } from '../common/mock';
|
||||
import { createAppMockRenderer } from '../common/mock';
|
||||
|
||||
jest.mock('../common/lib/kibana');
|
||||
jest.mock('./api');
|
||||
jest.mock('../components/case_view/use_on_refresh_case_view_page');
|
||||
|
||||
const commentId = 'ab124';
|
||||
|
||||
const wrapper: React.FC<string> = ({ children }) => <TestProviders>{children}</TestProviders>;
|
||||
const successToasterTitle = 'Deleted';
|
||||
|
||||
describe('useDeleteComment', () => {
|
||||
const addSuccess = jest.fn();
|
||||
|
@ -29,13 +27,16 @@ describe('useDeleteComment', () => {
|
|||
|
||||
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
|
||||
|
||||
let appMockRender: AppMockRenderer;
|
||||
|
||||
beforeEach(() => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('init', async () => {
|
||||
const { result } = renderHook<string, UseDeleteComment>(() => useDeleteComment(), {
|
||||
wrapper,
|
||||
const { result } = renderHook(() => useDeleteComment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBeTruthy();
|
||||
|
@ -44,17 +45,15 @@ describe('useDeleteComment', () => {
|
|||
it('calls deleteComment with correct arguments - case', async () => {
|
||||
const spyOnDeleteComment = jest.spyOn(api, 'deleteComment');
|
||||
|
||||
const { waitForNextUpdate, result } = renderHook<string, UseDeleteComment>(
|
||||
() => useDeleteComment(),
|
||||
{
|
||||
wrapper,
|
||||
}
|
||||
);
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
commentId,
|
||||
successToasterTitle,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -68,16 +67,14 @@ describe('useDeleteComment', () => {
|
|||
});
|
||||
|
||||
it('refreshes the case page view after delete', async () => {
|
||||
const { waitForNextUpdate, result } = renderHook<string, UseDeleteComment>(
|
||||
() => useDeleteComment(),
|
||||
{
|
||||
wrapper,
|
||||
}
|
||||
);
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
commentId,
|
||||
successToasterTitle,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
@ -85,20 +82,39 @@ describe('useDeleteComment', () => {
|
|||
expect(useRefreshCaseViewPage()).toBeCalled();
|
||||
});
|
||||
|
||||
it('shows a success toaster correctly', async () => {
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
commentId,
|
||||
successToasterTitle,
|
||||
});
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(addSuccess).toHaveBeenCalledWith({
|
||||
title: 'Deleted',
|
||||
className: 'eui-textBreakWord',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets isError when fails to delete a case', async () => {
|
||||
const spyOnDeleteComment = jest.spyOn(api, 'deleteComment');
|
||||
spyOnDeleteComment.mockRejectedValue(new Error('Not possible :O'));
|
||||
spyOnDeleteComment.mockRejectedValue(new Error('Error'));
|
||||
|
||||
const { waitForNextUpdate, result } = renderHook<string, UseDeleteComment>(
|
||||
() => useDeleteComment(),
|
||||
{
|
||||
wrapper,
|
||||
}
|
||||
);
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
commentId,
|
||||
successToasterTitle,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
|
|
@ -16,10 +16,11 @@ import * as i18n from './translations';
|
|||
interface MutationArgs {
|
||||
caseId: string;
|
||||
commentId: string;
|
||||
successToasterTitle: string;
|
||||
}
|
||||
|
||||
export const useDeleteComment = () => {
|
||||
const { showErrorToast } = useCasesToast();
|
||||
const { showErrorToast, showSuccessToast } = useCasesToast();
|
||||
const refreshCaseViewPage = useRefreshCaseViewPage();
|
||||
|
||||
return useMutation(
|
||||
|
@ -29,7 +30,8 @@ export const useDeleteComment = () => {
|
|||
},
|
||||
{
|
||||
mutationKey: casesMutationsKeys.deleteComment,
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, { successToasterTitle }) => {
|
||||
showSuccessToast(successToasterTitle);
|
||||
refreshCaseViewPage();
|
||||
},
|
||||
onError: (error: ServerError) => {
|
||||
|
|
|
@ -42,3 +42,8 @@ export interface UpdateAlertCasesRequest {
|
|||
alerts: AlertInfo[];
|
||||
caseIds: string[];
|
||||
}
|
||||
|
||||
export interface RemoveAlertsFromCaseRequest {
|
||||
alerts: AlertInfo[];
|
||||
caseId: string;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { mockCaseComments } from '../../mocks';
|
||||
import { createCasesClientMockArgs } from '../mocks';
|
||||
import { deleteComment } from './delete';
|
||||
|
||||
describe('deleteComment', () => {
|
||||
const clientArgs = createCasesClientMockArgs();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Alerts', () => {
|
||||
const commentSO = mockCaseComments[0];
|
||||
const alertsSO = mockCaseComments[3];
|
||||
clientArgs.services.attachmentService.getter.get.mockResolvedValue(alertsSO);
|
||||
|
||||
it('delete alerts correctly', async () => {
|
||||
await deleteComment({ caseID: 'mock-id-4', attachmentID: 'mock-comment-4' }, clientArgs);
|
||||
|
||||
expect(clientArgs.services.alertsService.ensureAlertsAuthorized).toHaveBeenCalledWith({
|
||||
alerts: [{ id: 'test-id', index: 'test-index' }],
|
||||
});
|
||||
|
||||
expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).toHaveBeenCalledWith({
|
||||
alerts: [{ id: 'test-id', index: 'test-index' }],
|
||||
caseId: 'mock-id-4',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call the alert service when the attachment is not an alert', async () => {
|
||||
clientArgs.services.attachmentService.getter.get.mockResolvedValue(commentSO);
|
||||
await deleteComment({ caseID: 'mock-id-1', attachmentID: 'mock-comment-1' }, clientArgs);
|
||||
|
||||
expect(clientArgs.services.alertsService.ensureAlertsAuthorized).not.toHaveBeenCalledWith();
|
||||
|
||||
expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).not.toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -11,6 +11,7 @@ import pMap from 'p-map';
|
|||
import type { SavedObject } from '@kbn/core/server';
|
||||
import type { CommentAttributes } from '../../../common/api';
|
||||
import { Actions, ActionTypes } from '../../../common/api';
|
||||
import { getAlertInfoFromComments, isCommentRequestTypeAlert } from '../../common/utils';
|
||||
import { CASE_SAVED_OBJECT, MAX_CONCURRENT_SEARCHES } from '../../../common/constants';
|
||||
import type { CasesClientArgs } from '../types';
|
||||
import { createCaseError } from '../../common/error';
|
||||
|
@ -90,29 +91,29 @@ export async function deleteComment(
|
|||
) {
|
||||
const {
|
||||
user,
|
||||
services: { attachmentService, userActionService },
|
||||
services: { attachmentService, userActionService, alertsService },
|
||||
logger,
|
||||
authorization,
|
||||
} = clientArgs;
|
||||
|
||||
try {
|
||||
const myComment = await attachmentService.getter.get({
|
||||
const attachment = await attachmentService.getter.get({
|
||||
attachmentId: attachmentID,
|
||||
});
|
||||
|
||||
if (myComment == null) {
|
||||
if (attachment == null) {
|
||||
throw Boom.notFound(`This comment ${attachmentID} does not exist anymore.`);
|
||||
}
|
||||
|
||||
await authorization.ensureAuthorized({
|
||||
entities: [{ owner: myComment.attributes.owner, id: myComment.id }],
|
||||
entities: [{ owner: attachment.attributes.owner, id: attachment.id }],
|
||||
operation: Operations.deleteComment,
|
||||
});
|
||||
|
||||
const type = CASE_SAVED_OBJECT;
|
||||
const id = caseID;
|
||||
|
||||
const caseRef = myComment.references.find((c) => c.type === type);
|
||||
const caseRef = attachment.references.find((c) => c.type === type);
|
||||
if (caseRef == null || (caseRef != null && caseRef.id !== id)) {
|
||||
throw Boom.notFound(`This comment ${attachmentID} does not exist in ${id}.`);
|
||||
}
|
||||
|
@ -127,10 +128,12 @@ export async function deleteComment(
|
|||
action: Actions.delete,
|
||||
caseId: id,
|
||||
attachmentId: attachmentID,
|
||||
payload: { attachment: { ...myComment.attributes } },
|
||||
payload: { attachment: { ...attachment.attributes } },
|
||||
user,
|
||||
owner: myComment.attributes.owner,
|
||||
owner: attachment.attributes.owner,
|
||||
});
|
||||
|
||||
await handleAlerts({ alertsService, attachment: attachment.attributes, caseId: id });
|
||||
} catch (error) {
|
||||
throw createCaseError({
|
||||
message: `Failed to delete comment: ${caseID} comment id: ${attachmentID}: ${error}`,
|
||||
|
@ -139,3 +142,19 @@ export async function deleteComment(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface HandleAlertsArgs {
|
||||
alertsService: CasesClientArgs['services']['alertsService'];
|
||||
attachment: CommentAttributes;
|
||||
caseId: string;
|
||||
}
|
||||
|
||||
const handleAlerts = async ({ alertsService, attachment, caseId }: HandleAlertsArgs) => {
|
||||
if (!isCommentRequestTypeAlert(attachment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alerts = getAlertInfoFromComments([attachment]);
|
||||
await alertsService.ensureAlertsAuthorized({ alerts });
|
||||
await alertsService.removeCaseIdFromAlerts({ alerts, caseId });
|
||||
};
|
||||
|
|
|
@ -381,4 +381,47 @@ describe('updateAlertsStatus', () => {
|
|||
expect(alertsClient.bulkUpdateCases).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCaseIdFromAlerts', () => {
|
||||
const alerts = [
|
||||
{
|
||||
id: 'c3869d546717e8c581add9cbf7d24578f34cd3e72cbc8d8b8e9a9330a899f70f',
|
||||
index: '.internal.alerts-security.alerts-default-000001',
|
||||
},
|
||||
];
|
||||
const caseId = 'test-case';
|
||||
|
||||
it('update case info', async () => {
|
||||
await alertService.removeCaseIdFromAlerts({ alerts, caseId });
|
||||
|
||||
expect(alertsClient.removeCaseIdFromAlerts).toBeCalledWith({ alerts, caseId });
|
||||
});
|
||||
|
||||
it('filters out alerts with empty id', async () => {
|
||||
await alertService.removeCaseIdFromAlerts({
|
||||
alerts: [{ id: '', index: 'test-index' }, ...alerts],
|
||||
caseId,
|
||||
});
|
||||
|
||||
expect(alertsClient.removeCaseIdFromAlerts).toBeCalledWith({ alerts, caseId });
|
||||
});
|
||||
|
||||
it('filters out alerts with empty index', async () => {
|
||||
await alertService.removeCaseIdFromAlerts({
|
||||
alerts: [{ id: 'test-id', index: '' }, ...alerts],
|
||||
caseId,
|
||||
});
|
||||
|
||||
expect(alertsClient.removeCaseIdFromAlerts).toBeCalledWith({ alerts, caseId });
|
||||
});
|
||||
|
||||
it('does not call the alerts client with no alerts', async () => {
|
||||
await alertService.removeCaseIdFromAlerts({
|
||||
alerts: [{ id: '', index: 'test-index' }],
|
||||
caseId,
|
||||
});
|
||||
|
||||
expect(alertsClient.removeCaseIdFromAlerts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,7 +18,11 @@ import { CaseStatuses } from '../../../common/api';
|
|||
import { MAX_ALERTS_PER_CASE, MAX_CONCURRENT_SEARCHES } from '../../../common/constants';
|
||||
import { createCaseError } from '../../common/error';
|
||||
import type { AlertInfo } from '../../common/types';
|
||||
import type { UpdateAlertCasesRequest, UpdateAlertStatusRequest } from '../../client/alerts/types';
|
||||
import type {
|
||||
RemoveAlertsFromCaseRequest,
|
||||
UpdateAlertCasesRequest,
|
||||
UpdateAlertStatusRequest,
|
||||
} from '../../client/alerts/types';
|
||||
import type { AggregationBuilder, AggregationResponse } from '../../client/metrics/types';
|
||||
|
||||
export class AlertService {
|
||||
|
@ -230,6 +234,30 @@ export class AlertService {
|
|||
}
|
||||
}
|
||||
|
||||
public async removeCaseIdFromAlerts({
|
||||
alerts,
|
||||
caseId,
|
||||
}: RemoveAlertsFromCaseRequest): Promise<void> {
|
||||
try {
|
||||
const nonEmptyAlerts = this.getNonEmptyAlerts(alerts);
|
||||
|
||||
if (nonEmptyAlerts.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.alertsClient.removeCaseIdFromAlerts({
|
||||
alerts: nonEmptyAlerts,
|
||||
caseId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw createCaseError({
|
||||
message: `Failed to remove case ${caseId} from alerts: ${error}`,
|
||||
error,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureAlertsAuthorized({ alerts }: { alerts: AlertInfo[] }): Promise<void> {
|
||||
try {
|
||||
const nonEmptyAlerts = this.getNonEmptyAlerts(alerts);
|
||||
|
|
|
@ -139,6 +139,7 @@ export const createAlertServiceMock = (): AlertServiceMock => {
|
|||
executeAggregations: jest.fn(),
|
||||
bulkUpdateCases: jest.fn(),
|
||||
ensureAlertsAuthorized: jest.fn(),
|
||||
removeCaseIdFromAlerts: jest.fn(),
|
||||
};
|
||||
|
||||
// the cast here is required because jest.Mocked tries to include private members and would throw an error
|
||||
|
|
|
@ -23,6 +23,7 @@ const createAlertsClientMock = () => {
|
|||
getBrowserFields: jest.fn(),
|
||||
getAlertSummary: jest.fn(),
|
||||
ensureAllAlertsAuthorizedRead: jest.fn(),
|
||||
removeCaseIdFromAlerts: jest.fn(),
|
||||
};
|
||||
return mocked;
|
||||
};
|
||||
|
|
|
@ -105,6 +105,11 @@ export interface BulkUpdateCasesOptions {
|
|||
caseIds: string[];
|
||||
}
|
||||
|
||||
export interface RemoveAlertsFromCaseOptions {
|
||||
alerts: MgetAndAuditAlert[];
|
||||
caseId: string;
|
||||
}
|
||||
|
||||
interface GetAlertParams {
|
||||
id: string;
|
||||
index?: string;
|
||||
|
@ -846,6 +851,51 @@ export class AlertsClient {
|
|||
});
|
||||
}
|
||||
|
||||
public async removeCaseIdFromAlerts({ caseId, alerts }: RemoveAlertsFromCaseOptions) {
|
||||
try {
|
||||
if (alerts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mgetRes = await this.ensureAllAlertsAuthorized({
|
||||
alerts,
|
||||
operation: ReadOperations.Get,
|
||||
});
|
||||
|
||||
const painlessScript = `if (ctx._source['${ALERT_CASE_IDS}'] != null) {
|
||||
for (int i=0; i < ctx._source['${ALERT_CASE_IDS}'].length; i++) {
|
||||
if (ctx._source['${ALERT_CASE_IDS}'][i].equals('${caseId}')) {
|
||||
ctx._source['${ALERT_CASE_IDS}'].remove(i);
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const bulkUpdateRequest = [];
|
||||
|
||||
for (const doc of mgetRes.docs) {
|
||||
bulkUpdateRequest.push(
|
||||
{
|
||||
update: {
|
||||
_index: doc._index,
|
||||
_id: doc._id,
|
||||
},
|
||||
},
|
||||
{
|
||||
script: { source: painlessScript, lang: 'painless' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await this.esClient.bulk({
|
||||
refresh: 'wait_for',
|
||||
body: bulkUpdateRequest,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Error to remove case ${caseId} from alerts: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async find<Params extends RuleTypeParams = never>({
|
||||
aggs,
|
||||
featureIds,
|
||||
|
|
|
@ -0,0 +1,304 @@
|
|||
/*
|
||||
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import {
|
||||
ALERT_CASE_IDS,
|
||||
ALERT_RULE_CONSUMER,
|
||||
ALERT_RULE_TYPE_ID,
|
||||
MAX_CASES_PER_ALERT,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { AlertsClient, ConstructorOptions } from '../alerts_client';
|
||||
import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock';
|
||||
|
||||
describe('bulkUpdateCases', () => {
|
||||
const alertingAuthMock = alertingAuthorizationMock.create();
|
||||
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
|
||||
const auditLogger = auditLoggerMock.create();
|
||||
const caseIds = ['test-case'];
|
||||
const alerts = [
|
||||
{
|
||||
id: 'alert-id',
|
||||
index: 'alert-index',
|
||||
},
|
||||
];
|
||||
|
||||
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
|
||||
logger: loggingSystemMock.create().get(),
|
||||
authorization: alertingAuthMock,
|
||||
esClient: esClientMock,
|
||||
auditLogger,
|
||||
ruleDataService: ruleDataServiceMock.create(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
esClientMock.mget.mockResponse({
|
||||
docs: [
|
||||
{
|
||||
found: true,
|
||||
_id: 'alert-id',
|
||||
_index: 'alert-index',
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_CASE_IDS]: caseIds,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('bulks update the alerts with case info correctly', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
await alertsClient.bulkUpdateCases({ caseIds, alerts });
|
||||
|
||||
expect(esClientMock.mget).toHaveBeenCalledWith({
|
||||
docs: [{ _id: 'alert-id', _index: 'alert-index' }],
|
||||
});
|
||||
|
||||
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Array [
|
||||
Object {
|
||||
"update": Object {
|
||||
"_id": "alert-id",
|
||||
"_index": "alert-index",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"doc": Object {
|
||||
"kibana.alert.case_ids": Array [
|
||||
"test-case",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"refresh": "wait_for",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('bulks update correctly with multiple cases and alerts', async () => {
|
||||
const multipleAlerts = [
|
||||
...alerts,
|
||||
{
|
||||
id: 'alert-id-2',
|
||||
index: 'alert-index-2',
|
||||
},
|
||||
];
|
||||
|
||||
const multipleCases = [...caseIds, 'another-case'];
|
||||
|
||||
esClientMock.mget.mockResponse({
|
||||
docs: multipleAlerts.map((alert) => ({
|
||||
found: true,
|
||||
_id: alert.id,
|
||||
_index: alert.index,
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_CASE_IDS]: multipleCases,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
|
||||
await alertsClient.bulkUpdateCases({
|
||||
caseIds: multipleCases,
|
||||
alerts: multipleAlerts,
|
||||
});
|
||||
|
||||
expect(esClientMock.mget).toHaveBeenCalledWith({
|
||||
docs: [
|
||||
{ _id: 'alert-id', _index: 'alert-index' },
|
||||
{ _id: 'alert-id-2', _index: 'alert-index-2' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Array [
|
||||
Object {
|
||||
"update": Object {
|
||||
"_id": "alert-id",
|
||||
"_index": "alert-index",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"doc": Object {
|
||||
"kibana.alert.case_ids": Array [
|
||||
"test-case",
|
||||
"another-case",
|
||||
],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"update": Object {
|
||||
"_id": "alert-id-2",
|
||||
"_index": "alert-index-2",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"doc": Object {
|
||||
"kibana.alert.case_ids": Array [
|
||||
"test-case",
|
||||
"another-case",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"refresh": "wait_for",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('removes duplicated cases', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
|
||||
await alertsClient.bulkUpdateCases({
|
||||
caseIds: [...caseIds, ...caseIds],
|
||||
alerts,
|
||||
});
|
||||
|
||||
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Array [
|
||||
Object {
|
||||
"update": Object {
|
||||
"_id": "alert-id",
|
||||
"_index": "alert-index",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"doc": Object {
|
||||
"kibana.alert.case_ids": Array [
|
||||
"test-case",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"refresh": "wait_for",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('calls ensureAllAlertsAuthorized correctly', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
await alertsClient.bulkUpdateCases({ caseIds, alerts });
|
||||
|
||||
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
|
||||
consumer: 'apm',
|
||||
entity: 'alert',
|
||||
operation: 'get',
|
||||
ruleTypeId: 'apm.error_rate',
|
||||
});
|
||||
});
|
||||
|
||||
it(`throws an error when adding a case to an alert with ${MAX_CASES_PER_ALERT} cases`, async () => {
|
||||
esClientMock.mget.mockResponse({
|
||||
docs: [
|
||||
{
|
||||
found: true,
|
||||
_id: 'alert-id',
|
||||
_index: 'alert-index',
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_CASE_IDS]: Array.from(Array(10).keys()),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
|
||||
await expect(
|
||||
alertsClient.bulkUpdateCases({ caseIds, alerts })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"You cannot attach more than 10 cases to an alert"`
|
||||
);
|
||||
});
|
||||
|
||||
it(`throws an error when adding more than ${MAX_CASES_PER_ALERT} cases to an alert`, async () => {
|
||||
esClientMock.mget.mockResponse({
|
||||
docs: [
|
||||
{
|
||||
found: true,
|
||||
_id: 'alert-id',
|
||||
_index: 'alert-index',
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_CASE_IDS]: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
const multipleCases = Array.from(Array(11).values());
|
||||
|
||||
await expect(
|
||||
alertsClient.bulkUpdateCases({ caseIds: multipleCases, alerts })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"You cannot attach more than 10 cases to an alert"`
|
||||
);
|
||||
});
|
||||
|
||||
it(`throws an error when the sum of cases extends the limit of ${MAX_CASES_PER_ALERT} cases per alert`, async () => {
|
||||
esClientMock.mget.mockResponse({
|
||||
docs: [
|
||||
{
|
||||
found: true,
|
||||
_id: 'alert-id',
|
||||
_index: 'alert-index',
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_CASE_IDS]: Array.from(Array(5).keys()),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
const multipleCases = Array.from(Array(6).values());
|
||||
|
||||
await expect(
|
||||
alertsClient.bulkUpdateCases({ caseIds: multipleCases, alerts })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"You cannot attach more than 10 cases to an alert"`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when no alerts are provided', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
|
||||
await expect(
|
||||
alertsClient.bulkUpdateCases({ caseIds, alerts: [] })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"You need to define at least one alert to update case ids"`
|
||||
);
|
||||
});
|
||||
|
||||
it(`throws an error when the provided case ids are more than ${MAX_CASES_PER_ALERT}`, async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
const multipleCases = Array.from(Array(11).values());
|
||||
|
||||
await expect(
|
||||
alertsClient.bulkUpdateCases({ caseIds: multipleCases, alerts })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"You cannot attach more than 10 cases to an alert"`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import { ALERT_CASE_IDS, ALERT_RULE_CONSUMER, ALERT_RULE_TYPE_ID } from '@kbn/rule-data-utils';
|
||||
import { AlertsClient, ConstructorOptions } from '../alerts_client';
|
||||
import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock';
|
||||
|
||||
describe('removeCaseIdFromAlerts', () => {
|
||||
const alertingAuthMock = alertingAuthorizationMock.create();
|
||||
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
|
||||
const auditLogger = auditLoggerMock.create();
|
||||
const caseId = 'test-case';
|
||||
const alerts = [
|
||||
{
|
||||
id: 'alert-id',
|
||||
index: 'alert-index',
|
||||
},
|
||||
];
|
||||
|
||||
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
|
||||
logger: loggingSystemMock.create().get(),
|
||||
authorization: alertingAuthMock,
|
||||
esClient: esClientMock,
|
||||
auditLogger,
|
||||
ruleDataService: ruleDataServiceMock.create(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
esClientMock.mget.mockResponseOnce({
|
||||
docs: [
|
||||
{
|
||||
found: true,
|
||||
_id: 'alert-id',
|
||||
_index: 'alert-index',
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_CASE_IDS]: [caseId],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('removes alerts from a case', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts });
|
||||
|
||||
expect(esClientMock.mget).toHaveBeenCalledWith({
|
||||
docs: [{ _id: 'alert-id', _index: 'alert-index' }],
|
||||
});
|
||||
|
||||
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Array [
|
||||
Object {
|
||||
"update": Object {
|
||||
"_id": "alert-id",
|
||||
"_index": "alert-index",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"script": Object {
|
||||
"lang": "painless",
|
||||
"source": "if (ctx._source['kibana.alert.case_ids'] != null) {
|
||||
for (int i=0; i < ctx._source['kibana.alert.case_ids'].length; i++) {
|
||||
if (ctx._source['kibana.alert.case_ids'][i].equals('test-case')) {
|
||||
ctx._source['kibana.alert.case_ids'].remove(i);
|
||||
}
|
||||
}
|
||||
}",
|
||||
},
|
||||
},
|
||||
],
|
||||
"refresh": "wait_for",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('calls ensureAllAlertsAuthorized correctly', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts });
|
||||
|
||||
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
|
||||
consumer: 'apm',
|
||||
entity: 'alert',
|
||||
operation: 'get',
|
||||
ruleTypeId: 'apm.error_rate',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not do any calls if there are no alerts', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts: [] });
|
||||
|
||||
expect(alertingAuthMock.ensureAuthorized).not.toHaveBeenCalled();
|
||||
expect(esClientMock.bulk).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -6,7 +6,20 @@
|
|||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { CommentType } from '@kbn/cases-plugin/common';
|
||||
import { ALERT_CASE_IDS } from '@kbn/rule-data-utils';
|
||||
import {
|
||||
createSecuritySolutionAlerts,
|
||||
getAlertById,
|
||||
getSecuritySolutionAlerts,
|
||||
} from '../../../../common/lib/alerts';
|
||||
import {
|
||||
createSignalsIndex,
|
||||
deleteSignalsIndex,
|
||||
deleteAllRules,
|
||||
} from '../../../../../detection_engine_api_integration/utils';
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
import { User } from '../../../../common/lib/authentication/types';
|
||||
|
||||
import { getPostCaseRequest, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock';
|
||||
import {
|
||||
|
@ -25,9 +38,13 @@ import {
|
|||
noKibanaPrivileges,
|
||||
obsOnly,
|
||||
obsOnlyRead,
|
||||
obsOnlyReadAlerts,
|
||||
obsSec,
|
||||
obsSecRead,
|
||||
secOnly,
|
||||
secOnlyRead,
|
||||
secOnlyReadAlerts,
|
||||
secSolutionOnlyReadNoIndexAlerts,
|
||||
superUser,
|
||||
} from '../../../../common/lib/authentication/users';
|
||||
|
||||
|
@ -35,6 +52,9 @@ import {
|
|||
export default ({ getService }: FtrProviderContext): void => {
|
||||
const supertest = getService('supertest');
|
||||
const es = getService('es');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const log = getService('log');
|
||||
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||
|
||||
describe('delete_comment', () => {
|
||||
afterEach(async () => {
|
||||
|
@ -91,9 +111,255 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('rbac', () => {
|
||||
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||
describe('alerts', () => {
|
||||
type Alerts = Array<{ _id: string; _index: string }>;
|
||||
|
||||
const createCaseAttachAlertAndDeleteAlert = async ({
|
||||
totalCases,
|
||||
indexOfCaseToDelete,
|
||||
owner,
|
||||
expectedHttpCode = 204,
|
||||
deleteCommentAuth = { user: superUser, space: 'space1' },
|
||||
alerts,
|
||||
getAlerts,
|
||||
}: {
|
||||
totalCases: number;
|
||||
indexOfCaseToDelete: number;
|
||||
owner: string;
|
||||
expectedHttpCode?: number;
|
||||
deleteCommentAuth?: { user: User; space: string | null };
|
||||
alerts: Alerts;
|
||||
getAlerts: (alerts: Alerts) => Promise<Array<Record<string, unknown>>>;
|
||||
}) => {
|
||||
const cases = await Promise.all(
|
||||
[...Array(totalCases).keys()].map((index) =>
|
||||
createCase(
|
||||
supertestWithoutAuth,
|
||||
{
|
||||
...postCaseReq,
|
||||
owner,
|
||||
settings: { syncAlerts: false },
|
||||
},
|
||||
200,
|
||||
{ user: superUser, space: 'space1' }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const updatedCases = [];
|
||||
|
||||
for (const theCase of cases) {
|
||||
const updatedCase = await createComment({
|
||||
supertest: supertestWithoutAuth,
|
||||
caseId: theCase.id,
|
||||
params: {
|
||||
alertId: alerts.map((alert) => alert._id),
|
||||
index: alerts.map((alert) => alert._index),
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
owner,
|
||||
type: CommentType.alert,
|
||||
},
|
||||
auth: { user: superUser, space: 'space1' },
|
||||
});
|
||||
|
||||
updatedCases.push(updatedCase);
|
||||
}
|
||||
|
||||
const caseIds = updatedCases.map((theCase) => theCase.id);
|
||||
|
||||
const updatedAlerts = await getAlerts(alerts);
|
||||
|
||||
for (const alert of updatedAlerts) {
|
||||
expect(alert[ALERT_CASE_IDS]).eql(caseIds);
|
||||
}
|
||||
|
||||
const caseToDelete = updatedCases[indexOfCaseToDelete];
|
||||
const commentId = caseToDelete.comments![0].id;
|
||||
|
||||
await deleteComment({
|
||||
supertest: supertestWithoutAuth,
|
||||
caseId: caseToDelete.id,
|
||||
commentId,
|
||||
expectedHttpCode,
|
||||
auth: deleteCommentAuth,
|
||||
});
|
||||
|
||||
const alertAfterDeletion = await getAlerts(alerts);
|
||||
|
||||
const caseIdsWithoutRemovedCase =
|
||||
expectedHttpCode === 204
|
||||
? updatedCases
|
||||
.filter((theCase) => theCase.id !== caseToDelete.id)
|
||||
.map((theCase) => theCase.id)
|
||||
: updatedCases.map((theCase) => theCase.id);
|
||||
|
||||
for (const alert of alertAfterDeletion) {
|
||||
expect(alert[ALERT_CASE_IDS]).eql(caseIdsWithoutRemovedCase);
|
||||
}
|
||||
};
|
||||
|
||||
describe('security_solution', () => {
|
||||
let alerts: Alerts = [];
|
||||
|
||||
const getAlerts = async (_alerts: Alerts) => {
|
||||
await es.indices.refresh({ index: _alerts.map((alert) => alert._index) });
|
||||
const updatedAlerts = await getSecuritySolutionAlerts(
|
||||
supertest,
|
||||
alerts.map((alert) => alert._id)
|
||||
);
|
||||
|
||||
return updatedAlerts.hits.hits.map((alert) => ({ ...alert._source }));
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
|
||||
await createSignalsIndex(supertest, log);
|
||||
const signals = await createSecuritySolutionAlerts(supertest, log);
|
||||
alerts = [signals.hits.hits[0], signals.hits.hits[1]];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteSignalsIndex(supertest, log);
|
||||
await deleteAllRules(supertest, log);
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
|
||||
});
|
||||
|
||||
it('removes a case from the alert schema when deleting an alert attachment', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 1,
|
||||
indexOfCaseToDelete: 0,
|
||||
owner: 'securitySolutionFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove only one case', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 3,
|
||||
indexOfCaseToDelete: 1,
|
||||
owner: 'securitySolutionFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete case ID from the alert schema when the user has write access to the indices and only read access to the siem solution', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 1,
|
||||
indexOfCaseToDelete: 0,
|
||||
owner: 'securitySolutionFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
expectedHttpCode: 204,
|
||||
deleteCommentAuth: { user: secOnlyReadAlerts, space: 'space1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 1,
|
||||
indexOfCaseToDelete: 0,
|
||||
owner: 'securitySolutionFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
expectedHttpCode: 403,
|
||||
deleteCommentAuth: { user: obsSec, space: 'space1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete the case ID from the alert schema when the user has read access to the kibana feature but no read access to the ES index', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 1,
|
||||
indexOfCaseToDelete: 0,
|
||||
owner: 'securitySolutionFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
expectedHttpCode: 204,
|
||||
deleteCommentAuth: { user: secSolutionOnlyReadNoIndexAlerts, space: 'space1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('observability', () => {
|
||||
const alerts = [
|
||||
{ _id: 'NoxgpHkBqbdrfX07MqXV', _index: '.alerts-observability.apm.alerts' },
|
||||
{ _id: 'space1alert', _index: '.alerts-observability.apm.alerts' },
|
||||
];
|
||||
|
||||
const getAlerts = async (_alerts: Alerts) => {
|
||||
const updatedAlerts = await Promise.all(
|
||||
_alerts.map((alert) =>
|
||||
getAlertById({
|
||||
supertest: supertestWithoutAuth,
|
||||
id: alert._id,
|
||||
index: alert._index,
|
||||
auth: { user: superUser, space: 'space1' },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return updatedAlerts as Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts');
|
||||
});
|
||||
|
||||
it('removes a case from the alert schema when deleting an alert attachment', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 1,
|
||||
indexOfCaseToDelete: 0,
|
||||
owner: 'observabilityFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove only one case', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 3,
|
||||
indexOfCaseToDelete: 1,
|
||||
owner: 'observabilityFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete case ID from the alert schema when the user has read access only', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 1,
|
||||
indexOfCaseToDelete: 0,
|
||||
expectedHttpCode: 204,
|
||||
owner: 'observabilityFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
deleteCommentAuth: { user: obsOnlyReadAlerts, space: 'space1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
|
||||
await createCaseAttachAlertAndDeleteAlert({
|
||||
totalCases: 1,
|
||||
indexOfCaseToDelete: 0,
|
||||
expectedHttpCode: 403,
|
||||
owner: 'observabilityFixture',
|
||||
alerts,
|
||||
getAlerts,
|
||||
deleteCommentAuth: { user: obsSec, space: 'space1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rbac', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllCaseItems(es);
|
||||
});
|
||||
|
|
|
@ -812,7 +812,7 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
|
||||
const caseIds = cases.map((theCase) => theCase.id);
|
||||
|
||||
expect(alert['kibana.alert.case_ids']).eql(caseIds);
|
||||
expect(alert[ALERT_CASE_IDS]).eql(caseIds);
|
||||
|
||||
return { alert, cases };
|
||||
};
|
||||
|
@ -851,7 +851,7 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
auth: { user: superUser, space: 'space1' },
|
||||
});
|
||||
|
||||
expect(alert['kibana.alert.case_ids']).eql([postedCase.id]);
|
||||
expect(alert[ALERT_CASE_IDS]).eql([postedCase.id]);
|
||||
});
|
||||
|
||||
it('should not add more than 10 cases to an alert', async () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue