From c2877a6d96791a9dd5498de80a75945b9e1c70fc Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 26 Feb 2021 15:35:43 +0200 Subject: [PATCH 1/8] [Security Solution][Case] Fix subcases bugs on detections and case view (#91836) Co-authored-by: Jonathan Buttner --- .../case/common/api/cases/commentable_case.ts | 35 -------- x-pack/plugins/case/common/api/cases/index.ts | 1 - x-pack/plugins/case/common/api/helpers.ts | 8 +- .../plugins/case/common/api/runtime_types.ts | 27 +++++- .../plugins/case/server/client/cases/types.ts | 2 +- .../case/server/client/cases/utils.test.ts | 2 + .../plugins/case/server/client/cases/utils.ts | 1 + .../case/server/client/comments/add.ts | 6 +- x-pack/plugins/case/server/client/types.ts | 3 +- .../server/common/models/commentable_case.ts | 63 +++++++++----- .../case/server/connectors/case/index.test.ts | 4 +- .../case/server/connectors/case/types.ts | 4 +- .../api/cases/comments/delete_all_comments.ts | 8 +- .../api/cases/comments/delete_comment.ts | 8 +- .../api/cases/comments/find_comments.ts | 6 +- .../api/cases/comments/get_all_comment.ts | 6 +- .../api/cases/comments/patch_comment.ts | 16 ++-- .../routes/api/cases/comments/post_comment.ts | 4 +- .../api/cases/sub_case/patch_sub_cases.ts | 4 +- .../case/server/scripts/sub_cases/index.ts | 11 +-- .../components/all_cases/expanded_row.tsx | 14 ++- .../cases/components/all_cases/index.tsx | 49 ++++++++--- .../all_cases/status_filter.test.tsx | 20 +++++ .../components/all_cases/status_filter.tsx | 9 +- .../components/all_cases/table_filters.tsx | 3 + .../components/case_action_bar/index.tsx | 24 +++--- .../cases/components/case_view/index.test.tsx | 80 ++++++++++++++++- .../cases/components/case_view/index.tsx | 69 ++++++++------- .../connectors/case/alert_fields.tsx | 4 +- .../connectors/case/existing_case.tsx | 8 +- .../cases/components/create/flyout.test.tsx | 14 +-- .../public/cases/components/create/flyout.tsx | 8 +- .../components/create/form_context.test.tsx | 86 +++++++++++++++++++ .../cases/components/create/form_context.tsx | 12 ++- .../public/cases/components/create/index.tsx | 2 +- .../cases/components/status/button.test.tsx | 2 +- .../public/cases/components/status/config.ts | 2 +- .../add_to_case_action.test.tsx | 69 ++++++++++++--- .../timeline_actions/add_to_case_action.tsx | 34 +++++--- .../use_all_cases_modal/all_cases_modal.tsx | 26 ++++-- .../use_all_cases_modal/index.test.tsx | 2 +- .../components/use_all_cases_modal/index.tsx | 12 ++- .../create_case_modal.test.tsx | 6 +- .../create_case_modal.tsx | 2 +- .../use_create_case_modal/index.test.tsx | 6 +- .../use_create_case_modal/index.tsx | 2 +- .../user_action_tree/index.test.tsx | 6 +- .../components/user_action_tree/index.tsx | 15 ++-- .../cases/containers/use_get_case.test.tsx | 8 +- .../public/cases/containers/use_get_case.tsx | 34 +------- .../cases/containers/use_post_comment.tsx | 2 +- .../public/cases/translations.ts | 7 ++ .../alerts/use_query.test.tsx | 2 +- .../detection_engine/alerts/use_query.tsx | 2 +- .../flyout/add_to_case_button/index.tsx | 4 +- .../tests/cases/comments/delete_comment.ts | 14 +-- .../tests/cases/comments/find_comments.ts | 4 +- .../tests/cases/comments/get_all_comments.ts | 4 +- .../basic/tests/cases/comments/get_comment.ts | 2 +- .../tests/cases/comments/patch_comment.ts | 39 ++++----- .../tests/cases/comments/post_comment.ts | 4 +- .../basic/tests/cases/delete_cases.ts | 16 ++-- .../basic/tests/cases/find_cases.ts | 6 +- .../tests/cases/sub_cases/delete_sub_cases.ts | 20 ++--- .../tests/cases/sub_cases/find_sub_cases.ts | 12 +-- .../tests/cases/sub_cases/get_sub_case.ts | 16 ++-- .../tests/cases/sub_cases/patch_sub_cases.ts | 26 +++--- .../case_api_integration/common/lib/mock.ts | 18 ++-- .../case_api_integration/common/lib/utils.ts | 4 +- 69 files changed, 683 insertions(+), 366 deletions(-) delete mode 100644 x-pack/plugins/case/common/api/cases/commentable_case.ts diff --git a/x-pack/plugins/case/common/api/cases/commentable_case.ts b/x-pack/plugins/case/common/api/cases/commentable_case.ts deleted file mode 100644 index 023229a90d352d..00000000000000 --- a/x-pack/plugins/case/common/api/cases/commentable_case.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 * as rt from 'io-ts'; -import { CaseAttributesRt } from './case'; -import { CommentResponseRt } from './comment'; -import { SubCaseAttributesRt, SubCaseResponseRt } from './sub_case'; - -export const CollectionSubCaseAttributesRt = rt.intersection([ - rt.partial({ subCase: SubCaseAttributesRt }), - rt.type({ - case: CaseAttributesRt, - }), -]); - -export const CollectWithSubCaseResponseRt = rt.intersection([ - CaseAttributesRt, - rt.type({ - id: rt.string, - totalComment: rt.number, - version: rt.string, - }), - rt.partial({ - subCase: SubCaseResponseRt, - totalAlerts: rt.number, - comments: rt.array(CommentResponseRt), - }), -]); - -export type CollectionWithSubCaseResponse = rt.TypeOf; -export type CollectionWithSubCaseAttributes = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index 4d1fc68109ddb7..6e7fb818cb2b58 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -11,4 +11,3 @@ export * from './comment'; export * from './status'; export * from './user_actions'; export * from './sub_case'; -export * from './commentable_case'; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 00c8ff402c802e..43e292b91db4b7 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -24,8 +24,8 @@ export const getSubCasesUrl = (caseID: string): string => { return SUB_CASES_URL.replace('{case_id}', caseID); }; -export const getSubCaseDetailsUrl = (caseID: string, subCaseID: string): string => { - return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +export const getSubCaseDetailsUrl = (caseID: string, subCaseId: string): string => { + return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseId); }; export const getCaseCommentsUrl = (id: string): string => { @@ -40,8 +40,8 @@ export const getCaseUserActionUrl = (id: string): string => { return CASE_USER_ACTIONS_URL.replace('{case_id}', id); }; -export const getSubCaseUserActionUrl = (caseID: string, subCaseID: string): string => { - return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +export const getSubCaseUserActionUrl = (caseID: string, subCaseId: string): string => { + return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseId); }; export const getCasePushUrl = (caseId: string, connectorId: string): string => { diff --git a/x-pack/plugins/case/common/api/runtime_types.ts b/x-pack/plugins/case/common/api/runtime_types.ts index 43e3be04d10e5e..b2ff763838287e 100644 --- a/x-pack/plugins/case/common/api/runtime_types.ts +++ b/x-pack/plugins/case/common/api/runtime_types.ts @@ -9,14 +9,37 @@ import { either, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import * as rt from 'io-ts'; -import { failure } from 'io-ts/lib/PathReporter'; +import { isObject } from 'lodash/fp'; type ErrorFactory = (message: string) => Error; +export const formatErrors = (errors: rt.Errors): string[] => { + const err = errors.map((error) => { + if (error.message != null) { + return error.message; + } else { + const keyContext = error.context + .filter( + (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' + ) + .map((entry) => entry.key) + .join(','); + + const nameContext = error.context.find((entry) => entry.type?.name?.length > 0); + const suppliedValue = + keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; + const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; + return `Invalid value "${value}" supplied to "${suppliedValue}"`; + } + }); + + return [...new Set(err)]; +}; + export const createPlainError = (message: string) => new Error(message); export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => { - throw createError(failure(errors).join('\n')); + throw createError(formatErrors(errors).join()); }; export const decodeOrThrow = ( diff --git a/x-pack/plugins/case/server/client/cases/types.ts b/x-pack/plugins/case/server/client/cases/types.ts index 2dd2caf9fe73a0..f1d56e7132bd11 100644 --- a/x-pack/plugins/case/server/client/cases/types.ts +++ b/x-pack/plugins/case/server/client/cases/types.ts @@ -72,7 +72,7 @@ export interface TransformFieldsArgs { export interface ExternalServiceComment { comment: string; - commentId?: string; + commentId: string; } export interface MapIncident { diff --git a/x-pack/plugins/case/server/client/cases/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts index 44e7a682aa7edf..859114a5e8fb07 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -540,6 +540,7 @@ describe('utils', () => { }, { comment: 'Elastic Security Alerts attached to the case: 3', + commentId: 'mock-id-1-total-alerts', }, ]); }); @@ -569,6 +570,7 @@ describe('utils', () => { }, { comment: 'Elastic Security Alerts attached to the case: 4', + commentId: 'mock-id-1-total-alerts', }, ]); }); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index a5013d9b93982f..67d5ef55f83c3d 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -185,6 +185,7 @@ export const createIncident = async ({ if (totalAlerts > 0) { comments.push({ comment: `Elastic Security Alerts attached to the case: ${totalAlerts}`, + commentId: `${theCase.id}-total-alerts`, }); } diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 0a86c1825fedce..4c1cc59a957509 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -25,7 +25,7 @@ import { CaseType, SubCaseAttributes, CommentRequest, - CollectionWithSubCaseResponse, + CaseResponse, User, CommentRequestAlertType, AlertCommentRequestRt, @@ -113,7 +113,7 @@ const addGeneratedAlerts = async ({ caseClient, caseId, comment, -}: AddCommentFromRuleArgs): Promise => { +}: AddCommentFromRuleArgs): Promise => { const query = pipe( AlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) @@ -260,7 +260,7 @@ export const addComment = async ({ caseId, comment, user, -}: AddCommentArgs): Promise => { +}: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index ba5677426c2228..d6a8f6b5d706cd 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -13,7 +13,6 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, - CollectionWithSubCaseResponse, CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, @@ -89,7 +88,7 @@ export interface ConfigureFields { * This represents the interface that other plugins can access. */ export interface CaseClient { - addComment(args: CaseClientAddComment): Promise; + addComment(args: CaseClientAddComment): Promise; create(theCase: CasePostRequest): Promise; get(args: CaseClientGet): Promise; getAlerts(args: CaseClientGetAlerts): Promise; diff --git a/x-pack/plugins/case/server/common/models/commentable_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts index 9827118ee8e298..3ae225999db4ee 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -17,8 +17,8 @@ import { CaseSettings, CaseStatuses, CaseType, - CollectionWithSubCaseResponse, - CollectWithSubCaseResponseRt, + CaseResponse, + CaseResponseRt, CommentAttributes, CommentPatchRequest, CommentRequest, @@ -254,7 +254,7 @@ export class CommentableCase { }; } - public async encode(): Promise { + public async encode(): Promise { const collectionCommentStats = await this.service.getAllCaseComments({ client: this.soClient, id: this.collection.id, @@ -265,22 +265,6 @@ export class CommentableCase { }, }); - if (this.subCase) { - const subCaseComments = await this.service.getAllSubCaseComments({ - client: this.soClient, - id: this.subCase.id, - }); - - return CollectWithSubCaseResponseRt.encode({ - subCase: flattenSubCaseSavedObject({ - savedObject: this.subCase, - comments: subCaseComments.saved_objects, - totalAlerts: countAlertsForID({ comments: subCaseComments, id: this.subCase.id }), - }), - ...this.formatCollectionForEncoding(collectionCommentStats.total), - }); - } - const collectionComments = await this.service.getAllCaseComments({ client: this.soClient, id: this.collection.id, @@ -291,10 +275,45 @@ export class CommentableCase { }, }); - return CollectWithSubCaseResponseRt.encode({ + const collectionTotalAlerts = + countAlertsForID({ comments: collectionComments, id: this.collection.id }) ?? 0; + + const caseResponse = { comments: flattenCommentSavedObjects(collectionComments.saved_objects), - totalAlerts: countAlertsForID({ comments: collectionComments, id: this.collection.id }), + totalAlerts: collectionTotalAlerts, ...this.formatCollectionForEncoding(collectionCommentStats.total), - }); + }; + + if (this.subCase) { + const subCaseComments = await this.service.getAllSubCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + const totalAlerts = countAlertsForID({ comments: subCaseComments, id: this.subCase.id }) ?? 0; + + return CaseResponseRt.encode({ + ...caseResponse, + /** + * For now we need the sub case comments and totals to be exposed on the top level of the response so that the UI + * functionality can stay the same. Ideally in the future we can refactor this so that the UI will look for the + * comments either in the top level for a case or a collection or in the subCases field if it is a sub case. + * + * If we ever need to return both the collection's comments and the sub case comments we'll need to refactor it then + * as well. + */ + comments: flattenCommentSavedObjects(subCaseComments.saved_objects), + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + subCases: [ + flattenSubCaseSavedObject({ + savedObject: this.subCase, + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + }), + ], + }); + } + + return CaseResponseRt.encode(caseResponse); } } diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 4be519858db18c..e4c29bb099f0e4 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -18,7 +18,6 @@ import { AssociationType, CaseResponse, CasesResponse, - CollectionWithSubCaseResponse, } from '../../../common/api'; import { connectorMappingsServiceMock, @@ -1018,9 +1017,10 @@ describe('case connector', () => { describe('addComment', () => { it('executes correctly', async () => { - const commentReturn: CollectionWithSubCaseResponse = { + const commentReturn: CaseResponse = { id: 'mock-it', totalComment: 0, + totalAlerts: 0, version: 'WzksMV0=', closed_at: null, diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index 50ff104d7bad02..6a7dfd9c2e6876 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -16,7 +16,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse, CollectionWithSubCaseResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -29,7 +29,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse | CollectionWithSubCaseResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse; export type CaseActionType = ActionType< CaseConfiguration, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index bcbf1828e1fde4..e0b3a4420f4b5d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -23,7 +23,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), }, @@ -35,11 +35,11 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); - const id = request.query?.subCaseID ?? request.params.case_id; + const id = request.query?.subCaseId ?? request.params.case_id; const comments = await caseService.getCommentsByAssociation({ client, id, - associationType: request.query?.subCaseID + associationType: request.query?.subCaseId ? AssociationType.subCase : AssociationType.case, }); @@ -61,7 +61,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, - subCaseId: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, commentId: comment.id, fields: ['comment'], }) diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 73307753a550de..cae0809ea5f0bf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -25,7 +25,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), }, @@ -46,8 +46,8 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); } - const type = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - const id = request.query?.subCaseID ?? request.params.case_id; + const type = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const id = request.query?.subCaseId ?? request.params.case_id; const caseRef = myComment.references.find((c) => c.type === type); if (caseRef == null || (caseRef != null && caseRef.id !== id)) { @@ -69,7 +69,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, - subCaseId: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, commentId: request.params.comment_id, fields: ['comment'], }), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index 3431c340c791e3..0ec0f1871c7adf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -27,7 +27,7 @@ import { defaultPage, defaultPerPage } from '../..'; const FindQueryParamsRt = rt.partial({ ...SavedObjectFindOptionsRt.props, - subCaseID: rt.string, + subCaseId: rt.string, }); export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { @@ -49,8 +49,8 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { fold(throwErrors(Boom.badRequest), identity) ); - const id = query.subCaseID ?? request.params.case_id; - const associationType = query.subCaseID ? AssociationType.subCase : AssociationType.case; + const id = query.subCaseId ?? request.params.case_id; + const associationType = query.subCaseId ? AssociationType.subCase : AssociationType.case; const args = query ? { caseService, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 730b1b92a8a076..8bf49ec3e27a12 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -25,7 +25,7 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { query: schema.maybe( schema.object({ includeSubCaseComments: schema.maybe(schema.boolean()), - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), }, @@ -35,10 +35,10 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { const client = context.core.savedObjects.client; let comments: SavedObjectsFindResponse; - if (request.query?.subCaseID) { + if (request.query?.subCaseId) { comments = await caseService.getAllSubCaseComments({ client, - id: request.query.subCaseID, + id: request.query.subCaseId, options: { sortField: defaultSortField, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index e8b6f7bc957eb3..01b0e174640537 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -26,11 +26,11 @@ interface CombinedCaseParams { service: CaseServiceSetup; client: SavedObjectsClientContract; caseID: string; - subCaseID?: string; + subCaseId?: string; } -async function getCommentableCase({ service, client, caseID, subCaseID }: CombinedCaseParams) { - if (subCaseID) { +async function getCommentableCase({ service, client, caseID, subCaseId }: CombinedCaseParams) { + if (subCaseId) { const [caseInfo, subCase] = await Promise.all([ service.getCase({ client, @@ -38,7 +38,7 @@ async function getCommentableCase({ service, client, caseID, subCaseID }: Combin }), service.getSubCase({ client, - id: subCaseID, + id: subCaseId, }), ]); return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); @@ -66,7 +66,7 @@ export function initPatchCommentApi({ }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), body: escapeHatch, @@ -87,7 +87,7 @@ export function initPatchCommentApi({ service: caseService, client, caseID: request.params.case_id, - subCaseID: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, }); const myComment = await caseService.getComment({ @@ -103,7 +103,7 @@ export function initPatchCommentApi({ throw Boom.badRequest(`You cannot change the type of the comment.`); } - const saveObjType = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const saveObjType = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; const caseRef = myComment.references.find((c) => c.type === saveObjType); if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { @@ -144,7 +144,7 @@ export function initPatchCommentApi({ actionAt: updatedDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, - subCaseId: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, commentId: updatedComment.id, fields: ['comment'], newValue: JSON.stringify(queryRestAttributes), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 95b611950bd411..607f3f381f0675 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -21,7 +21,7 @@ export function initPostCommentApi({ router }: RouteDeps) { }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), body: escapeHatch, @@ -33,7 +33,7 @@ export function initPostCommentApi({ router }: RouteDeps) { } const caseClient = context.case.getCaseClient(); - const caseId = request.query?.subCaseID ?? request.params.case_id; + const caseId = request.query?.subCaseId ?? request.params.case_id; const comment = request.body as CommentRequest; try { diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts index ca5cd657a39f32..4b8e4920852c27 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -153,8 +153,8 @@ async function getParentCases({ return parentCases.saved_objects.reduce((acc, so) => { const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); - subCaseIDsWithParent?.forEach((subCaseID) => { - acc.set(subCaseID, so); + subCaseIDsWithParent?.forEach((subCaseId) => { + acc.set(subCaseId, so); }); return acc; }, new Map>()); diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts index f6ec1f44076e4c..ba3bcaa65091c3 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -8,12 +8,7 @@ import yargs from 'yargs'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; -import { - CaseResponse, - CaseType, - CollectionWithSubCaseResponse, - ConnectorTypes, -} from '../../../common/api'; +import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api'; import { CommentType } from '../../../common/api/cases/comment'; import { CASES_URL } from '../../../common/constants'; import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; @@ -119,9 +114,7 @@ async function handleGenGroupAlerts(argv: any) { ), }; - const executeResp = await client.request< - ActionTypeExecutorResult - >({ + const executeResp = await client.request>({ path: `/api/actions/action/${createdAction.data.id}/_execute`, method: 'POST', body: { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx index bb4bd0f98949da..1e1e925a20adac 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui'; import styled from 'styled-components'; -import { Case } from '../../containers/types'; +import { Case, SubCase } from '../../containers/types'; import { CasesColumns } from './columns'; import { AssociationType } from '../../../../../case/common/api'; @@ -34,14 +34,25 @@ BasicTable.displayName = 'BasicTable'; export const getExpandedRowMap = ({ data, columns, + isModal, + onSubCaseClick, }: { data: Case[] | null; columns: CasesColumns[]; + isModal: boolean; + onSubCaseClick?: (theSubCase: SubCase) => void; }): ExpandedRowMap => { if (data == null) { return {}; } + const rowProps = (theSubCase: SubCase) => { + return { + ...(isModal && onSubCaseClick ? { onClick: () => onSubCaseClick(theSubCase) } : {}), + className: 'subCase', + }; + }; + return data.reduce((acc, curr) => { if (curr.subCases != null) { const subCases = curr.subCases.map((subCase, index) => ({ @@ -58,6 +69,7 @@ export const getExpandedRowMap = ({ data-test-subj={`sub-cases-table-${curr.id}`} itemId="id" items={subCases} + rowProps={rowProps} /> ), }; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index ce0fea07bf473c..56dcf3bc28757e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -19,11 +19,12 @@ import { import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; import { isEmpty, memoize } from 'lodash/fp'; import styled, { css } from 'styled-components'; -import * as i18n from './translations'; +import classnames from 'classnames'; -import { CaseStatuses } from '../../../../../case/common/api'; +import * as i18n from './translations'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import { getCasesColumns } from './columns'; -import { Case, DeleteCase, FilterOptions, SortFieldCase } from '../../containers/types'; +import { Case, DeleteCase, FilterOptions, SortFieldCase, SubCase } from '../../containers/types'; import { useGetCases, UpdateCase } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -58,6 +59,7 @@ import { getExpandedRowMap } from './expanded_row'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; `; + const FlexItemDivider = styled(EuiFlexItem)` ${({ theme }) => css` .euiFlexGroup--gutterMedium > &.euiFlexItem { @@ -75,6 +77,7 @@ const ProgressLoader = styled(EuiProgress)` z-index: ${theme.eui.euiZHeader}; `} `; + const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; @@ -86,19 +89,39 @@ const getSortField = (field: string): SortFieldCase => { const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any const BasicTable = styled(EuiBasicTable)` - .euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent { - padding: 8px 0 8px 32px; - } + ${({ theme }) => ` + .euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent { + padding: 8px 0 8px 32px; + } + + &.isModal .euiTableRow.isDisabled { + cursor: not-allowed; + background-color: ${theme.eui.euiTableHoverClickableColor}; + } + + &.isModal .euiTableRow.euiTableRow-isExpandedRow .euiTableRowCell, + &.isModal .euiTableRow.euiTableRow-isExpandedRow:hover { + background-color: transparent; + } + + &.isModal .euiTableRow.euiTableRow-isExpandedRow { + .subCase:hover { + background-color: ${theme.eui.euiTableHoverClickableColor}; + } + } + `} `; BasicTable.displayName = 'BasicTable'; interface AllCasesProps { - onRowClick?: (theCase?: Case) => void; + onRowClick?: (theCase?: Case | SubCase) => void; isModal?: boolean; userCanCrud: boolean; + disabledStatuses?: CaseStatuses[]; + disabledCases?: CaseType[]; } export const AllCases = React.memo( - ({ onRowClick, isModal = false, userCanCrud }) => { + ({ onRowClick, isModal = false, userCanCrud, disabledStatuses, disabledCases = [] }) => { const { navigateToApp } = useKibana().services.application; const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); const { actionLicense } = useGetActionLicense(); @@ -334,8 +357,10 @@ export const AllCases = React.memo( getExpandedRowMap({ columns: memoizedGetCasesColumns, data: data.cases, + isModal, + onSubCaseClick: onRowClick, }), - [data.cases, memoizedGetCasesColumns] + [data.cases, isModal, memoizedGetCasesColumns, onRowClick] ); const memoizedPagination = useMemo( @@ -356,6 +381,7 @@ export const AllCases = React.memo( () => ({ selectable: (theCase) => isEmpty(theCase.subCases), onSelectionChange: setSelectedCases, + selectableMessage: (selectable) => (!selectable ? i18n.SELECTABLE_MESSAGE_COLLECTIONS : ''), }), [setSelectedCases] ); @@ -377,7 +403,8 @@ export const AllCases = React.memo( return { 'data-test-subj': `cases-table-row-${theCase.id}`, - ...(isModal ? { onClick: onTableRowClick } : {}), + className: classnames({ isDisabled: theCase.type === CaseType.collection }), + ...(isModal && theCase.type !== CaseType.collection ? { onClick: onTableRowClick } : {}), }; }, [isModal, onRowClick] @@ -462,6 +489,7 @@ export const AllCases = React.memo( status: filterOptions.status, }} setFilterRefetch={setFilterRefetch} + disabledStatuses={disabledStatuses} /> {isCasesLoading && isDataEmpty ? (
@@ -530,6 +558,7 @@ export const AllCases = React.memo( rowProps={tableRowProps} selection={userCanCrud && !isModal ? euiBasicTableSelectionProps : undefined} sorting={sorting} + className={classnames({ isModal })} />
)} diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx index 785d4447c0acf4..11d53b6609e74c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx @@ -61,4 +61,24 @@ describe('StatusFilter', () => { expect(onStatusChanged).toBeCalledWith('closed'); }); }); + + it('should disabled selected statuses', () => { + const wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + + expect( + wrapper.find('button[data-test-subj="case-status-filter-open"]').prop('disabled') + ).toBeFalsy(); + + expect( + wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').prop('disabled') + ).toBeFalsy(); + + expect( + wrapper.find('button[data-test-subj="case-status-filter-closed"]').prop('disabled') + ).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx index 7fa0625229b480..41997d6f384214 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx @@ -14,9 +14,15 @@ interface Props { stats: Record; selectedStatus: CaseStatuses; onStatusChanged: (status: CaseStatuses) => void; + disabledStatuses?: CaseStatuses[]; } -const StatusFilterComponent: React.FC = ({ stats, selectedStatus, onStatusChanged }) => { +const StatusFilterComponent: React.FC = ({ + stats, + selectedStatus, + onStatusChanged, + disabledStatuses = [], +}) => { const caseStatuses = Object.keys(statuses) as CaseStatuses[]; const options: Array> = caseStatuses.map((status) => ({ value: status, @@ -28,6 +34,7 @@ const StatusFilterComponent: React.FC = ({ stats, selectedStatus, onStatu {` (${stats[status]})`} ), + disabled: disabledStatuses.includes(status), 'data-test-subj': `case-status-filter-${status}`, })); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx index 1f7f1d1e0d4876..61bbbac5a1e847 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx @@ -25,6 +25,7 @@ interface CasesTableFiltersProps { onFilterChanged: (filterOptions: Partial) => void; initial: FilterOptions; setFilterRefetch: (val: () => void) => void; + disabledStatuses?: CaseStatuses[]; } // Fix the width of the status dropdown to prevent hiding long text items @@ -50,6 +51,7 @@ const CasesTableFiltersComponent = ({ onFilterChanged, initial = defaultInitial, setFilterRefetch, + disabledStatuses, }: CasesTableFiltersProps) => { const [selectedReporters, setSelectedReporters] = useState( initial.reporters.map((r) => r.full_name ?? r.username ?? '') @@ -158,6 +160,7 @@ const CasesTableFiltersComponent = ({ selectedStatus={initial.status} onStatusChanged={onStatusChanged} stats={stats} + disabledStatuses={disabledStatuses} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 5e33736ce9c3ac..95c534f7c1edef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -16,7 +16,7 @@ import { EuiFlexItem, EuiIconTip, } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import * as i18n from '../case_view/translations'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { Actions } from './actions'; @@ -73,19 +73,21 @@ const CaseActionBarComponent: React.FC = ({ ); return ( - + - - {i18n.STATUS} - - - - + {caseData.type !== CaseType.collection && ( + + {i18n.STATUS} + + + + + )} {title} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 7a5f6647a8dcf6..5f9fb5b63d6ebc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -29,6 +29,7 @@ import { connectorsMock } from '../../containers/configure/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; +import { CaseType } from '../../../../../case/common/api'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -201,6 +202,10 @@ describe('CaseView ', () => { .first() .text() ).toBe(data.description); + + expect( + wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text() + ).toBe('Mark in progress'); }); }); @@ -464,7 +469,7 @@ describe('CaseView ', () => { ); await waitFor(() => { wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click'); - expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id); + expect(fetchCaseUserActions).toBeCalledWith('1234', undefined); expect(fetchCase).toBeCalled(); }); }); @@ -547,8 +552,7 @@ describe('CaseView ', () => { }); }); - // TO DO fix when the useEffects in edit_connector are cleaned up - it.skip('should update connector', async () => { + it('should update connector', async () => { const wrapper = mount( @@ -752,4 +756,74 @@ describe('CaseView ', () => { }); }); }); + + describe('Collections', () => { + it('it does not allow the user to update the status', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="case-action-bar-wrapper"]').exists()).toBe(true); + expect(wrapper.find('button[data-test-subj="case-view-status"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="user-actions"]').exists()).toBe(true); + expect( + wrapper.find('button[data-test-subj="case-view-status-action-button"]').exists() + ).toBe(false); + }); + }); + + it('it shows the push button when has data to push', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="has-data-to-push-button"]').exists()).toBe(true); + }); + }); + + it('it does not show the horizontal rule when does NOT has data to push', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: false, + })); + + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="case-view-bottom-actions-horizontal-rule"]').exists() + ).toBe(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index e42431e55ee290..d0b7c34ab84fd0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -17,7 +17,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses, CaseAttributes } from '../../../../../case/common/api'; +import { CaseStatuses, CaseAttributes, CaseType } from '../../../../../case/common/api'; import { Case, CaseConnector } from '../../containers/types'; import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; @@ -213,9 +213,9 @@ export const CaseComponent = React.memo( const handleUpdateCase = useCallback( (newCase: Case) => { updateCase(newCase); - fetchCaseUserActions(newCase.id); + fetchCaseUserActions(caseId, subCaseId); }, - [updateCase, fetchCaseUserActions] + [updateCase, fetchCaseUserActions, caseId, subCaseId] ); const { loading: isLoadingConnectors, connectors } = useConnectors(); @@ -283,9 +283,9 @@ export const CaseComponent = React.memo( ); const handleRefresh = useCallback(() => { - fetchCaseUserActions(caseData.id); + fetchCaseUserActions(caseId, subCaseId); fetchCase(); - }, [caseData.id, fetchCase, fetchCaseUserActions]); + }, [caseId, fetchCase, fetchCaseUserActions, subCaseId]); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); @@ -345,6 +345,7 @@ export const CaseComponent = React.memo( ); } }, [dispatch]); + return ( <> @@ -387,7 +388,7 @@ export const CaseComponent = React.memo( caseUserActions={caseUserActions} connectors={connectors} data={caseData} - fetchUserActions={fetchCaseUserActions.bind(null, caseData.id)} + fetchUserActions={fetchCaseUserActions.bind(null, caseId, subCaseId)} isLoadingDescription={isLoading && updateKey === 'description'} isLoadingUserActions={isLoadingUserActions} onShowAlertDetails={showAlert} @@ -395,22 +396,29 @@ export const CaseComponent = React.memo( updateCase={updateCase} userCanCrud={userCanCrud} /> - - - - + - - {hasDataToPush && ( - - {pushButton} - - )} - + {caseData.type !== CaseType.collection && ( + + + + )} + {hasDataToPush && ( + + {pushButton} + + )} + + )} )} @@ -465,6 +473,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = if (isError) { return null; } + if (isLoading) { return ( @@ -476,14 +485,16 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = } return ( - + data && ( + + ) ); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx index d5c90bd09a6db2..b7fbaff288a2a3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx @@ -47,7 +47,9 @@ const CaseParamsFields: React.FunctionComponent = ({ onCaseChanged, sel onCaseChanged(''); dispatchResetIsDeleted(); } - }, [isDeleted, dispatchResetIsDeleted, onCaseChanged]); + // onCaseChanged and/or dispatchResetIsDeleted causes re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDeleted]); useEffect(() => { if (!isLoading && !isError && data != null) { setCreatedCase(data); onCaseChanged(data.id); } - }, [data, isLoading, isError, onCaseChanged]); + // onCaseChanged causes re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, isLoading, isError]); return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx index 842fe9e00ab390..d5883b7b88cd0d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx @@ -20,14 +20,16 @@ jest.mock('../create/form_context', () => { onSuccess, }: { children: ReactNode; - onSuccess: ({ id }: { id: string }) => void; + onSuccess: ({ id }: { id: string }) => Promise; }) => { return ( <> @@ -55,10 +57,10 @@ jest.mock('../create/submit_button', () => { }); const onCloseFlyout = jest.fn(); -const onCaseCreated = jest.fn(); +const onSuccess = jest.fn(); const defaultProps = { onCloseFlyout, - onCaseCreated, + onSuccess, }; describe('CreateCaseFlyout', () => { @@ -97,7 +99,7 @@ describe('CreateCaseFlyout', () => { const props = wrapper.find('FormContext').props(); expect(props).toEqual( expect.objectContaining({ - onSuccess: onCaseCreated, + onSuccess, }) ); }); @@ -110,6 +112,6 @@ describe('CreateCaseFlyout', () => { ); wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); - expect(onCaseCreated).toHaveBeenCalledWith({ id: 'case-id' }); + expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx index cb3436f6ba3bcb..e7bb0b25f391fd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx @@ -17,7 +17,8 @@ import * as i18n from '../../translations'; export interface CreateCaseModalProps { onCloseFlyout: () => void; - onCaseCreated: (theCase: Case) => void; + onSuccess: (theCase: Case) => Promise; + afterCaseCreated?: (theCase: Case) => Promise; } const Container = styled.div` @@ -40,7 +41,8 @@ const FormWrapper = styled.div` `; const CreateCaseFlyoutComponent: React.FC = ({ - onCaseCreated, + onSuccess, + afterCaseCreated, onCloseFlyout, }) => { return ( @@ -52,7 +54,7 @@ const CreateCaseFlyoutComponent: React.FC = ({ - + diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx index 8236ab7b19d27d..1e512ef5ffabd4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx @@ -98,6 +98,7 @@ const fillForm = (wrapper: ReactWrapper) => { describe('Create case', () => { const fetchTags = jest.fn(); const onFormSubmitSuccess = jest.fn(); + const afterCaseCreated = jest.fn(); beforeEach(() => { jest.resetAllMocks(); @@ -593,4 +594,89 @@ describe('Create case', () => { }); }); }); + + it(`it should call afterCaseCreated`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(afterCaseCreated).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should call callbacks in correct order`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(postCase).toHaveBeenCalled(); + expect(afterCaseCreated).toHaveBeenCalled(); + expect(pushCaseToExternalService).toHaveBeenCalled(); + expect(onFormSubmitSuccess).toHaveBeenCalled(); + }); + + const postCaseOrder = postCase.mock.invocationCallOrder[0]; + const afterCaseOrder = afterCaseCreated.mock.invocationCallOrder[0]; + const pushCaseToExternalServiceOrder = pushCaseToExternalService.mock.invocationCallOrder[0]; + const onFormSubmitSuccessOrder = onFormSubmitSuccess.mock.invocationCallOrder[0]; + + expect( + postCaseOrder < afterCaseOrder && + postCaseOrder < pushCaseToExternalServiceOrder && + postCaseOrder < onFormSubmitSuccessOrder + ).toBe(true); + + expect( + afterCaseOrder < pushCaseToExternalServiceOrder && afterCaseOrder < onFormSubmitSuccessOrder + ).toBe(true); + + expect(pushCaseToExternalServiceOrder < onFormSubmitSuccessOrder).toBe(true); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index 83b8870ab597d3..26203d7268fd38 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -32,13 +32,15 @@ const initialCaseValue: FormProps = { interface Props { caseType?: CaseType; - onSuccess?: (theCase: Case) => void; + onSuccess?: (theCase: Case) => Promise; + afterCaseCreated?: (theCase: Case) => Promise; } export const FormContext: React.FC = ({ caseType = CaseType.individual, children, onSuccess, + afterCaseCreated, }) => { const { connectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); @@ -72,6 +74,10 @@ export const FormContext: React.FC = ({ settings: { syncAlerts }, }); + if (afterCaseCreated && updatedCase) { + await afterCaseCreated(updatedCase); + } + if (updatedCase?.id && dataConnectorId !== 'none') { await pushCaseToExternalService({ caseId: updatedCase.id, @@ -80,11 +86,11 @@ export const FormContext: React.FC = ({ } if (onSuccess && updatedCase) { - onSuccess(updatedCase); + await onSuccess(updatedCase); } } }, - [caseType, connectors, postCase, onSuccess, pushCaseToExternalService] + [caseType, connectors, postCase, onSuccess, pushCaseToExternalService, afterCaseCreated] ); const { form } = useForm({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index b7d162bd92761a..9f904350b772ef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -41,7 +41,7 @@ const InsertTimeline = () => { export const Create = React.memo(() => { const history = useHistory(); const onSuccess = useCallback( - ({ id }) => { + async ({ id }) => { history.push(getCaseDetailsUrl({ id })); }, [history] diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx index ab30fe2979b9eb..22d72429836de8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx @@ -42,7 +42,7 @@ describe('StatusActionButton', () => { expect( wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') - ).toBe('folderClosed'); + ).toBe('folderCheck'); }); it('it renders the correct button icon: status closed', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/config.ts b/x-pack/plugins/security_solution/public/cases/components/status/config.ts index d811db43df814a..0eebef39859c73 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/config.ts +++ b/x-pack/plugins/security_solution/public/cases/components/status/config.ts @@ -83,7 +83,7 @@ export const statuses: Statuses = { [CaseStatuses.closed]: { color: 'default', label: i18n.CLOSED, - icon: 'folderClosed' as const, + icon: 'folderCheck' as const, actions: { bulk: { title: i18n.BULK_ACTION_CLOSE_SELECTED, diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index aa1305f1f655cb..b3302a05cfcb21 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -30,12 +30,18 @@ jest.mock('../../../common/components/toasters', () => { jest.mock('../all_cases', () => { return { - AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => { + AllCases: ({ onRowClick }: { onRowClick: (theCase: Partial) => void }) => { return ( @@ -49,18 +55,25 @@ jest.mock('../create/form_context', () => { FormContext: ({ children, onSuccess, + afterCaseCreated, }: { children: ReactNode; - onSuccess: (theCase: Partial) => void; + onSuccess: (theCase: Partial) => Promise; + afterCaseCreated: (theCase: Partial) => Promise; }) => { return ( <> @@ -212,11 +225,43 @@ describe('AddToCaseAction', () => { }); }); - it('navigates to case view', async () => { + it('navigates to case view when attach to a new case', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); + + expect(mockDispatchToaster).toHaveBeenCalled(); + const toast = mockDispatchToaster.mock.calls[0][0].toast; + + const toastWrapper = mount( + {}} /> + ); + + toastWrapper + .find('[data-test-subj="toaster-content-case-view-link"]') + .first() + .simulate('click'); + + expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' }); + }); + + it('navigates to case view when attach to an existing case', async () => { usePostCommentMock.mockImplementation(() => { return { ...defaultPostComment, - postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => updateCase()), + postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => { + updateCase({ + id: 'selected-case', + title: 'the selected case', + settings: { syncAlerts: true }, + }); + }), }; }); @@ -227,8 +272,8 @@ describe('AddToCaseAction', () => { ); wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click'); expect(mockDispatchToaster).toHaveBeenCalled(); const toast = mockDispatchToaster.mock.calls[0][0].toast; @@ -242,6 +287,8 @@ describe('AddToCaseAction', () => { .first() .simulate('click'); - expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' }); + expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { + path: '/selected-case', + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index aa9cec2d6b5b14..3000551dd3c07f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -15,7 +15,7 @@ import { EuiToolTip, } from '@elastic/eui'; -import { CommentType } from '../../../../../case/common/api'; +import { CommentType, CaseStatuses } from '../../../../../case/common/api'; import { Ecs } from '../../../../common/ecs'; import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item'; import { usePostComment } from '../../containers/use_post_comment'; @@ -70,9 +70,9 @@ const AddToCaseActionComponent: React.FC = ({ } = useControl(); const attachAlertToCase = useCallback( - (theCase: Case) => { + async (theCase: Case, updateCase?: (newCase: Case) => void) => { closeCaseFlyoutOpen(); - postComment({ + await postComment({ caseId: theCase.id, data: { type: CommentType.alert, @@ -83,14 +83,19 @@ const AddToCaseActionComponent: React.FC = ({ name: rule?.name != null ? rule.name[0] : null, }, }, - updateCase: () => - dispatchToaster({ - type: 'addToaster', - toast: createUpdateSuccessToaster(theCase, onViewCaseClick), - }), + updateCase, }); }, - [closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule, dispatchToaster, onViewCaseClick] + [closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule] + ); + + const onCaseSuccess = useCallback( + async (theCase: Case) => + dispatchToaster({ + type: 'addToaster', + toast: createUpdateSuccessToaster(theCase, onViewCaseClick), + }), + [dispatchToaster, onViewCaseClick] ); const onCaseClicked = useCallback( @@ -105,12 +110,13 @@ const AddToCaseActionComponent: React.FC = ({ return; } - attachAlertToCase(theCase); + attachAlertToCase(theCase, onCaseSuccess); }, - [attachAlertToCase, openCaseFlyoutOpen] + [attachAlertToCase, onCaseSuccess, openCaseFlyoutOpen] ); const { modal: allCasesModal, openModal: openAllCaseModal } = useAllCasesModal({ + disabledStatuses: [CaseStatuses.closed], onRowClick: onCaseClicked, }); @@ -183,7 +189,11 @@ const AddToCaseActionComponent: React.FC = ({ {isCreateCaseFlyoutOpen && ( - + )} {allCasesModal} diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx index eda8ed8cdfbcd5..e1d6baa6e630a3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -6,36 +6,52 @@ */ import React, { memo } from 'react'; +import styled from 'styled-components'; import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; -import { Case } from '../../containers/types'; +import { CaseStatuses } from '../../../../../case/common/api'; +import { Case, SubCase } from '../../containers/types'; import { AllCases } from '../all_cases'; import * as i18n from './translations'; export interface AllCasesModalProps { isModalOpen: boolean; onCloseCaseModal: () => void; - onRowClick: (theCase?: Case) => void; + onRowClick: (theCase?: Case | SubCase) => void; + disabledStatuses?: CaseStatuses[]; } +const Modal = styled(EuiModal)` + ${({ theme }) => ` + width: ${theme.eui.euiBreakpoints.l}; + max-width: ${theme.eui.euiBreakpoints.l}; + `} +`; + const AllCasesModalComponent: React.FC = ({ isModalOpen, onCloseCaseModal, onRowClick, + disabledStatuses, }) => { const userPermissions = useGetUserSavedObjectPermissions(); const userCanCrud = userPermissions?.crud ?? false; return isModalOpen ? ( - + {i18n.SELECT_CASE_TITLE} - + - + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx index 576f942a36a8fb..57bb39a1ab50f2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx @@ -123,7 +123,7 @@ describe('useAllCasesModal', () => { }); const modal = result.current.modal; - render(<>{modal}); + render({modal}); act(() => { userEvent.click(screen.getByText('case-row')); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx index 79b490c1962da4..52b8ebe0210c0e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx @@ -6,11 +6,13 @@ */ import React, { useState, useCallback, useMemo } from 'react'; -import { Case } from '../../containers/types'; +import { CaseStatuses } from '../../../../../case/common/api'; +import { Case, SubCase } from '../../containers/types'; import { AllCasesModal } from './all_cases_modal'; export interface UseAllCasesModalProps { - onRowClick: (theCase?: Case) => void; + onRowClick: (theCase?: Case | SubCase) => void; + disabledStatuses?: CaseStatuses[]; } export interface UseAllCasesModalReturnedValues { @@ -22,12 +24,13 @@ export interface UseAllCasesModalReturnedValues { export const useAllCasesModal = ({ onRowClick, + disabledStatuses, }: UseAllCasesModalProps): UseAllCasesModalReturnedValues => { const [isModalOpen, setIsModalOpen] = useState(false); const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); const onClick = useCallback( - (theCase?: Case) => { + (theCase?: Case | SubCase) => { closeModal(); onRowClick(theCase); }, @@ -41,6 +44,7 @@ export const useAllCasesModal = ({ isModalOpen={isModalOpen} onCloseCaseModal={closeModal} onRowClick={onClick} + disabledStatuses={disabledStatuses} /> ), isModalOpen, @@ -48,7 +52,7 @@ export const useAllCasesModal = ({ openModal, onRowClick, }), - [isModalOpen, closeModal, onClick, openModal, onRowClick] + [isModalOpen, closeModal, onClick, disabledStatuses, openModal, onRowClick] ); return state; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx index 0e04acb013b2d5..08fca0cc6e0097 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx @@ -20,14 +20,16 @@ jest.mock('../create/form_context', () => { onSuccess, }: { children: ReactNode; - onSuccess: ({ id }: { id: string }) => void; + onSuccess: ({ id }: { id: string }) => Promise; }) => { return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 2806e358fceee9..3e11ee526839cd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -19,7 +19,7 @@ import { CaseType } from '../../../../../case/common/api'; export interface CreateCaseModalProps { isModalOpen: boolean; onCloseCaseModal: () => void; - onSuccess: (theCase: Case) => void; + onSuccess: (theCase: Case) => Promise; caseType?: CaseType; } diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx index 9966cf75351dd4..5174c03e56e0b3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx @@ -25,14 +25,16 @@ jest.mock('../create/form_context', () => { onSuccess, }: { children: ReactNode; - onSuccess: ({ id }: { id: string }) => void; + onSuccess: ({ id }: { id: string }) => Promise; }) => { return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx index 3dc852a19e73fc..1cef63ae9cfbf8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx @@ -29,7 +29,7 @@ export const useCreateCaseModal = ({ const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); const onSuccess = useCallback( - (theCase) => { + async (theCase) => { onCaseCreated(theCase); closeModal(); }, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx index c5d3ef1893ad7b..056add32add82d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx @@ -55,6 +55,9 @@ describe('UserActionTree ', () => { useFormMock.mockImplementation(() => ({ form: formHookMock })); useFormDataMock.mockImplementation(() => [{ content: sampleData.content, comment: '' }]); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + jest + .spyOn(routeData, 'useParams') + .mockReturnValue({ detailName: 'case-id', subCaseId: 'sub-case-id' }); }); it('Loading spinner when user actions loading and displays fullName/username', () => { @@ -289,7 +292,8 @@ describe('UserActionTree ', () => { ).toEqual(false); expect(patchComment).toBeCalledWith({ commentUpdate: sampleData.content, - caseId: props.data.id, + caseId: 'case-id', + subCaseId: 'sub-case-id', commentId: props.data.comments[0].id, fetchUserActions, updateCase, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index 2a9f99465251b9..cf68d07859ced5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -122,7 +122,11 @@ export const UserActionTree = React.memo( userCanCrud, onShowAlertDetails, }: UserActionTreeProps) => { - const { commentId, subCaseId } = useParams<{ commentId?: string; subCaseId?: string }>(); + const { detailName: caseId, commentId, subCaseId } = useParams<{ + detailName: string; + commentId?: string; + subCaseId?: string; + }>(); const handlerTimeoutId = useRef(0); const addCommentRef = useRef(null); const [initLoading, setInitLoading] = useState(true); @@ -149,15 +153,16 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( ({ id, version }: { id: string; version: string }, content: string) => { patchComment({ - caseId: caseData.id, + caseId, commentId: id, commentUpdate: content, fetchUserActions, version, updateCase, + subCaseId, }); }, - [caseData.id, fetchUserActions, patchComment, updateCase] + [caseId, fetchUserActions, patchComment, subCaseId, updateCase] ); const handleOutlineComment = useCallback( @@ -223,7 +228,7 @@ export const UserActionTree = React.memo( const MarkdownNewComment = useMemo( () => ( ), - [caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId, subCaseId] + [caseId, userCanCrud, handleUpdate, handleManageMarkdownEditId, subCaseId] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx index a157be2dc13537..a3d64a17727e50 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx @@ -6,7 +6,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { initialData, useGetCase, UseGetCase } from './use_get_case'; +import { useGetCase, UseGetCase } from './use_get_case'; import { basicCase } from './mock'; import * as api from './api'; @@ -26,8 +26,8 @@ describe('useGetCase', () => { ); await waitForNextUpdate(); expect(result.current).toEqual({ - data: initialData, - isLoading: true, + data: null, + isLoading: false, isError: false, fetchCase: result.current.fetchCase, updateCase: result.current.updateCase, @@ -102,7 +102,7 @@ describe('useGetCase', () => { await waitForNextUpdate(); expect(result.current).toEqual({ - data: initialData, + data: null, isLoading: false, isError: true, fetchCase: result.current.fetchCase, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index fb8da8d0663ee1..70e202b5d6bdf6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -6,16 +6,14 @@ */ import { useEffect, useReducer, useCallback, useRef } from 'react'; -import { CaseStatuses, CaseType } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getCase, getSubCase } from './api'; -import { getNoneConnector } from '../components/configure_cases/utils'; interface CaseState { - data: Case; + data: Case | null; isLoading: boolean; isError: boolean; } @@ -56,32 +54,6 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { return state; } }; -export const initialData: Case = { - id: '', - closedAt: null, - closedBy: null, - createdAt: '', - comments: [], - connector: { ...getNoneConnector(), fields: null }, - createdBy: { - username: '', - }, - description: '', - externalService: null, - status: CaseStatuses.open, - tags: [], - title: '', - totalAlerts: 0, - totalComment: 0, - type: CaseType.individual, - updatedAt: null, - updatedBy: null, - version: '', - subCaseIds: [], - settings: { - syncAlerts: true, - }, -}; export interface UseGetCase extends CaseState { fetchCase: () => void; @@ -90,9 +62,9 @@ export interface UseGetCase extends CaseState { export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { const [state, dispatch] = useReducer(dataFetchReducer, { - isLoading: true, + isLoading: false, isError: false, - data: initialData, + data: null, }); const [, dispatchToaster] = useStateToaster(); const isCancelledRef = useRef(false); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index 5eb875287ba888..75d3047bc828ea 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -48,7 +48,7 @@ interface PostComment { subCaseId?: string; } export interface UsePostComment extends NewCommentState { - postComment: (args: PostComment) => void; + postComment: (args: PostComment) => Promise; } export const usePostComment = (): UsePostComment => { diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index caaa1f6e248ea6..b7cfe11aafda07 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -294,3 +294,10 @@ export const ALERT_ADDED_TO_CASE = i18n.translate( defaultMessage: 'added to case', } ); + +export const SELECTABLE_MESSAGE_COLLECTIONS = i18n.translate( + 'xpack.securitySolution.common.allCases.table.selectableMessageCollections', + { + defaultMessage: 'Cases with sub-cases cannot be selected', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx index ff358b6ab0e1d6..f7466b183e18a5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx @@ -25,7 +25,7 @@ describe('useQueryAlerts', () => { >(() => useQueryAlerts(mockAlertsQuery, indexName)); await waitForNextUpdate(); expect(result.current).toEqual({ - loading: true, + loading: false, data: null, response: '', request: '', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index 8557e1082c1cb9..3736c8593daa93 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -42,7 +42,7 @@ export const useQueryAlerts = ( setQuery, refetch: null, }); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); useEffect(() => { let isSubscribed = true; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index 487d42aac48409..5cba64299ee9d5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -20,7 +20,7 @@ import { TimelineStatus, TimelineId, TimelineType } from '../../../../../common/ import { getCreateCaseUrl, getCaseDetailsUrl } from '../../../../common/components/link_to'; import { SecurityPageName } from '../../../../app/types'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; -import { Case } from '../../../../cases/containers/types'; +import { Case, SubCase } from '../../../../cases/containers/types'; import * as i18n from '../../timeline/properties/translations'; interface Props { @@ -46,7 +46,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { const [isPopoverOpen, setPopover] = useState(false); const onRowClick = useCallback( - async (theCase?: Case) => { + async (theCase?: Case | SubCase) => { await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { path: theCase != null ? getCaseDetailsUrl({ id: theCase.id }) : getCreateCaseUrl(), }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts index f908a369b46d71..c58ca0242a5b5a 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts @@ -101,15 +101,15 @@ export default ({ getService }: FtrProviderContext): void => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest .delete( - `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}?subCaseID=${ - caseInfo.subCase!.id + `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}?subCaseId=${ + caseInfo.subCases![0].id }` ) .set('kbn-xsrf', 'true') .send() .expect(204); const { body } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` ); expect(body.length).to.eql(0); }); @@ -117,24 +117,24 @@ export default ({ getService }: FtrProviderContext): void => { it('deletes all comments from a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); let { body: allComments } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` ); expect(allComments.length).to.eql(2); await supertest - .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send() .expect(204); ({ body: allComments } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` )); // no comments for the sub case diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts index 585333291111eb..2d8e4c44e023e9 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts @@ -126,13 +126,13 @@ export default ({ getService }: FtrProviderContext): void => { it('finds comments for a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const { body: subCaseComments }: { body: CommentsResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) .send() .expect(200); expect(subCaseComments.total).to.be(2); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts index 1af16f9e54563c..264103a2052e53 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts @@ -83,13 +83,13 @@ export default ({ getService }: FtrProviderContext): void => { it('should get comments from a sub cases', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.subCase!.id}/comments`) + .post(`${CASES_URL}/${caseInfo.subCases![0].id}/comments`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const { body: comments } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .expect(200); expect(comments.length).to.eql(2); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts index 389ec3f088f95b..bf63c55938dfec 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts @@ -60,7 +60,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should get a sub case comment', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body: comment }: { body: CommentResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}`) .expect(200); expect(comment.type).to.be(CommentType.generatedAlert); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts index 86b1c3031cbef7..6d9962e938249c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -10,10 +10,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { - CollectionWithSubCaseResponse, - CommentType, -} from '../../../../../../plugins/case/common/api'; +import { CaseResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, @@ -56,42 +53,38 @@ export default ({ getService }: FtrProviderContext): void => { it('patches a comment for a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - const { - body: patchedSubCase, - }: { body: CollectionWithSubCaseResponse } = await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + const { body: patchedSubCase }: { body: CaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const newComment = 'Well I decided to update my comment. So what? Deal with it.'; const { body: patchedSubCaseUpdatedComment } = await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send({ - id: patchedSubCase.subCase!.comments![1].id, - version: patchedSubCase.subCase!.comments![1].version, + id: patchedSubCase.comments![1].id, + version: patchedSubCase.comments![1].version, comment: newComment, type: CommentType.user, }) .expect(200); - expect(patchedSubCaseUpdatedComment.subCase.comments.length).to.be(2); - expect(patchedSubCaseUpdatedComment.subCase.comments[0].type).to.be( - CommentType.generatedAlert - ); - expect(patchedSubCaseUpdatedComment.subCase.comments[1].type).to.be(CommentType.user); - expect(patchedSubCaseUpdatedComment.subCase.comments[1].comment).to.be(newComment); + expect(patchedSubCaseUpdatedComment.comments.length).to.be(2); + expect(patchedSubCaseUpdatedComment.comments[0].type).to.be(CommentType.generatedAlert); + expect(patchedSubCaseUpdatedComment.comments[1].type).to.be(CommentType.user); + expect(patchedSubCaseUpdatedComment.comments[1].comment).to.be(newComment); }); it('fails to update the generated alert comment type', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send({ - id: caseInfo.subCase!.comments![0].id, - version: caseInfo.subCase!.comments![0].version, + id: caseInfo.comments![0].id, + version: caseInfo.comments![0].version, type: CommentType.alert, alertId: 'test-id', index: 'test-index', @@ -106,11 +99,11 @@ export default ({ getService }: FtrProviderContext): void => { it('fails to update the generated alert comment by using another generated alert comment', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send({ - id: caseInfo.subCase!.comments![0].id, - version: caseInfo.subCase!.comments![0].version, + id: caseInfo.comments![0].id, + version: caseInfo.comments![0].version, type: CommentType.generatedAlert, alerts: [{ _id: 'id1' }], index: 'test-index', diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index fb095c117cdfb0..9447f7ad3613c2 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -393,13 +393,13 @@ export default ({ getService }: FtrProviderContext): void => { // create another sub case just to make sure we get the right comments await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const { body: subCaseComments }: { body: CommentsResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) .send() .expect(200); expect(subCaseComments.total).to.be(2); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts index 8edc3b0d081138..5e761e4d7e33a3 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts @@ -20,7 +20,7 @@ import { deleteComments, } from '../../../common/lib/utils'; import { getSubCaseDetailsUrl } from '../../../../../plugins/case/common/api/helpers'; -import { CollectionWithSubCaseResponse } from '../../../../../plugins/case/common/api'; +import { CaseResponse } from '../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -104,7 +104,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should delete the sub cases when deleting a collection', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); const { body } = await supertest .delete(`${CASES_URL}?ids=["${caseInfo.id}"]`) @@ -114,27 +114,25 @@ export default ({ getService }: FtrProviderContext): void => { expect(body).to.eql({}); await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .send() .expect(404); }); it(`should delete a sub case's comments when that case gets deleted`, async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); // there should be two comments on the sub case now - const { - body: patchedCaseWithSubCase, - }: { body: CollectionWithSubCaseResponse } = await supertest + const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest .post(`${CASES_URL}/${caseInfo.id}/comments`) .set('kbn-xsrf', 'true') - .query({ subCaseID: caseInfo.subCase!.id }) + .query({ subCaseId: caseInfo.subCases![0].id }) .send(postCommentUserReq) .expect(200); const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ - patchedCaseWithSubCase.subCase!.comments![1].id + patchedCaseWithSubCase.comments![1].id }`; // make sure we can get the second comment await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts index a2bc0acbcf17cc..7514044d376ca4 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -265,8 +265,8 @@ export default ({ getService }: FtrProviderContext): void => { supertest, cases: [ { - id: collection.newSubCaseInfo.subCase!.id, - version: collection.newSubCaseInfo.subCase!.version, + id: collection.newSubCaseInfo.subCases![0].id, + version: collection.newSubCaseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -356,7 +356,7 @@ export default ({ getService }: FtrProviderContext): void => { it('correctly counts stats including a collection without sub cases', async () => { // delete the sub case on the collection so that it doesn't have any sub cases await supertest - .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCase!.id}"]`) + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]`) .set('kbn-xsrf', 'true') .send() .expect(204); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts index 537afbe8250682..1d8216ded8b7c0 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts @@ -19,7 +19,7 @@ import { deleteCaseAction, } from '../../../../common/lib/utils'; import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; -import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api'; +import { CaseResponse } from '../../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default function ({ getService }: FtrProviderContext) { @@ -40,10 +40,10 @@ export default function ({ getService }: FtrProviderContext) { it('should delete a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); const { body: subCase } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .send() .expect(200); @@ -57,33 +57,31 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.eql({}); await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .send() .expect(404); }); it(`should delete a sub case's comments when that case gets deleted`, async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); // there should be two comments on the sub case now - const { - body: patchedCaseWithSubCase, - }: { body: CollectionWithSubCaseResponse } = await supertest + const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest .post(`${CASES_URL}/${caseInfo.id}/comments`) .set('kbn-xsrf', 'true') - .query({ subCaseID: caseInfo.subCase!.id }) + .query({ subCaseId: caseInfo.subCases![0].id }) .send(postCommentUserReq) .expect(200); const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ - patchedCaseWithSubCase.subCase!.comments![1].id + patchedCaseWithSubCase.comments![1].id }`; // make sure we can get the second comment await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); await supertest - .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCase!.id}"]`) + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCases![0].id}"]`) .set('kbn-xsrf', 'true') .send() .expect(204); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts index 3463b372509809..4fd4cd6ec7542b 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts @@ -74,7 +74,7 @@ export default ({ getService }: FtrProviderContext): void => { ...findSubCasesResp, total: 1, // find should not return the comments themselves only the stats - subCases: [{ ...caseInfo.subCase!, comments: [], totalComment: 1, totalAlerts: 2 }], + subCases: [{ ...caseInfo.subCases![0], comments: [], totalComment: 1, totalAlerts: 2 }], count_open_cases: 1, }); }); @@ -101,7 +101,7 @@ export default ({ getService }: FtrProviderContext): void => { status: CaseStatuses.closed, }, { - ...subCase2Resp.newSubCaseInfo.subCase, + ...subCase2Resp.newSubCaseInfo.subCases![0], comments: [], totalComment: 1, totalAlerts: 2, @@ -157,8 +157,8 @@ export default ({ getService }: FtrProviderContext): void => { supertest, cases: [ { - id: secondSub.subCase!.id, - version: secondSub.subCase!.version, + id: secondSub.subCases![0].id, + version: secondSub.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -231,8 +231,8 @@ export default ({ getService }: FtrProviderContext): void => { supertest, cases: [ { - id: secondSub.subCase!.id, - version: secondSub.subCase!.version, + id: secondSub.subCases![0].id, + version: secondSub.subCases![0].version, status: CaseStatuses['in-progress'], }, ], diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts index cd5a1ed85742f1..dff462d78ba82d 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts @@ -28,7 +28,7 @@ import { } from '../../../../../../plugins/case/common/api/helpers'; import { AssociationType, - CollectionWithSubCaseResponse, + CaseResponse, SubCaseResponse, } from '../../../../../../plugins/case/common/api'; @@ -53,14 +53,14 @@ export default ({ getService }: FtrProviderContext): void => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body }: { body: SubCaseResponse } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .set('kbn-xsrf', 'true') .send() .expect(200); expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( commentsResp({ - comments: [{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }], + comments: [{ comment: defaultCreateSubComment, id: caseInfo.comments![0].id }], associationType: AssociationType.subCase, }) ); @@ -73,15 +73,15 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the correct number of alerts with multiple types of alerts', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest + const { body: singleAlert }: { body: CaseResponse } = await supertest .post(getCaseCommentsUrl(caseInfo.id)) - .query({ subCaseID: caseInfo.subCase!.id }) + .query({ subCaseId: caseInfo.subCases![0].id }) .set('kbn-xsrf', 'true') .send(postCommentAlertReq) .expect(200); const { body }: { body: SubCaseResponse } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .set('kbn-xsrf', 'true') .send() .expect(200); @@ -89,10 +89,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( commentsResp({ comments: [ - { comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }, + { comment: defaultCreateSubComment, id: caseInfo.comments![0].id }, { comment: postCommentAlertReq, - id: singleAlert.subCase!.comments![1].id, + id: singleAlert.comments![1].id, }, ], associationType: AssociationType.subCase, diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts index 49b3c0b1f465b3..5a1da194a721f5 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts @@ -59,15 +59,15 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], type: 'sub_case', }); const { body: subCase }: { body: SubCaseResponse } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .expect(200); expect(subCase.status).to.eql(CaseStatuses['in-progress']); @@ -102,8 +102,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -159,8 +159,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -239,8 +239,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: collectionWithSecondSub.subCase!.id, - version: collectionWithSecondSub.subCase!.version, + id: collectionWithSecondSub.subCases![0].id, + version: collectionWithSecondSub.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -349,8 +349,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -450,8 +450,8 @@ export default function ({ getService }: FtrProviderContext) { .send({ subCases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, type: 'blah', }, ], diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index f6fd2b1a6b3bed..c3c37bd20f1404 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -26,7 +26,6 @@ import { CaseClientPostRequest, SubCaseResponse, AssociationType, - CollectionWithSubCaseResponse, SubCasesFindResponse, CommentRequest, } from '../../../../plugins/case/common/api'; @@ -159,18 +158,17 @@ export const subCaseResp = ({ interface FormattedCollectionResponse { caseInfo: Partial; - subCase?: Partial; + subCases?: Array>; comments?: Array>; } -export const formatCollectionResponse = ( - caseInfo: CollectionWithSubCaseResponse -): FormattedCollectionResponse => { +export const formatCollectionResponse = (caseInfo: CaseResponse): FormattedCollectionResponse => { + const subCase = removeServerGeneratedPropertiesFromSubCase(caseInfo.subCases?.[0]); return { caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo), - subCase: removeServerGeneratedPropertiesFromSubCase(caseInfo.subCase), + subCases: subCase ? [subCase] : undefined, comments: removeServerGeneratedPropertiesFromComments( - caseInfo.subCase?.comments ?? caseInfo.comments + caseInfo.subCases?.[0].comments ?? caseInfo.comments ), }; }; @@ -187,10 +185,10 @@ export const removeServerGeneratedPropertiesFromSubCase = ( }; export const removeServerGeneratedPropertiesFromCaseCollection = ( - config: Partial -): Partial => { + config: Partial +): Partial => { // eslint-disable-next-line @typescript-eslint/naming-convention - const { closed_at, created_at, updated_at, version, subCase, ...rest } = config; + const { closed_at, created_at, updated_at, version, subCases, ...rest } = config; return rest; }; diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 7aee6170c3d5ab..3ade7ef96f9dd1 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -17,7 +17,7 @@ import { CaseConnector, ConnectorTypes, CasePostRequest, - CollectionWithSubCaseResponse, + CaseResponse, SubCasesFindResponse, CaseStatuses, SubCasesResponse, @@ -120,7 +120,7 @@ export const defaultCreateSubPost = postCollectionReq; * Response structure for the createSubCase and createSubCaseComment functions. */ export interface CreateSubCaseResp { - newSubCaseInfo: CollectionWithSubCaseResponse; + newSubCaseInfo: CaseResponse; modifiedSubCases?: SubCasesResponse; } From 910a19f3c418cdae2d0c3d607595844844098324 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Fri, 26 Feb 2021 07:25:06 -0800 Subject: [PATCH 2/8] Add warning for EQL and Threshold rules if exception list contains value list items (#92914) --- .../common/detection_engine/utils.ts | 12 ++++++- .../routes/__mocks__/request_responses.ts | 29 +++++++++++++++++ .../signals/signal_rule_alert_type.test.ts | 32 ++++++++++++++++++- .../signals/signal_rule_alert_type.ts | 13 ++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index 725a2eb9fea7bb..79b912e082fdb1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -5,9 +5,19 @@ * 2.0. */ -import { EntriesArray } from '../shared_imports'; +import { + CreateExceptionListItemSchema, + EntriesArray, + ExceptionListItemSchema, +} from '../shared_imports'; import { Type } from './schemas/common/schemas'; +export const hasLargeValueItem = ( + exceptionItems: Array +) => { + return exceptionItems.some((exceptionItem) => hasLargeValueList(exceptionItem.entries)); +}; + export const hasLargeValueList = (entries: EntriesArray): boolean => { const found = entries.filter(({ type }) => type === 'list'); return found.length > 0; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index cf6ea572aa8561..649ce9ed643651 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -440,6 +440,35 @@ export const getMlResult = (): RuleAlertType => { }; }; +export const getThresholdResult = (): RuleAlertType => { + const result = getResult(); + + return { + ...result, + params: { + ...result.params, + type: 'threshold', + threshold: { + field: 'host.ip', + value: 5, + }, + }, + }; +}; + +export const getEqlResult = (): RuleAlertType => { + const result = getResult(); + + return { + ...result, + params: { + ...result.params, + type: 'eql', + query: 'process where true', + }, + }; +}; + export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index cadc6d0c5b7c01..d3d82682cbb4a0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -7,7 +7,12 @@ import moment from 'moment'; import { loggingSystemMock } from 'src/core/server/mocks'; -import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; +import { + getResult, + getMlResult, + getThresholdResult, + getEqlResult, +} from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; @@ -24,6 +29,7 @@ import { getListClientMock } from '../../../../../lists/server/services/lists/li import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; +import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -211,6 +217,30 @@ describe('rules_notification_alert_type', () => { ); }); + it('should set a warning when exception list for threshold rule contains value list exceptions', async () => { + (getExceptions as jest.Mock).mockReturnValue([ + getExceptionListItemSchemaMock({ entries: [getEntryListMock()] }), + ]); + payload = getPayload(getThresholdResult(), alertServices); + await alert.executor(payload); + expect(ruleStatusService.warning).toHaveBeenCalled(); + expect(ruleStatusService.warning.mock.calls[0][0]).toContain( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to Threshold rules' + ); + }); + + it('should set a warning when exception list for EQL rule contains value list exceptions', async () => { + (getExceptions as jest.Mock).mockReturnValue([ + getExceptionListItemSchemaMock({ entries: [getEntryListMock()] }), + ]); + payload = getPayload(getEqlResult(), alertServices); + await alert.executor(payload); + expect(ruleStatusService.warning).toHaveBeenCalled(); + expect(ruleStatusService.warning.mock.calls[0][0]).toContain( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to EQL rules' + ); + }); + it('should set a failure status for when rules cannot read ANY provided indices', async () => { (checkPrivileges as jest.Mock).mockResolvedValueOnce({ username: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 2025ba512cb653..14a65bc1eeb7ba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -24,6 +24,7 @@ import { isThresholdRule, isEqlRule, isThreatMatchRule, + hasLargeValueItem, } from '../../../../common/detection_engine/utils'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; import { SetupPlugins } from '../../../plugin'; @@ -365,6 +366,12 @@ export const signalRulesAlertType = ({ }), ]); } else if (isThresholdRule(type) && threshold) { + if (hasLargeValueItem(exceptionItems ?? [])) { + await ruleStatusService.warning( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to Threshold rules' + ); + wroteWarningStatus = true; + } const inputIndex = await getInputIndex(services, version, index); const thresholdFields = Array.isArray(threshold.field) @@ -552,6 +559,12 @@ export const signalRulesAlertType = ({ if (query === undefined) { throw new Error('EQL query rule must have a query defined'); } + if (hasLargeValueItem(exceptionItems ?? [])) { + await ruleStatusService.warning( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to EQL rules' + ); + wroteWarningStatus = true; + } try { const signalIndexVersion = await getIndexVersion(services.callCluster, outputIndex); if (isOutdated({ current: signalIndexVersion, target: MIN_EQL_RULE_INDEX_VERSION })) { From 83234aad2d460a757c7929a3adcaf03f7df50fbc Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Fri, 26 Feb 2021 08:48:51 -0800 Subject: [PATCH 3/8] [Alerts][Doc] Added README documentation for alerts plugin status and framework health checks configuration options. (#92761) * [Alerts][Doc] Added README documentation for alerts plugin status and framework health checks configuration options. * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- x-pack/plugins/alerts/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index c57216603665dc..07bad42a3bfa3d 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -15,6 +15,7 @@ Table of Contents - [Usage](#usage) - [Alerts API keys](#alerts-api-keys) - [Limitations](#limitations) + - [Plugin status](#plugin-status) - [Alert types](#alert-types) - [Methods](#methods) - [Executor](#executor) @@ -79,6 +80,27 @@ Note that the `manage_own_api_key` cluster privilege is not enough - it can be u is unauthorized for user [user-name-here] ``` +## Plugin status + +The plugin status of an alert is customized by including information about checking failures for the framework decryption: +``` +core.status.set( + combineLatest([ + core.status.derivedStatus$, + getHealthStatusStream(startPlugins.taskManager), + ]).pipe( + map(([derivedStatus, healthStatus]) => { + if (healthStatus.level > derivedStatus.level) { + return healthStatus as ServiceStatus; + } else { + return derivedStatus; + } + }) + ) + ); +``` +To check for framework decryption failures, we use the task `alerting_health_check`, which runs every 60 minutes by default. To change the default schedule, use the kibana.yml configuration option `xpack.alerts.healthCheck.interval`. + ## Alert types ### Methods From 993ac501053dc18607c9a006aaeb3f104c994ff8 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Fri, 26 Feb 2021 12:48:09 -0500 Subject: [PATCH 4/8] [Security Solution][Case][Bug] Improve case logging (#91924) * First pass at bringing in more logging * Adding more logging to routes * Adding more logging fixing tests * Removing duplicate case string in logs * Removing unneeded export * Fixing type error * Adding line breaks to make the messages more readable * Fixing type errors Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/case/server/client/alerts/get.ts | 6 +- .../client/alerts/update_status.test.ts | 1 + .../server/client/alerts/update_status.ts | 6 +- .../case/server/client/cases/create.test.ts | 101 +++--- .../case/server/client/cases/create.ts | 80 +++-- .../plugins/case/server/client/cases/get.ts | 66 ++-- .../plugins/case/server/client/cases/push.ts | 32 +- .../case/server/client/cases/update.test.ts | 73 ++-- .../case/server/client/cases/update.ts | 313 +++++++++--------- x-pack/plugins/case/server/client/client.ts | 220 ++++++++---- .../case/server/client/comments/add.test.ts | 41 ++- .../case/server/client/comments/add.ts | 275 ++++++++------- .../server/client/configure/get_mappings.ts | 75 +++-- .../plugins/case/server/client/index.test.ts | 3 + x-pack/plugins/case/server/client/mocks.ts | 2 +- x-pack/plugins/case/server/client/types.ts | 3 +- x-pack/plugins/case/server/common/error.ts | 73 ++++ .../server/common/models/commentable_case.ts | 310 +++++++++-------- .../case/server/connectors/case/index.ts | 60 +++- x-pack/plugins/case/server/plugin.ts | 6 + .../routes/api/__fixtures__/mock_router.ts | 1 + .../routes/api/__fixtures__/route_contexts.ts | 1 + .../api/cases/comments/delete_all_comments.ts | 10 +- .../api/cases/comments/delete_comment.ts | 10 +- .../api/cases/comments/find_comments.ts | 5 +- .../api/cases/comments/get_all_comment.ts | 5 +- .../routes/api/cases/comments/get_comment.ts | 5 +- .../api/cases/comments/patch_comment.ts | 32 +- .../routes/api/cases/comments/post_comment.ts | 5 +- .../api/cases/configure/get_configure.ts | 3 +- .../api/cases/configure/get_connectors.ts | 3 +- .../api/cases/configure/patch_configure.ts | 8 +- .../api/cases/configure/post_configure.ts | 8 +- .../server/routes/api/cases/delete_cases.ts | 5 +- .../server/routes/api/cases/find_cases.ts | 3 +- .../case/server/routes/api/cases/get_case.ts | 11 +- .../server/routes/api/cases/patch_cases.ts | 15 +- .../case/server/routes/api/cases/post_case.ts | 15 +- .../case/server/routes/api/cases/push_case.ts | 21 +- .../api/cases/reporters/get_reporters.ts | 3 +- .../routes/api/cases/status/get_status.ts | 3 +- .../api/cases/sub_case/delete_sub_cases.ts | 10 +- .../api/cases/sub_case/find_sub_cases.ts | 5 +- .../routes/api/cases/sub_case/get_sub_case.ts | 5 +- .../api/cases/sub_case/patch_sub_cases.ts | 308 +++++++++-------- .../user_actions/get_all_user_actions.ts | 36 +- .../plugins/case/server/routes/api/types.ts | 3 + .../plugins/case/server/routes/api/utils.ts | 17 +- .../case/server/services/alerts/index.test.ts | 5 +- .../case/server/services/alerts/index.ts | 80 +++-- .../services/connector_mappings/index.ts | 4 +- x-pack/plugins/case/server/services/index.ts | 60 ++-- .../server/services/user_actions/index.ts | 4 +- 53 files changed, 1501 insertions(+), 954 deletions(-) create mode 100644 x-pack/plugins/case/server/common/error.ts diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts index a7ca5d9742c6bb..0b2663b7372041 100644 --- a/x-pack/plugins/case/server/client/alerts/get.ts +++ b/x-pack/plugins/case/server/client/alerts/get.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient } from 'kibana/server'; +import { ElasticsearchClient, Logger } from 'kibana/server'; import { AlertServiceContract } from '../../services'; import { CaseClientGetAlertsResponse } from './types'; @@ -14,6 +14,7 @@ interface GetParams { ids: string[]; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } export const get = async ({ @@ -21,12 +22,13 @@ export const get = async ({ ids, indices, scopedClusterClient, + logger, }: GetParams): Promise => { if (ids.length === 0 || indices.size <= 0) { return []; } - const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient }); + const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient, logger }); if (!alerts) { return []; } diff --git a/x-pack/plugins/case/server/client/alerts/update_status.test.ts b/x-pack/plugins/case/server/client/alerts/update_status.test.ts index c8df1c8ab74f36..b3ed3c2b84a993 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.test.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.test.ts @@ -22,6 +22,7 @@ describe('updateAlertsStatus', () => { expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ scopedClusterClient: expect.anything(), + logger: expect.anything(), ids: ['alert-id-1'], indices: new Set(['.siem-signals']), status: CaseStatuses.closed, diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts index cb18bd4fc16e3e..2194c3a18afdd6 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient } from 'src/core/server'; +import { ElasticsearchClient, Logger } from 'src/core/server'; import { CaseStatuses } from '../../../common/api'; import { AlertServiceContract } from '../../services'; @@ -15,6 +15,7 @@ interface UpdateAlertsStatusArgs { status: CaseStatuses; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } export const updateAlertsStatus = async ({ @@ -23,6 +24,7 @@ export const updateAlertsStatus = async ({ status, indices, scopedClusterClient, + logger, }: UpdateAlertsStatusArgs): Promise => { - await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient }); + await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient, logger }); }; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index 3016a57f218757..e8cc1a8898f047 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -6,6 +6,7 @@ */ import { ConnectorTypes, CaseStatuses, CaseType, CaseClientPostRequest } from '../../../common/api'; +import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, @@ -276,14 +277,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing description', async () => { @@ -303,14 +306,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing tags', async () => { @@ -330,14 +335,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing connector ', async () => { @@ -352,14 +359,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when connector missing the right fields', async () => { @@ -380,14 +389,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws if you passing status for a new case', async () => { @@ -413,7 +424,7 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create(postCase).catch((e) => { + return caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); @@ -441,10 +452,12 @@ describe('create', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create(postCase).catch((e) => { + return caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(400); }); }); }); diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index ee47c59072fdd9..f88924483e0b87 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -10,7 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract, Logger } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { @@ -34,6 +34,7 @@ import { CaseServiceSetup, CaseUserActionServiceSetup, } from '../../services'; +import { createCaseError } from '../../common/error'; interface CreateCaseArgs { caseConfigureService: CaseConfigureServiceSetup; @@ -42,8 +43,12 @@ interface CreateCaseArgs { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; theCase: CasePostRequest; + logger: Logger; } +/** + * Creates a new case. + */ export const create = async ({ savedObjectsClient, caseService, @@ -51,6 +56,7 @@ export const create = async ({ userActionService, user, theCase, + logger, }: CreateCaseArgs): Promise => { // default to an individual case if the type is not defined. const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; @@ -60,41 +66,45 @@ export const create = async ({ fold(throwErrors(Boom.badRequest), identity) ); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = user; - const createdDate = new Date().toISOString(); - const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); - const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); - - const newCase = await caseService.postNewCase({ - client: savedObjectsClient, - attributes: transformNewCase({ - createdDate, - newCase: query, - username, - full_name, - email, - connector: transformCaseConnectorToEsConnector(query.connector ?? caseConfigureConnector), - }), - }); + try { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const createdDate = new Date().toISOString(); + const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); + const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCaseUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: newCase.id, - fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], - newValue: JSON.stringify(query), + const newCase = await caseService.postNewCase({ + client: savedObjectsClient, + attributes: transformNewCase({ + createdDate, + newCase: query, + username, + full_name, + email, + connector: transformCaseConnectorToEsConnector(query.connector ?? caseConfigureConnector), }), - ], - }); + }); - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: newCase, - }) - ); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: newCase.id, + fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], + newValue: JSON.stringify(query), + }), + ], + }); + + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: newCase, + }) + ); + } catch (error) { + throw createCaseError({ message: `Failed to create case: ${error}`, error, logger }); + } }; diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts index ab0b97abbcb76a..fa556986ee8d34 100644 --- a/x-pack/plugins/case/server/client/cases/get.ts +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { CaseResponseRt, CaseResponse } from '../../../common/api'; import { CaseServiceSetup } from '../../services'; import { countAlertsForID } from '../../common'; +import { createCaseError } from '../../common/error'; interface GetParams { savedObjectsClient: SavedObjectsClientContract; @@ -17,50 +18,59 @@ interface GetParams { id: string; includeComments?: boolean; includeSubCaseComments?: boolean; + logger: Logger; } +/** + * Retrieves a case and optionally its comments and sub case comments. + */ export const get = async ({ savedObjectsClient, caseService, id, + logger, includeComments = false, includeSubCaseComments = false, }: GetParams): Promise => { - const [theCase, subCasesForCaseId] = await Promise.all([ - caseService.getCase({ + try { + const [theCase, subCasesForCaseId] = await Promise.all([ + caseService.getCase({ + client: savedObjectsClient, + id, + }), + caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), + ]); + + const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); + + if (!includeComments) { + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + subCaseIds, + }) + ); + } + const theComments = await caseService.getAllCaseComments({ client: savedObjectsClient, id, - }), - caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), - ]); + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + includeSubCaseComments, + }); - const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); - - if (!includeComments) { return CaseResponseRt.encode( flattenCaseSavedObject({ savedObject: theCase, + comments: theComments.saved_objects, subCaseIds, + totalComment: theComments.total, + totalAlerts: countAlertsForID({ comments: theComments, id }), }) ); + } catch (error) { + throw createCaseError({ message: `Failed to get case id: ${id}: ${error}`, error, logger }); } - const theComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - id, - options: { - sortField: 'created_at', - sortOrder: 'asc', - }, - includeSubCaseComments, - }); - - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: theCase, - comments: theComments.saved_objects, - subCaseIds, - totalComment: theComments.total, - totalAlerts: countAlertsForID({ comments: theComments, id }), - }) - ); }; diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index 1e0c246855d88b..352328ed1dd40d 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -5,11 +5,12 @@ * 2.0. */ -import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; +import Boom from '@hapi/boom'; import { SavedObjectsBulkUpdateResponse, SavedObjectsClientContract, SavedObjectsUpdateResponse, + Logger, } from 'kibana/server'; import { ActionResult, ActionsClient } from '../../../../actions/server'; import { flattenCaseSavedObject, getAlertIndicesAndIDs } from '../../routes/api/utils'; @@ -34,16 +35,7 @@ import { CaseUserActionServiceSetup, } from '../../services'; import { CaseClientHandler } from '../client'; - -const createError = (e: Error | BoomType, message: string): Error | BoomType => { - if (isBoom(e)) { - e.message = message; - e.output.payload.message = message; - return e; - } - - return Error(message); -}; +import { createCaseError } from '../../common/error'; interface PushParams { savedObjectsClient: SavedObjectsClientContract; @@ -55,6 +47,7 @@ interface PushParams { connectorId: string; caseClient: CaseClientHandler; actionsClient: ActionsClient; + logger: Logger; } export const push = async ({ @@ -67,6 +60,7 @@ export const push = async ({ connectorId, caseId, user, + logger, }: PushParams): Promise => { /* Start of push to external service */ let theCase: CaseResponse; @@ -84,7 +78,7 @@ export const push = async ({ ]); } catch (e) { const message = `Error getting case and/or connector and/or user actions: ${e.message}`; - throw createError(e, message); + throw createCaseError({ message, error: e, logger }); } // We need to change the logic when we support subcases @@ -102,7 +96,11 @@ export const push = async ({ indices, }); } catch (e) { - throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`); + throw createCaseError({ + message: `Error getting alerts for case with id ${theCase.id}: ${e.message}`, + logger, + error: e, + }); } try { @@ -113,7 +111,7 @@ export const push = async ({ }); } catch (e) { const message = `Error getting mapping for connector with id ${connector.id}: ${e.message}`; - throw createError(e, message); + throw createCaseError({ message, error: e, logger }); } try { @@ -127,7 +125,7 @@ export const push = async ({ }); } catch (e) { const message = `Error creating incident for case with id ${theCase.id}: ${e.message}`; - throw createError(e, message); + throw createCaseError({ error: e, message, logger }); } const pushRes = await actionsClient.execute({ @@ -171,7 +169,7 @@ export const push = async ({ ]); } catch (e) { const message = `Error getting user and/or case and/or case configuration and/or case comments: ${e.message}`; - throw createError(e, message); + throw createCaseError({ error: e, message, logger }); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -257,7 +255,7 @@ export const push = async ({ ]); } catch (e) { const message = `Error updating case and/or comments and/or creating user action: ${e.message}`; - throw createError(e, message); + throw createCaseError({ error: e, message, logger }); } /* End of update case with push information */ diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 7a3e4458f25c54..752b0ab369de09 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -6,6 +6,7 @@ */ import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common/api'; +import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, mockCaseNoConnectorId, @@ -640,14 +641,16 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .update({ cases: patchCases }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .update({ cases: patchCases }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing version', async () => { @@ -671,18 +674,20 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .update({ cases: patchCases }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .update({ cases: patchCases }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when fields are identical', async () => { - expect.assertions(4); + expect.assertions(5); const patchCases = { cases: [ { @@ -698,16 +703,18 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update(patchCases).catch((e) => { + return caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(406); - expect(e.message).toBe('All update fields are identical to current version.'); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(406); + expect(boomErr.message).toContain('All update fields are identical to current version.'); }); }); test('it throws when case does not exist', async () => { - expect.assertions(4); + expect.assertions(5); const patchCases = { cases: [ { @@ -728,18 +735,20 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update(patchCases).catch((e) => { + return caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); - expect(e.message).toBe( + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(404); + expect(boomErr.message).toContain( 'These cases not-exists do not exist. Please check you have the correct ids.' ); }); }); test('it throws when cases conflicts', async () => { - expect.assertions(4); + expect.assertions(5); const patchCases = { cases: [ { @@ -755,11 +764,13 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update(patchCases).catch((e) => { + return caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(409); - expect(e.message).toBe( + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(409); + expect(boomErr.message).toContain( 'These cases mock-id-1 has been updated. Please refresh before saving additional updates.' ); }); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index a4ca2b4cbdef98..36318f03bd33f5 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -15,6 +15,7 @@ import { SavedObjectsClientContract, SavedObjectsFindResponse, SavedObjectsFindResult, + Logger, } from 'kibana/server'; import { AlertInfo, @@ -53,6 +54,7 @@ import { } from '../../saved_object_types'; import { CaseClientHandler } from '..'; import { addAlertInfoToStatusMap } from '../../common'; +import { createCaseError } from '../../common/error'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. @@ -325,6 +327,7 @@ interface UpdateArgs { user: User; caseClient: CaseClientHandler; cases: CasesPatchRequest; + logger: Logger; } export const update = async ({ @@ -334,175 +337,189 @@ export const update = async ({ user, caseClient, cases, + logger, }: UpdateArgs): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) ); - const myCases = await caseService.getCases({ - client: savedObjectsClient, - caseIds: query.cases.map((q) => q.id), - }); - - let nonExistingCases: CasePatchRequest[] = []; - const conflictedCases = query.cases.filter((q) => { - const myCase = myCases.saved_objects.find((c) => c.id === q.id); + try { + const myCases = await caseService.getCases({ + client: savedObjectsClient, + caseIds: query.cases.map((q) => q.id), + }); - if (myCase && myCase.error) { - nonExistingCases = [...nonExistingCases, q]; - return false; - } - return myCase == null || myCase?.version !== q.version; - }); + let nonExistingCases: CasePatchRequest[] = []; + const conflictedCases = query.cases.filter((q) => { + const myCase = myCases.saved_objects.find((c) => c.id === q.id); - if (nonExistingCases.length > 0) { - throw Boom.notFound( - `These cases ${nonExistingCases - .map((c) => c.id) - .join(', ')} do not exist. Please check you have the correct ids.` - ); - } + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } + return myCase == null || myCase?.version !== q.version; + }); - if (conflictedCases.length > 0) { - throw Boom.conflict( - `These cases ${conflictedCases - .map((c) => c.id) - .join(', ')} has been updated. Please refresh before saving additional updates.` - ); - } + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } - const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => { - const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id); - const { connector, ...thisCase } = updateCase; - return currentCase != null - ? getCaseToUpdate(currentCase.attributes, { - ...thisCase, - ...(connector != null - ? { connector: transformCaseConnectorToEsConnector(connector) } - : {}), - }) - : { id: thisCase.id, version: thisCase.version }; - }); + if (conflictedCases.length > 0) { + throw Boom.conflict( + `These cases ${conflictedCases + .map((c) => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } - const updateFilterCases = updateCases.filter((updateCase) => { - const { id, version, ...updateCaseAttributes } = updateCase; - return Object.keys(updateCaseAttributes).length > 0; - }); + const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => { + const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id); + const { connector, ...thisCase } = updateCase; + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...thisCase, + ...(connector != null + ? { connector: transformCaseConnectorToEsConnector(connector) } + : {}), + }) + : { id: thisCase.id, version: thisCase.version }; + }); - if (updateFilterCases.length <= 0) { - throw Boom.notAcceptable('All update fields are identical to current version.'); - } + const updateFilterCases = updateCases.filter((updateCase) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); - const casesMap = myCases.saved_objects.reduce((acc, so) => { - acc.set(so.id, so); - return acc; - }, new Map>()); + if (updateFilterCases.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } - throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); - throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); - await throwIfInvalidUpdateOfTypeWithAlerts({ - requests: updateFilterCases, - caseService, - client: savedObjectsClient, - }); + const casesMap = myCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); + throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); + await throwIfInvalidUpdateOfTypeWithAlerts({ + requests: updateFilterCases, + caseService, + client: savedObjectsClient, + }); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = user; - const updatedDt = new Date().toISOString(); - const updatedCases = await caseService.patchCases({ - client: savedObjectsClient, - cases: updateFilterCases.map((thisCase) => { - const { id: caseId, version, ...updateCaseAttributes } = thisCase; - let closedInfo = {}; - if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { - closedInfo = { - closed_at: updatedDt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateCaseAttributes.status && - (updateCaseAttributes.status === CaseStatuses.open || - updateCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { - closed_at: null, - closed_by: null, + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const updatedDt = new Date().toISOString(); + const updatedCases = await caseService.patchCases({ + client: savedObjectsClient, + cases: updateFilterCases.map((thisCase) => { + const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateCaseAttributes.status && + (updateCaseAttributes.status === CaseStatuses.open || + updateCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + caseId, + updatedAttributes: { + ...updateCaseAttributes, + ...closedInfo, + updated_at: updatedDt, + updated_by: { email, full_name, username }, + }, + version, }; - } - return { - caseId, - updatedAttributes: { - ...updateCaseAttributes, - ...closedInfo, - updated_at: updatedDt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); + }), + }); - // If a status update occurred and the case is synced then we need to update all alerts' status - // attached to the case to the new status. - const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.status != null && - currentCase.attributes.status !== caseToUpdate.status && - currentCase.attributes.settings.syncAlerts - ); - }); + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); - // If syncAlerts setting turned on we need to update all alerts' status - // attached to the case to the current status. - const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.settings?.syncAlerts != null && - currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && - caseToUpdate.settings.syncAlerts - ); - }); + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); - // Update the alert's status to match any case status or sync settings changes - await updateAlerts({ - casesWithStatusChangedAndSynced, - casesWithSyncSettingChangedToOn, - caseService, - client: savedObjectsClient, - caseClient, - casesMap, - }); + // Update the alert's status to match any case status or sync settings changes + await updateAlerts({ + casesWithStatusChangedAndSynced, + casesWithSyncSettingChangedToOn, + caseService, + client: savedObjectsClient, + caseClient, + casesMap, + }); - const returnUpdatedCase = myCases.saved_objects - .filter((myCase) => - updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) - ) - .map((myCase) => { - const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); - return flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, - }, + const returnUpdatedCase = myCases.saved_objects + .filter((myCase) => + updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) + ) + .map((myCase) => { + const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); + return flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + }); }); - }); - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: buildCaseUserActions({ - originalCases: myCases.saved_objects, - updatedCases: updatedCases.saved_objects, - actionDate: updatedDt, - actionBy: { email, full_name, username }, - }), - }); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); - return CasesResponseRt.encode(returnUpdatedCase); + return CasesResponseRt.encode(returnUpdatedCase); + } catch (error) { + const idVersions = cases.cases.map((caseInfo) => ({ + id: caseInfo.id, + version: caseInfo.version, + })); + + throw createCaseError({ + message: `Failed to update case, ids: ${JSON.stringify(idVersions)}: ${error}`, + error, + logger, + }); + } }; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index c684548decbe6f..c34c3942b18d0e 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'src/core/server'; import { CaseClientFactoryArguments, CaseClient, @@ -36,6 +36,7 @@ import { get } from './cases/get'; import { get as getUserActions } from './user_actions/get'; import { get as getAlerts } from './alerts/get'; import { push } from './cases/push'; +import { createCaseError } from '../common/error'; /** * This class is a pass through for common case functionality (like creating, get a case). @@ -49,6 +50,7 @@ export class CaseClientHandler implements CaseClient { private readonly _savedObjectsClient: SavedObjectsClientContract; private readonly _userActionService: CaseUserActionServiceSetup; private readonly _alertsService: AlertServiceContract; + private readonly logger: Logger; constructor(clientArgs: CaseClientFactoryArguments) { this._scopedClusterClient = clientArgs.scopedClusterClient; @@ -59,96 +61,190 @@ export class CaseClientHandler implements CaseClient { this._savedObjectsClient = clientArgs.savedObjectsClient; this._userActionService = clientArgs.userActionService; this._alertsService = clientArgs.alertsService; + this.logger = clientArgs.logger; } public async create(caseInfo: CasePostRequest) { - return create({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - caseConfigureService: this._caseConfigureService, - userActionService: this._userActionService, - user: this.user, - theCase: caseInfo, - }); + try { + return create({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + caseConfigureService: this._caseConfigureService, + userActionService: this._userActionService, + user: this.user, + theCase: caseInfo, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to create a new case using client: ${error}`, + error, + logger: this.logger, + }); + } } public async update(cases: CasesPatchRequest) { - return update({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - user: this.user, - cases, - caseClient: this, - }); + try { + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + cases, + caseClient: this, + logger: this.logger, + }); + } catch (error) { + const caseIDVersions = cases.cases.map((caseInfo) => ({ + id: caseInfo.id, + version: caseInfo.version, + })); + throw createCaseError({ + message: `Failed to update cases using client: ${JSON.stringify(caseIDVersions)}: ${error}`, + error, + logger: this.logger, + }); + } } public async addComment({ caseId, comment }: CaseClientAddComment) { - return addComment({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - caseClient: this, - caseId, - comment, - user: this.user, - }); + try { + return addComment({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + user: this.user, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to add comment using client case id: ${caseId}: ${error}`, + error, + logger: this.logger, + }); + } } public async getFields(fields: ConfigureFields) { - return getFields(fields); + try { + return getFields(fields); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve fields using client: ${error}`, + error, + logger: this.logger, + }); + } } public async getMappings(args: MappingsClient) { - return getMappings({ - ...args, - savedObjectsClient: this._savedObjectsClient, - connectorMappingsService: this._connectorMappingsService, - caseClient: this, - }); + try { + return getMappings({ + ...args, + savedObjectsClient: this._savedObjectsClient, + connectorMappingsService: this._connectorMappingsService, + caseClient: this, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get mappings using client: ${error}`, + error, + logger: this.logger, + }); + } } public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { - return updateAlertsStatus({ - ...args, - alertsService: this._alertsService, - scopedClusterClient: this._scopedClusterClient, - }); + try { + return updateAlertsStatus({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to update alerts status using client ids: ${JSON.stringify( + args.ids + )} \nindices: ${JSON.stringify([...args.indices])} \nstatus: ${args.status}: ${error}`, + error, + logger: this.logger, + }); + } } public async get(args: CaseClientGet) { - return get({ - ...args, - caseService: this._caseService, - savedObjectsClient: this._savedObjectsClient, - }); + try { + return get({ + ...args, + caseService: this._caseService, + savedObjectsClient: this._savedObjectsClient, + logger: this.logger, + }); + } catch (error) { + this.logger.error(`Failed to get case using client id: ${args.id}: ${error}`); + throw error; + } } public async getUserActions(args: CaseClientGetUserActions) { - return getUserActions({ - ...args, - savedObjectsClient: this._savedObjectsClient, - userActionService: this._userActionService, - }); + try { + return getUserActions({ + ...args, + savedObjectsClient: this._savedObjectsClient, + userActionService: this._userActionService, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get user actions using client id: ${args.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } public async getAlerts(args: CaseClientGetAlerts) { - return getAlerts({ - ...args, - alertsService: this._alertsService, - scopedClusterClient: this._scopedClusterClient, - }); + try { + return getAlerts({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get alerts using client ids: ${JSON.stringify( + args.ids + )} \nindices: ${JSON.stringify([...args.indices])}: ${error}`, + error, + logger: this.logger, + }); + } } public async push(args: CaseClientPush) { - return push({ - ...args, - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - user: this.user, - caseClient: this, - caseConfigureService: this._caseConfigureService, - }); + try { + return push({ + ...args, + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + caseClient: this, + caseConfigureService: this._caseConfigureService, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to push case using client id: ${args.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } } diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index c9b1e4fd132722..123ecec6abea33 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -7,6 +7,7 @@ import { omit } from 'lodash/fp'; import { CommentType } from '../../../common/api'; +import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, mockCaseComments, @@ -297,7 +298,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', // @ts-expect-error @@ -326,7 +327,7 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { const requestAttributes = omit(attribute, allRequestAttributes); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', // @ts-expect-error @@ -353,7 +354,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); ['alertId', 'index'].forEach((attribute) => { - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', comment: { @@ -387,7 +388,7 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { const requestAttributes = omit(attribute, allRequestAttributes); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', // @ts-expect-error @@ -414,7 +415,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); ['comment'].forEach((attribute) => { - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', comment: { @@ -437,14 +438,14 @@ describe('addComment', () => { }); test('it throws when the case does not exists', async () => { - expect.assertions(3); + expect.assertions(4); const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'not-exists', comment: { @@ -454,20 +455,22 @@ describe('addComment', () => { }) .catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(404); }); }); test('it throws when postNewCase throws', async () => { - expect.assertions(3); + expect.assertions(4); const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', comment: { @@ -477,13 +480,15 @@ describe('addComment', () => { }) .catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(400); }); }); test('it throws when the case is closed and the comment is of type alert', async () => { - expect.assertions(3); + expect.assertions(4); const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, @@ -491,7 +496,7 @@ describe('addComment', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-4', comment: { @@ -506,8 +511,10 @@ describe('addComment', () => { }) .catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(400); }); }); }); diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 4c1cc59a957509..d3d7047e71bd3c 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,7 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { SavedObject, SavedObjectsClientContract, Logger } from 'src/core/server'; import { decodeCommentRequest, getAlertIds, @@ -38,6 +38,7 @@ import { import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CommentableCase } from '../../common'; import { CaseClientHandler } from '..'; +import { createCaseError } from '../../common/error'; import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common/constants'; @@ -104,6 +105,7 @@ interface AddCommentFromRuleArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; + logger: Logger; } const addGeneratedAlerts = async ({ @@ -113,6 +115,7 @@ const addGeneratedAlerts = async ({ caseClient, caseId, comment, + logger, }: AddCommentFromRuleArgs): Promise => { const query = pipe( AlertCommentRequestRt.decode(comment), @@ -125,88 +128,104 @@ const addGeneratedAlerts = async ({ if (comment.type !== CommentType.generatedAlert) { throw Boom.internal('Attempting to add a non generated alert in the wrong context'); } - const createdDate = new Date().toISOString(); - const caseInfo = await caseService.getCase({ - client: savedObjectsClient, - id: caseId, - }); + try { + const createdDate = new Date().toISOString(); - if ( - query.type === CommentType.generatedAlert && - caseInfo.attributes.type !== CaseType.collection - ) { - throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); - } + const caseInfo = await caseService.getCase({ + client: savedObjectsClient, + id: caseId, + }); - const userDetails: User = { - username: caseInfo.attributes.created_by?.username, - full_name: caseInfo.attributes.created_by?.full_name, - email: caseInfo.attributes.created_by?.email, - }; + if ( + query.type === CommentType.generatedAlert && + caseInfo.attributes.type !== CaseType.collection + ) { + throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); + } - const subCase = await getSubCase({ - caseService, - savedObjectsClient, - caseId, - createdAt: createdDate, - userActionService, - user: userDetails, - }); + const userDetails: User = { + username: caseInfo.attributes.created_by?.username, + full_name: caseInfo.attributes.created_by?.full_name, + email: caseInfo.attributes.created_by?.email, + }; - const commentableCase = new CommentableCase({ - collection: caseInfo, - subCase, - soClient: savedObjectsClient, - service: caseService, - }); + const subCase = await getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt: createdDate, + userActionService, + user: userDetails, + }); - const { - comment: newComment, - commentableCase: updatedCase, - } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); - - if ( - (newComment.attributes.type === CommentType.alert || - newComment.attributes.type === CommentType.generatedAlert) && - caseInfo.attributes.settings.syncAlerts - ) { - const ids = getAlertIds(query); - await caseClient.updateAlertsStatus({ - ids, - status: subCase.attributes.status, - indices: new Set([ - ...(Array.isArray(newComment.attributes.index) - ? newComment.attributes.index - : [newComment.attributes.index]), - ]), + const commentableCase = new CommentableCase({ + logger, + collection: caseInfo, + subCase, + soClient: savedObjectsClient, + service: caseService, }); - } - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { ...userDetails }, - caseId: updatedCase.caseId, - subCaseId: updatedCase.subCaseId, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], - }); + const { + comment: newComment, + commentableCase: updatedCase, + } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); + + if ( + (newComment.attributes.type === CommentType.alert || + newComment.attributes.type === CommentType.generatedAlert) && + caseInfo.attributes.settings.syncAlerts + ) { + const ids = getAlertIds(query); + await caseClient.updateAlertsStatus({ + ids, + status: subCase.attributes.status, + indices: new Set([ + ...(Array.isArray(newComment.attributes.index) + ? newComment.attributes.index + : [newComment.attributes.index]), + ]), + }); + } + + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { ...userDetails }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }); - return updatedCase.encode(); + return updatedCase.encode(); + } catch (error) { + throw createCaseError({ + message: `Failed while adding a generated alert to case id: ${caseId} error: ${error}`, + error, + logger, + }); + } }; -async function getCombinedCase( - service: CaseServiceSetup, - client: SavedObjectsClientContract, - id: string -): Promise { +async function getCombinedCase({ + service, + client, + id, + logger, +}: { + service: CaseServiceSetup; + client: SavedObjectsClientContract; + id: string; + logger: Logger; +}): Promise { const [casePromise, subCasePromise] = await Promise.allSettled([ service.getCase({ client, @@ -225,6 +244,7 @@ async function getCombinedCase( id: subCasePromise.value.references[0].id, }); return new CommentableCase({ + logger, collection: caseValue, subCase: subCasePromise.value, service, @@ -238,7 +258,12 @@ async function getCombinedCase( if (casePromise.status === 'rejected') { throw casePromise.reason; } else { - return new CommentableCase({ collection: casePromise.value, service, soClient: client }); + return new CommentableCase({ + logger, + collection: casePromise.value, + service, + soClient: client, + }); } } @@ -250,6 +275,7 @@ interface AddCommentArgs { caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; user: User; + logger: Logger; } export const addComment = async ({ @@ -260,6 +286,7 @@ export const addComment = async ({ caseId, comment, user, + logger, }: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), @@ -274,56 +301,70 @@ export const addComment = async ({ savedObjectsClient, userActionService, caseService, + logger, }); } decodeCommentRequest(comment); - const createdDate = new Date().toISOString(); - - const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = user; - const userInfo: User = { - username, - full_name, - email, - }; - - const { comment: newComment, commentableCase: updatedCase } = await combinedCase.createComment({ - createdDate, - user: userInfo, - commentReq: query, - }); + try { + const createdDate = new Date().toISOString(); - if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { - const ids = getAlertIds(query); - await caseClient.updateAlertsStatus({ - ids, - status: updatedCase.status, - indices: new Set([ - ...(Array.isArray(newComment.attributes.index) - ? newComment.attributes.index - : [newComment.attributes.index]), - ]), + const combinedCase = await getCombinedCase({ + service: caseService, + client: savedObjectsClient, + id: caseId, + logger, }); - } - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: updatedCase.caseId, - subCaseId: updatedCase.subCaseId, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], - }); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const userInfo: User = { + username, + full_name, + email, + }; + + const { comment: newComment, commentableCase: updatedCase } = await combinedCase.createComment({ + createdDate, + user: userInfo, + commentReq: query, + }); + + if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { + const ids = getAlertIds(query); + await caseClient.updateAlertsStatus({ + ids, + status: updatedCase.status, + indices: new Set([ + ...(Array.isArray(newComment.attributes.index) + ? newComment.attributes.index + : [newComment.attributes.index]), + ]), + }); + } + + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }); - return updatedCase.encode(); + return updatedCase.encode(); + } catch (error) { + throw createCaseError({ + message: `Failed while adding a comment to case id: ${caseId} error: ${error}`, + error, + logger, + }); + } }; diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.ts b/x-pack/plugins/case/server/client/configure/get_mappings.ts index 5dd90efd8a2d74..5553580a41560f 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract, Logger } from 'src/core/server'; import { ActionsClient } from '../../../../actions/server'; import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; import { ConnectorMappingsServiceSetup } from '../../services'; import { CaseClientHandler } from '..'; +import { createCaseError } from '../../common/error'; interface GetMappingsArgs { savedObjectsClient: SavedObjectsClientContract; @@ -20,6 +21,7 @@ interface GetMappingsArgs { caseClient: CaseClientHandler; connectorType: string; connectorId: string; + logger: Logger; } export const getMappings = async ({ @@ -29,42 +31,51 @@ export const getMappings = async ({ caseClient, connectorType, connectorId, + logger, }: GetMappingsArgs): Promise => { - if (connectorType === ConnectorTypes.none) { - return []; - } - const myConnectorMappings = await connectorMappingsService.find({ - client: savedObjectsClient, - options: { - hasReference: { - type: ACTION_SAVED_OBJECT_TYPE, - id: connectorId, - }, - }, - }); - let theMapping; - // Create connector mappings if there are none - if (myConnectorMappings.total === 0) { - const res = await caseClient.getFields({ - actionsClient, - connectorId, - connectorType, - }); - theMapping = await connectorMappingsService.post({ + try { + if (connectorType === ConnectorTypes.none) { + return []; + } + const myConnectorMappings = await connectorMappingsService.find({ client: savedObjectsClient, - attributes: { - mappings: res.defaultMappings, - }, - references: [ - { + options: { + hasReference: { type: ACTION_SAVED_OBJECT_TYPE, - name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, id: connectorId, }, - ], + }, + }); + let theMapping; + // Create connector mappings if there are none + if (myConnectorMappings.total === 0) { + const res = await caseClient.getFields({ + actionsClient, + connectorId, + connectorType, + }); + theMapping = await connectorMappingsService.post({ + client: savedObjectsClient, + attributes: { + mappings: res.defaultMappings, + }, + references: [ + { + type: ACTION_SAVED_OBJECT_TYPE, + name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, + id: connectorId, + }, + ], + }); + } else { + theMapping = myConnectorMappings.saved_objects[0]; + } + return theMapping ? theMapping.attributes.mappings : []; + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve mapping connector id: ${connectorId} type: ${connectorType}: ${error}`, + error, + logger, }); - } else { - theMapping = myConnectorMappings.saved_objects[0]; } - return theMapping ? theMapping.attributes.mappings : []; }; diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 8a085bf29f2147..6f4b4b136f68fb 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -7,6 +7,7 @@ import { elasticsearchServiceMock, + loggingSystemMock, savedObjectsClientMock, } from '../../../../../src/core/server/mocks'; import { nullUser } from '../common'; @@ -22,6 +23,7 @@ jest.mock('./client'); import { CaseClientHandler } from './client'; import { createExternalCaseClient } from './index'; +const logger = loggingSystemMock.create().get('case'); const esClient = elasticsearchServiceMock.createElasticsearchClient(); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); @@ -41,6 +43,7 @@ describe('createExternalCaseClient()', () => { user: nullUser, savedObjectsClient, userActionService, + logger, }); expect(CaseClientHandler).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 302745913babbd..98ffed0eaf8c5e 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -47,7 +47,6 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ }; }> => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); - // const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); @@ -78,6 +77,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ userActionService, alertsService, scopedClusterClient: esClient, + logger: log, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index d6a8f6b5d706cd..adc66d8b1ea77b 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, @@ -76,6 +76,7 @@ export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; + logger: Logger; } export interface ConfigureFields { diff --git a/x-pack/plugins/case/server/common/error.ts b/x-pack/plugins/case/server/common/error.ts new file mode 100644 index 00000000000000..95b05fd612e602 --- /dev/null +++ b/x-pack/plugins/case/server/common/error.ts @@ -0,0 +1,73 @@ +/* + * 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 { Boom, isBoom } from '@hapi/boom'; +import { Logger } from 'src/core/server'; + +/** + * Helper class for wrapping errors while preserving the original thrown error. + */ +class CaseError extends Error { + public readonly wrappedError?: Error; + constructor(message?: string, originalError?: Error) { + super(message); + this.name = this.constructor.name; // for stack traces + if (isCaseError(originalError)) { + this.wrappedError = originalError.wrappedError; + } else { + this.wrappedError = originalError; + } + } + + /** + * This function creates a boom representation of the error. If the wrapped error is a boom we'll grab the statusCode + * and data from that. + */ + public boomify(): Boom { + const message = this.message ?? this.wrappedError?.message; + let statusCode = 500; + let data: unknown | undefined; + + if (isBoom(this.wrappedError)) { + data = this.wrappedError?.data; + statusCode = this.wrappedError?.output?.statusCode ?? 500; + } + + return new Boom(message, { + data, + statusCode, + }); + } +} + +/** + * Type guard for determining if an error is a CaseError + */ +export function isCaseError(error: unknown): error is CaseError { + return error instanceof CaseError; +} + +/** + * Create a CaseError that wraps the original thrown error. This also logs the message that will be placed in the CaseError + * if the logger was defined. + */ +export function createCaseError({ + message, + error, + logger, +}: { + message?: string; + error?: Error; + logger?: Logger; +}) { + const logMessage: string | undefined = message ?? error?.toString(); + if (logMessage !== undefined) { + logger?.error(logMessage); + } + + return new CaseError(message, error); +} diff --git a/x-pack/plugins/case/server/common/models/commentable_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts index 3ae225999db4ee..1ff5b7beadcaf1 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -11,6 +11,7 @@ import { SavedObjectReference, SavedObjectsClientContract, SavedObjectsUpdateResponse, + Logger, } from 'src/core/server'; import { AssociationType, @@ -35,6 +36,7 @@ import { } from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; +import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; interface UpdateCommentResp { @@ -52,6 +54,7 @@ interface CommentableCaseParams { subCase?: SavedObject; soClient: SavedObjectsClientContract; service: CaseServiceSetup; + logger: Logger; } /** @@ -63,11 +66,14 @@ export class CommentableCase { private readonly subCase?: SavedObject; private readonly soClient: SavedObjectsClientContract; private readonly service: CaseServiceSetup; - constructor({ collection, subCase, soClient, service }: CommentableCaseParams) { + private readonly logger: Logger; + + constructor({ collection, subCase, soClient, service, logger }: CommentableCaseParams) { this.collection = collection; this.subCase = subCase; this.soClient = soClient; this.service = service; + this.logger = logger; } public get status(): CaseStatuses { @@ -119,55 +125,64 @@ export class CommentableCase { } private async update({ date, user }: { date: string; user: User }): Promise { - let updatedSubCaseAttributes: SavedObject | undefined; + try { + let updatedSubCaseAttributes: SavedObject | undefined; + + if (this.subCase) { + const updatedSubCase = await this.service.patchSubCase({ + client: this.soClient, + subCaseId: this.subCase.id, + updatedAttributes: { + updated_at: date, + updated_by: { + ...user, + }, + }, + version: this.subCase.version, + }); + + updatedSubCaseAttributes = { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updatedSubCase.attributes, + }, + version: updatedSubCase.version ?? this.subCase.version, + }; + } - if (this.subCase) { - const updatedSubCase = await this.service.patchSubCase({ + const updatedCase = await this.service.patchCase({ client: this.soClient, - subCaseId: this.subCase.id, + caseId: this.collection.id, updatedAttributes: { updated_at: date, - updated_by: { - ...user, - }, + updated_by: { ...user }, }, - version: this.subCase.version, + version: this.collection.version, }); - updatedSubCaseAttributes = { - ...this.subCase, - attributes: { - ...this.subCase.attributes, - ...updatedSubCase.attributes, + // this will contain the updated sub case information if the sub case was defined initially + return new CommentableCase({ + collection: { + ...this.collection, + attributes: { + ...this.collection.attributes, + ...updatedCase.attributes, + }, + version: updatedCase.version ?? this.collection.version, }, - version: updatedSubCase.version ?? this.subCase.version, - }; + subCase: updatedSubCaseAttributes, + soClient: this.soClient, + service: this.service, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to update commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, + }); } - - const updatedCase = await this.service.patchCase({ - client: this.soClient, - caseId: this.collection.id, - updatedAttributes: { - updated_at: date, - updated_by: { ...user }, - }, - version: this.collection.version, - }); - - // this will contain the updated sub case information if the sub case was defined initially - return new CommentableCase({ - collection: { - ...this.collection, - attributes: { - ...this.collection.attributes, - ...updatedCase.attributes, - }, - version: updatedCase.version ?? this.collection.version, - }, - subCase: updatedSubCaseAttributes, - soClient: this.soClient, - service: this.service, - }); } /** @@ -182,25 +197,33 @@ export class CommentableCase { updatedAt: string; user: User; }): Promise { - const { id, version, ...queryRestAttributes } = updateRequest; + try { + const { id, version, ...queryRestAttributes } = updateRequest; - const [comment, commentableCase] = await Promise.all([ - this.service.patchComment({ - client: this.soClient, - commentId: id, - updatedAttributes: { - ...queryRestAttributes, - updated_at: updatedAt, - updated_by: user, - }, - version, - }), - this.update({ date: updatedAt, user }), - ]); - return { - comment, - commentableCase, - }; + const [comment, commentableCase] = await Promise.all([ + this.service.patchComment({ + client: this.soClient, + commentId: id, + updatedAttributes: { + ...queryRestAttributes, + updated_at: updatedAt, + updated_by: user, + }, + version, + }), + this.update({ date: updatedAt, user }), + ]); + return { + comment, + commentableCase, + }; + } catch (error) { + throw createCaseError({ + message: `Failed to update comment in commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } /** @@ -215,33 +238,41 @@ export class CommentableCase { user: User; commentReq: CommentRequest; }): Promise { - if (commentReq.type === CommentType.alert) { - if (this.status === CaseStatuses.closed) { - throw Boom.badRequest('Alert cannot be attached to a closed case'); - } + try { + if (commentReq.type === CommentType.alert) { + if (this.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } - if (!this.subCase && this.collection.attributes.type === CaseType.collection) { - throw Boom.badRequest('Alert cannot be attached to a collection case'); + if (!this.subCase && this.collection.attributes.type === CaseType.collection) { + throw Boom.badRequest('Alert cannot be attached to a collection case'); + } } - } - const [comment, commentableCase] = await Promise.all([ - this.service.postNewComment({ - client: this.soClient, - attributes: transformNewComment({ - associationType: this.subCase ? AssociationType.subCase : AssociationType.case, - createdDate, - ...commentReq, - ...user, + const [comment, commentableCase] = await Promise.all([ + this.service.postNewComment({ + client: this.soClient, + attributes: transformNewComment({ + associationType: this.subCase ? AssociationType.subCase : AssociationType.case, + createdDate, + ...commentReq, + ...user, + }), + references: this.buildRefsToCase(), }), - references: this.buildRefsToCase(), - }), - this.update({ date: createdDate, user }), - ]); - return { - comment, - commentableCase, - }; + this.update({ date: createdDate, user }), + ]); + return { + comment, + commentableCase, + }; + } catch (error) { + throw createCaseError({ + message: `Failed creating a comment on a commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } private formatCollectionForEncoding(totalComment: number) { @@ -255,65 +286,74 @@ export class CommentableCase { } public async encode(): Promise { - const collectionCommentStats = await this.service.getAllCaseComments({ - client: this.soClient, - id: this.collection.id, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }); + try { + const collectionCommentStats = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }); - const collectionComments = await this.service.getAllCaseComments({ - client: this.soClient, - id: this.collection.id, - options: { - fields: [], - page: 1, - perPage: collectionCommentStats.total, - }, - }); + const collectionComments = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: collectionCommentStats.total, + }, + }); - const collectionTotalAlerts = - countAlertsForID({ comments: collectionComments, id: this.collection.id }) ?? 0; + const collectionTotalAlerts = + countAlertsForID({ comments: collectionComments, id: this.collection.id }) ?? 0; - const caseResponse = { - comments: flattenCommentSavedObjects(collectionComments.saved_objects), - totalAlerts: collectionTotalAlerts, - ...this.formatCollectionForEncoding(collectionCommentStats.total), - }; + const caseResponse = { + comments: flattenCommentSavedObjects(collectionComments.saved_objects), + totalAlerts: collectionTotalAlerts, + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }; - if (this.subCase) { - const subCaseComments = await this.service.getAllSubCaseComments({ - client: this.soClient, - id: this.subCase.id, - }); - const totalAlerts = countAlertsForID({ comments: subCaseComments, id: this.subCase.id }) ?? 0; + if (this.subCase) { + const subCaseComments = await this.service.getAllSubCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + const totalAlerts = + countAlertsForID({ comments: subCaseComments, id: this.subCase.id }) ?? 0; - return CaseResponseRt.encode({ - ...caseResponse, - /** - * For now we need the sub case comments and totals to be exposed on the top level of the response so that the UI - * functionality can stay the same. Ideally in the future we can refactor this so that the UI will look for the - * comments either in the top level for a case or a collection or in the subCases field if it is a sub case. - * - * If we ever need to return both the collection's comments and the sub case comments we'll need to refactor it then - * as well. - */ - comments: flattenCommentSavedObjects(subCaseComments.saved_objects), - totalComment: subCaseComments.saved_objects.length, - totalAlerts, - subCases: [ - flattenSubCaseSavedObject({ - savedObject: this.subCase, - totalComment: subCaseComments.saved_objects.length, - totalAlerts, - }), - ], + return CaseResponseRt.encode({ + ...caseResponse, + /** + * For now we need the sub case comments and totals to be exposed on the top level of the response so that the UI + * functionality can stay the same. Ideally in the future we can refactor this so that the UI will look for the + * comments either in the top level for a case or a collection or in the subCases field if it is a sub case. + * + * If we ever need to return both the collection's comments and the sub case comments we'll need to refactor it then + * as well. + */ + comments: flattenCommentSavedObjects(subCaseComments.saved_objects), + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + subCases: [ + flattenSubCaseSavedObject({ + savedObject: this.subCase, + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + }), + ], + }); + } + + return CaseResponseRt.encode(caseResponse); + } catch (error) { + throw createCaseError({ + message: `Failed encoding the commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, }); } - - return CaseResponseRt.encode(caseResponse); } } diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index fd2fc7389f9ca9..4a1d0569bde6df 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -6,7 +6,7 @@ */ import { curry } from 'lodash'; - +import { Logger } from 'src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, @@ -26,6 +26,7 @@ import * as i18n from './translations'; import { GetActionTypeParams, isCommentGeneratedAlert, separator } from '..'; import { nullUser } from '../../common'; +import { createCaseError } from '../../common/error'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -84,6 +85,7 @@ async function executor( connectorMappingsService, userActionService, alertsService, + logger, }); if (!supportedSubActions.includes(subAction)) { @@ -93,9 +95,17 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create({ - ...(subActionParams as CasePostRequest), - }); + try { + data = await caseClient.create({ + ...(subActionParams as CasePostRequest), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to create a case using connector: ${error}`, + error, + logger, + }); + } } if (subAction === 'update') { @@ -107,13 +117,29 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); + try { + data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); + } catch (error) { + throw createCaseError({ + message: `Failed to update case using connector id: ${updateParamsWithoutNullValues?.id} version: ${updateParamsWithoutNullValues?.version}: ${error}`, + error, + logger, + }); + } } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - const formattedComment = transformConnectorComment(comment); - data = await caseClient.addComment({ caseId, comment: formattedComment }); + try { + const formattedComment = transformConnectorComment(comment, logger); + data = await caseClient.addComment({ caseId, comment: formattedComment }); + } catch (error) { + throw createCaseError({ + message: `Failed to create comment using connector case id: ${caseId}: ${error}`, + error, + logger, + }); + } } return { status: 'ok', data: data ?? {}, actionId }; @@ -127,7 +153,19 @@ interface AttachmentAlerts { indices: string[]; rule: { id: string | null; name: string | null }; } -export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => { + +/** + * Convert a connector style comment passed through the action plugin to the expected format for the add comment functionality. + * + * @param comment an object defining the comment to be attached to a case/sub case + * @param logger an optional logger to handle logging an error if parsing failed + * + * Note: This is exported so that the integration tests can use it. + */ +export const transformConnectorComment = ( + comment: CommentSchemaType, + logger?: Logger +): CommentRequest => { if (isCommentGeneratedAlert(comment)) { try { const genAlerts: Array<{ @@ -162,7 +200,11 @@ export const transformConnectorComment = (comment: CommentSchemaType): CommentRe rule, }; } catch (e) { - throw new Error(`Error parsing generated alert in case connector -> ${e.message}`); + throw createCaseError({ + message: `Error parsing generated alert in case connector -> ${e}`, + error: e, + logger, + }); } } else { return comment; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 1c00c26a7c0b09..43daa519584297 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -97,11 +97,13 @@ export class CasePlugin { connectorMappingsService: this.connectorMappingsService, userActionService: this.userActionService, alertsService: this.alertsService, + logger: this.log, }) ); const router = core.http.createRouter(); initCaseApi({ + logger: this.log, caseService: this.caseService, caseConfigureService: this.caseConfigureService, connectorMappingsService: this.connectorMappingsService, @@ -137,6 +139,7 @@ export class CasePlugin { connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, + logger: this.log, }); }; @@ -156,6 +159,7 @@ export class CasePlugin { connectorMappingsService, userActionService, alertsService, + logger, }: { core: CoreSetup; caseService: CaseServiceSetup; @@ -163,6 +167,7 @@ export class CasePlugin { connectorMappingsService: ConnectorMappingsServiceSetup; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; + logger: Logger; }): IContextProvider => { return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); @@ -178,6 +183,7 @@ export class CasePlugin { userActionService, alertsService, user, + logger, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index b4230a05749a15..6b0c4adf9a6804 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -35,6 +35,7 @@ export const createRoute = async ( postUserActions: jest.fn(), getUserActions: jest.fn(), }, + logger: log, }); return router[method].mock.calls[0][1]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 492be96fb4aa92..7f66602c61fffc 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -57,6 +57,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { userActionService, alertsService, scopedClusterClient: esClient, + logger: log, }); return { context, services: { userActionService } }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index e0b3a4420f4b5d..fd250b74fff1e3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -13,7 +13,12 @@ import { wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { AssociationType } from '../../../../../common/api'; -export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteAllCommentsApi({ + caseService, + router, + userActionService, + logger, +}: RouteDeps) { router.delete( { path: CASE_COMMENTS_URL, @@ -70,6 +75,9 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic return response.noContent(); } catch (error) { + logger.error( + `Failed to delete all comments in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index cae0809ea5f0bf..f1c5fdc2b7cc82 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -14,7 +14,12 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; -export function initDeleteCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteCommentApi({ + caseService, + router, + userActionService, + logger, +}: RouteDeps) { router.delete( { path: CASE_COMMENT_DETAILS_URL, @@ -78,6 +83,9 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: return response.noContent(); } catch (error) { + logger.error( + `Failed to delete comment in route case id: ${request.params.case_id} comment id: ${request.params.comment_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index 0ec0f1871c7adf..57ddd84e8742c5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -30,7 +30,7 @@ const FindQueryParamsRt = rt.partial({ subCaseId: rt.string, }); -export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { +export function initFindCaseCommentsApi({ caseService, router, logger }: RouteDeps) { router.get( { path: `${CASE_COMMENTS_URL}/_find`, @@ -82,6 +82,9 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { const theComments = await caseService.getCommentsByAssociation(args); return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); } catch (error) { + logger.error( + `Failed to find comments in route case id: ${request.params.case_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 8bf49ec3e27a12..770efe0109744c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -14,7 +14,7 @@ import { flattenCommentSavedObjects, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { defaultSortField } from '../../../../common'; -export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { +export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_COMMENTS_URL, @@ -58,6 +58,9 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); } catch (error) { + logger.error( + `Failed to get all comments in route case id: ${request.params.case_id} include sub case comments: ${request.query?.includeSubCaseComments} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts index 6484472af0c3c1..9dedfccd3a250e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -12,7 +12,7 @@ import { RouteDeps } from '../../types'; import { flattenCommentSavedObject, wrapError } from '../../utils'; import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; -export function initGetCommentApi({ caseService, router }: RouteDeps) { +export function initGetCommentApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_COMMENT_DETAILS_URL, @@ -35,6 +35,9 @@ export function initGetCommentApi({ caseService, router }: RouteDeps) { body: CommentResponseRt.encode(flattenCommentSavedObject(comment)), }); } catch (error) { + logger.error( + `Failed to get comment in route case id: ${request.params.case_id} comment id: ${request.params.comment_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 01b0e174640537..f5db2dc004a1d7 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -12,7 +12,7 @@ import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { CommentableCase } from '../../../../common'; import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; @@ -26,10 +26,17 @@ interface CombinedCaseParams { service: CaseServiceSetup; client: SavedObjectsClientContract; caseID: string; + logger: Logger; subCaseId?: string; } -async function getCommentableCase({ service, client, caseID, subCaseId }: CombinedCaseParams) { +async function getCommentableCase({ + service, + client, + caseID, + subCaseId, + logger, +}: CombinedCaseParams) { if (subCaseId) { const [caseInfo, subCase] = await Promise.all([ service.getCase({ @@ -41,22 +48,23 @@ async function getCommentableCase({ service, client, caseID, subCaseId }: Combin id: subCaseId, }), ]); - return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); + return new CommentableCase({ + collection: caseInfo, + service, + subCase, + soClient: client, + logger, + }); } else { const caseInfo = await service.getCase({ client, id: caseID, }); - return new CommentableCase({ collection: caseInfo, service, soClient: client }); + return new CommentableCase({ collection: caseInfo, service, soClient: client, logger }); } } -export function initPatchCommentApi({ - caseConfigureService, - caseService, - router, - userActionService, -}: RouteDeps) { +export function initPatchCommentApi({ caseService, router, userActionService, logger }: RouteDeps) { router.patch( { path: CASE_COMMENTS_URL, @@ -88,6 +96,7 @@ export function initPatchCommentApi({ client, caseID: request.params.case_id, subCaseId: request.query?.subCaseId, + logger, }); const myComment = await caseService.getComment({ @@ -161,6 +170,9 @@ export function initPatchCommentApi({ body: await updatedCase.encode(), }); } catch (error) { + logger.error( + `Failed to patch comment in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 607f3f381f0675..b8dc43dbf3fae6 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../../types'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { CommentRequest } from '../../../../../common/api'; -export function initPostCommentApi({ router }: RouteDeps) { +export function initPostCommentApi({ router, logger }: RouteDeps) { router.post( { path: CASE_COMMENTS_URL, @@ -41,6 +41,9 @@ export function initPostCommentApi({ router }: RouteDeps) { body: await caseClient.addComment({ caseId, comment }), }); } catch (error) { + logger.error( + `Failed to post comment in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 33226d39a25957..2ca34d25482dd4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -12,7 +12,7 @@ import { wrapError } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; import { transformESConnectorToCaseConnector } from '../helpers'; -export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps) { +export function initGetCaseConfigure({ caseConfigureService, router, logger }: RouteDeps) { router.get( { path: CASE_CONFIGURE_URL, @@ -63,6 +63,7 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps : {}, }); } catch (error) { + logger.error(`Failed to get case configure in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index 0a368e0276bb5c..81ffc06355ff5e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -28,7 +28,7 @@ const isConnectorSupported = ( * Be aware that this api will only return 20 connectors */ -export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { +export function initCaseConfigureGetActionConnector({ router, logger }: RouteDeps) { router.get( { path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, @@ -52,6 +52,7 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { ); return response.ok({ body: results }); } catch (error) { + logger.error(`Failed to get connectors in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 02d39465373f98..cd764bb0e8a3e5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -24,7 +24,12 @@ import { transformESConnectorToCaseConnector, } from '../helpers'; -export function initPatchCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { +export function initPatchCaseConfigure({ + caseConfigureService, + caseService, + router, + logger, +}: RouteDeps) { router.patch( { path: CASE_CONFIGURE_URL, @@ -107,6 +112,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout }), }); } catch (error) { + logger.error(`Failed to get patch configure in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index db3d5cd6a2e56e..f619a727e2e7aa 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -24,7 +24,12 @@ import { transformESConnectorToCaseConnector, } from '../helpers'; -export function initPostCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { +export function initPostCaseConfigure({ + caseConfigureService, + caseService, + router, + logger, +}: RouteDeps) { router.post( { path: CASE_CONFIGURE_URL, @@ -96,6 +101,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route }), }); } catch (error) { + logger.error(`Failed to post case configure in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index 497e33d7feb308..5f2a6c67220c3f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -46,7 +46,7 @@ async function deleteSubCases({ ); } -export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteCasesApi({ caseService, router, userActionService, logger }: RouteDeps) { router.delete( { path: CASES_URL, @@ -111,6 +111,9 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R return response.noContent(); } catch (error) { + logger.error( + `Failed to delete cases in route ids: ${JSON.stringify(request.query.ids)}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 8ba83b42c06d70..d04f01eb735379 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -22,7 +22,7 @@ import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; import { constructQueryOptions } from './helpers'; -export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { +export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { router.get( { path: `${CASES_URL}/_find`, @@ -77,6 +77,7 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: ), }); } catch (error) { + logger.error(`Failed to find cases in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index a3311796fa5cd9..8a34e3a5b24316 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../types'; import { wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; -export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { +export function initGetCaseApi({ router, logger }: RouteDeps) { router.get( { path: CASE_DETAILS_URL, @@ -26,10 +26,10 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro }, }, async (context, request, response) => { - const caseClient = context.case.getCaseClient(); - const id = request.params.case_id; - try { + const caseClient = context.case.getCaseClient(); + const id = request.params.case_id; + return response.ok({ body: await caseClient.get({ id, @@ -38,6 +38,9 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro }), }); } catch (error) { + logger.error( + `Failed to retrieve case in route case id: ${request.params.case_id} \ninclude comments: ${request.query.includeComments} \ninclude sub comments: ${request.query.includeSubCaseComments}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 67d4d21a576340..2bff6000d5d6a9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -10,7 +10,7 @@ import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; import { CasesPatchRequest } from '../../../../common/api'; -export function initPatchCasesApi({ router }: RouteDeps) { +export function initPatchCasesApi({ router, logger }: RouteDeps) { router.patch( { path: CASES_URL, @@ -19,18 +19,19 @@ export function initPatchCasesApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const cases = request.body as CasesPatchRequest; + const caseClient = context.case.getCaseClient(); + const cases = request.body as CasesPatchRequest; - try { return response.ok({ body: await caseClient.update(cases), }); } catch (error) { + logger.error(`Failed to patch cases in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 349ed6c3e5af93..1328d958261302 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; import { CasePostRequest } from '../../../../common/api'; -export function initPostCaseApi({ router }: RouteDeps) { +export function initPostCaseApi({ router, logger }: RouteDeps) { router.post( { path: CASES_URL, @@ -20,17 +20,18 @@ export function initPostCaseApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } - const caseClient = context.case.getCaseClient(); - const theCase = request.body as CasePostRequest; - try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + const caseClient = context.case.getCaseClient(); + const theCase = request.body as CasePostRequest; + return response.ok({ body: await caseClient.create({ ...theCase }), }); } catch (error) { + logger.error(`Failed to post case in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index c1f0a2cb59cb1d..cfd2f6b9a61ad5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -16,7 +16,7 @@ import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; import { RouteDeps } from '../types'; import { CASE_PUSH_URL } from '../../../../common/constants'; -export function initPushCaseApi({ router }: RouteDeps) { +export function initPushCaseApi({ router, logger }: RouteDeps) { router.post( { path: CASE_PUSH_URL, @@ -26,18 +26,18 @@ export function initPushCaseApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const actionsClient = context.actions?.getActionsClient(); + const caseClient = context.case.getCaseClient(); + const actionsClient = context.actions?.getActionsClient(); - if (actionsClient == null) { - return response.badRequest({ body: 'Action client not found' }); - } + if (actionsClient == null) { + return response.badRequest({ body: 'Action client not found' }); + } - try { const params = pipe( CasePushRequestParamsRt.decode(request.params), fold(throwErrors(Boom.badRequest), identity) @@ -51,6 +51,7 @@ export function initPushCaseApi({ router }: RouteDeps) { }), }); } catch (error) { + logger.error(`Failed to push case in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts index 3d724ca5fc966f..e5433f49722395 100644 --- a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts @@ -10,7 +10,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_REPORTERS_URL } from '../../../../../common/constants'; -export function initGetReportersApi({ caseService, router }: RouteDeps) { +export function initGetReportersApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_REPORTERS_URL, @@ -24,6 +24,7 @@ export function initGetReportersApi({ caseService, router }: RouteDeps) { }); return response.ok({ body: UsersRt.encode(reporters) }); } catch (error) { + logger.error(`Failed to get reporters in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index f3cd0e2bdda5c2..d0addfff091243 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -12,7 +12,7 @@ import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; import { CASE_STATUS_URL } from '../../../../../common/constants'; import { constructQueryOptions } from '../helpers'; -export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { +export function initGetCasesStatusApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_STATUS_URL, @@ -41,6 +41,7 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }), }); } catch (error) { + logger.error(`Failed to get status stats in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts index db701dd0fc82b5..fd33afbd7df8ee 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -13,7 +13,12 @@ import { wrapError } from '../../utils'; import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; -export function initDeleteSubCasesApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteSubCasesApi({ + caseService, + router, + userActionService, + logger, +}: RouteDeps) { router.delete( { path: SUB_CASES_PATCH_DEL_URL, @@ -80,6 +85,9 @@ export function initDeleteSubCasesApi({ caseService, router, userActionService } return response.noContent(); } catch (error) { + logger.error( + `Failed to delete sub cases in route ids: ${JSON.stringify(request.query.ids)}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts index 98052ccaeaba8e..c24dde1944f832 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -24,7 +24,7 @@ import { SUB_CASES_URL } from '../../../../../common/constants'; import { constructQueryOptions } from '../helpers'; import { defaultPage, defaultPerPage } from '../..'; -export function initFindSubCasesApi({ caseService, router }: RouteDeps) { +export function initFindSubCasesApi({ caseService, router, logger }: RouteDeps) { router.get( { path: `${SUB_CASES_URL}/_find`, @@ -88,6 +88,9 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { ), }); } catch (error) { + logger.error( + `Failed to find sub cases in route case id: ${request.params.case_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts index b6d9a7345dbdd1..32dcc924e1a083 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts @@ -13,7 +13,7 @@ import { flattenSubCaseSavedObject, wrapError } from '../../utils'; import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; import { countAlertsForID } from '../../../../common'; -export function initGetSubCaseApi({ caseService, router }: RouteDeps) { +export function initGetSubCaseApi({ caseService, router, logger }: RouteDeps) { router.get( { path: SUB_CASE_DETAILS_URL, @@ -70,6 +70,9 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { ), }); } catch (error) { + logger.error( + `Failed to get sub case in route case id: ${request.params.case_id} sub case id: ${request.params.sub_case_id} include comments: ${request.query?.includeComments}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts index 4b8e4920852c27..73aacc2c2b0ba4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -14,6 +14,7 @@ import { KibanaRequest, SavedObject, SavedObjectsFindResponse, + Logger, } from 'kibana/server'; import { CaseClient } from '../../../../client'; @@ -47,6 +48,7 @@ import { import { getCaseToUpdate } from '../helpers'; import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; import { addAlertInfoToStatusMap } from '../../../../common'; +import { createCaseError } from '../../../../common/error'; interface UpdateArgs { client: SavedObjectsClientContract; @@ -55,6 +57,7 @@ interface UpdateArgs { request: KibanaRequest; caseClient: CaseClient; subCases: SubCasesPatchRequest; + logger: Logger; } function checkNonExistingOrConflict( @@ -216,41 +219,53 @@ async function updateAlerts({ caseService, client, caseClient, + logger, }: { subCasesToSync: SubCasePatchRequest[]; caseService: CaseServiceSetup; client: SavedObjectsClientContract; caseClient: CaseClient; + logger: Logger; }) { - const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { - acc.set(subCase.id, subCase); - return acc; - }, new Map()); - // get all the alerts for all sub cases that need to be synced - const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); - // create a map of the status (open, closed, etc) to alert info that needs to be updated - const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { - if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { - const id = getID(alertComment); - const status = - id !== undefined - ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open - : CaseStatuses.open; - - addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); - } - return acc; - }, new Map()); - - // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress - for (const [status, alertInfo] of alertsToUpdate.entries()) { - if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { - caseClient.updateAlertsStatus({ - ids: alertInfo.ids, - status, - indices: alertInfo.indices, - }); + try { + const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { + acc.set(subCase.id, subCase); + return acc; + }, new Map()); + // get all the alerts for all sub cases that need to be synced + const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); + // create a map of the status (open, closed, etc) to alert info that needs to be updated + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open + : CaseStatuses.open; + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } } + } catch (error) { + throw createCaseError({ + message: `Failed to update alert status while updating sub cases: ${JSON.stringify( + subCasesToSync + )}: ${error}`, + logger, + error, + }); } } @@ -261,133 +276,152 @@ async function update({ request, caseClient, subCases, + logger, }: UpdateArgs): Promise { const query = pipe( excess(SubCasesPatchRequestRt).decode(subCases), fold(throwErrors(Boom.badRequest), identity) ); - const bulkSubCases = await caseService.getSubCases({ - client, - ids: query.subCases.map((q) => q.id), - }); + try { + const bulkSubCases = await caseService.getSubCases({ + client, + ids: query.subCases.map((q) => q.id), + }); - const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { - acc.set(so.id, so); - return acc; - }, new Map>()); + const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); - checkNonExistingOrConflict(query.subCases, subCasesMap); + checkNonExistingOrConflict(query.subCases, subCasesMap); - const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); + const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); - if (nonEmptySubCaseRequests.length <= 0) { - throw Boom.notAcceptable('All update fields are identical to current version.'); - } + if (nonEmptySubCaseRequests.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } - const subIDToParentCase = await getParentCases({ - client, - caseService, - subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), - subCasesMap, - }); + const subIDToParentCase = await getParentCases({ + client, + caseService, + subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), + subCasesMap, + }); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const updatedAt = new Date().toISOString(); - const updatedCases = await caseService.patchSubCases({ - client, - subCases: nonEmptySubCaseRequests.map((thisCase) => { - const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; - let closedInfo: { closed_at: string | null; closed_by: User | null } = { - closed_at: null, - closed_by: null, - }; - - if ( - updateSubCaseAttributes.status && - updateSubCaseAttributes.status === CaseStatuses.closed - ) { - closedInfo = { - closed_at: updatedAt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateSubCaseAttributes.status && - (updateSubCaseAttributes.status === CaseStatuses.open || - updateSubCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const updatedAt = new Date().toISOString(); + const updatedCases = await caseService.patchSubCases({ + client, + subCases: nonEmptySubCaseRequests.map((thisCase) => { + const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; + let closedInfo: { closed_at: string | null; closed_by: User | null } = { closed_at: null, closed_by: null, }; - } - return { - subCaseId, - updatedAttributes: { - ...updateSubCaseAttributes, - ...closedInfo, - updated_at: updatedAt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); - const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { - const storedSubCase = subCasesMap.get(subCaseToUpdate.id); - const parentCase = subIDToParentCase.get(subCaseToUpdate.id); - return ( - storedSubCase !== undefined && - subCaseToUpdate.status !== undefined && - storedSubCase.attributes.status !== subCaseToUpdate.status && - parentCase?.attributes.settings.syncAlerts - ); - }); + if ( + updateSubCaseAttributes.status && + updateSubCaseAttributes.status === CaseStatuses.closed + ) { + closedInfo = { + closed_at: updatedAt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateSubCaseAttributes.status && + (updateSubCaseAttributes.status === CaseStatuses.open || + updateSubCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + subCaseId, + updatedAttributes: { + ...updateSubCaseAttributes, + ...closedInfo, + updated_at: updatedAt, + updated_by: { email, full_name, username }, + }, + version, + }; + }), + }); - await updateAlerts({ - caseService, - client, - caseClient, - subCasesToSync: subCasesToSyncAlertsFor, - }); + const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { + const storedSubCase = subCasesMap.get(subCaseToUpdate.id); + const parentCase = subIDToParentCase.get(subCaseToUpdate.id); + return ( + storedSubCase !== undefined && + subCaseToUpdate.status !== undefined && + storedSubCase.attributes.status !== subCaseToUpdate.status && + parentCase?.attributes.settings.syncAlerts + ); + }); - const returnUpdatedSubCases = updatedCases.saved_objects.reduce( - (acc, updatedSO) => { - const originalSubCase = subCasesMap.get(updatedSO.id); - if (originalSubCase) { - acc.push( - flattenSubCaseSavedObject({ - savedObject: { - ...originalSubCase, - ...updatedSO, - attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, - references: originalSubCase.references, - version: updatedSO.version ?? originalSubCase.version, - }, - }) - ); - } - return acc; - }, - [] - ); + await updateAlerts({ + caseService, + client, + caseClient, + subCasesToSync: subCasesToSyncAlertsFor, + logger, + }); - await userActionService.postUserActions({ - client, - actions: buildSubCaseUserActions({ - originalSubCases: bulkSubCases.saved_objects, - updatedSubCases: updatedCases.saved_objects, - actionDate: updatedAt, - actionBy: { email, full_name, username }, - }), - }); + const returnUpdatedSubCases = updatedCases.saved_objects.reduce( + (acc, updatedSO) => { + const originalSubCase = subCasesMap.get(updatedSO.id); + if (originalSubCase) { + acc.push( + flattenSubCaseSavedObject({ + savedObject: { + ...originalSubCase, + ...updatedSO, + attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, + references: originalSubCase.references, + version: updatedSO.version ?? originalSubCase.version, + }, + }) + ); + } + return acc; + }, + [] + ); + + await userActionService.postUserActions({ + client, + actions: buildSubCaseUserActions({ + originalSubCases: bulkSubCases.saved_objects, + updatedSubCases: updatedCases.saved_objects, + actionDate: updatedAt, + actionBy: { email, full_name, username }, + }), + }); - return SubCasesResponseRt.encode(returnUpdatedSubCases); + return SubCasesResponseRt.encode(returnUpdatedSubCases); + } catch (error) { + const idVersions = query.subCases.map((subCase) => ({ + id: subCase.id, + version: subCase.version, + })); + throw createCaseError({ + message: `Failed to update sub cases: ${JSON.stringify(idVersions)}: ${error}`, + error, + logger, + }); + } } -export function initPatchSubCasesApi({ router, caseService, userActionService }: RouteDeps) { +export function initPatchSubCasesApi({ + router, + caseService, + userActionService, + logger, +}: RouteDeps) { router.patch( { path: SUB_CASES_PATCH_DEL_URL, @@ -396,10 +430,10 @@ export function initPatchSubCasesApi({ router, caseService, userActionService }: }, }, async (context, request, response) => { - const caseClient = context.case.getCaseClient(); - const subCases = request.body as SubCasesPatchRequest; - try { + const caseClient = context.case.getCaseClient(); + const subCases = request.body as SubCasesPatchRequest; + return response.ok({ body: await update({ request, @@ -408,9 +442,11 @@ export function initPatchSubCasesApi({ router, caseService, userActionService }: client: context.core.savedObjects.client, caseService, userActionService, + logger, }), }); } catch (error) { + logger.error(`Failed to patch sub cases in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts index 488f32a795811f..2efef9ac67f80b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; -export function initGetAllCaseUserActionsApi({ router }: RouteDeps) { +export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { router.get( { path: CASE_USER_ACTIONS_URL, @@ -22,25 +22,28 @@ export function initGetAllCaseUserActionsApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; + const caseClient = context.case.getCaseClient(); + const caseId = request.params.case_id; - try { return response.ok({ body: await caseClient.getUserActions({ caseId }), }); } catch (error) { + logger.error( + `Failed to retrieve case user actions in route case id: ${request.params.case_id}: ${error}` + ); return response.customError(wrapError(error)); } } ); } -export function initGetAllSubCaseUserActionsApi({ router }: RouteDeps) { +export function initGetAllSubCaseUserActionsApi({ router, logger }: RouteDeps) { router.get( { path: SUB_CASE_USER_ACTIONS_URL, @@ -52,19 +55,22 @@ export function initGetAllSubCaseUserActionsApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; - const subCaseId = request.params.sub_case_id; + const caseClient = context.case.getCaseClient(); + const caseId = request.params.case_id; + const subCaseId = request.params.sub_case_id; - try { return response.ok({ body: await caseClient.getUserActions({ caseId, subCaseId }), }); } catch (error) { + logger.error( + `Failed to retrieve sub case user actions in route case id: ${request.params.case_id} sub case id: ${request.params.sub_case_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 395880e5c14101..6ce40e01c77520 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; + import type { CaseConfigureServiceSetup, CaseServiceSetup, @@ -20,6 +22,7 @@ export interface RouteDeps { connectorMappingsService: ConnectorMappingsServiceSetup; router: CasesRouter; userActionService: CaseUserActionServiceSetup; + logger: Logger; } export enum SortFieldCase { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 084b1a17a1434a..298f8bb877cda5 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -6,7 +6,7 @@ */ import { isEmpty } from 'lodash'; -import { badRequest, boomify, isBoom } from '@hapi/boom'; +import { badRequest, Boom, boomify, isBoom } from '@hapi/boom'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -45,6 +45,7 @@ import { import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase } from './types'; +import { isCaseError } from '../../common/error'; export const transformNewSubCase = ({ createdAt, @@ -182,9 +183,19 @@ export const transformNewComment = ({ }; }; +/** + * Transforms an error into the correct format for a kibana response. + */ export function wrapError(error: any): CustomHttpResponseOptions { - const options = { statusCode: error.statusCode ?? 500 }; - const boom = isBoom(error) ? error : boomify(error, options); + let boom: Boom; + + if (isCaseError(error)) { + boom = error.boomify(); + } else { + const options = { statusCode: error.statusCode ?? 500 }; + boom = isBoom(error) ? error : boomify(error, options); + } + return { body: boom, headers: boom.output.headers as { [key: string]: string }, diff --git a/x-pack/plugins/case/server/services/alerts/index.test.ts b/x-pack/plugins/case/server/services/alerts/index.test.ts index 35aa3ff80efc1e..3b1020d3ef5569 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -8,10 +8,11 @@ import { KibanaRequest } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; describe('updateAlertsStatus', () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); + const logger = loggingSystemMock.create().get('case'); describe('happy path', () => { let alertService: AlertServiceContract; @@ -21,6 +22,7 @@ describe('updateAlertsStatus', () => { request: {} as KibanaRequest, status: CaseStatuses.closed, scopedClusterClient: esClient, + logger, }; beforeEach(async () => { @@ -50,6 +52,7 @@ describe('updateAlertsStatus', () => { status: CaseStatuses.closed, indices: new Set(['']), scopedClusterClient: esClient, + logger, }) ).toBeUndefined(); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index a19e533418bc9a..45245b86ba2d51 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -9,9 +9,10 @@ import _ from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ElasticsearchClient } from 'kibana/server'; +import { ElasticsearchClient, Logger } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants'; +import { createCaseError } from '../../common/error'; export type AlertServiceContract = PublicMethodsOf; @@ -20,12 +21,14 @@ interface UpdateAlertsStatusArgs { status: CaseStatuses; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } interface GetAlertsArgs { ids: string[]; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } interface Alert { @@ -57,56 +60,75 @@ export class AlertService { status, indices, scopedClusterClient, + logger, }: UpdateAlertsStatusArgs) { const sanitizedIndices = getValidIndices(indices); if (sanitizedIndices.length <= 0) { - // log that we only had invalid indices + logger.warn(`Empty alert indices when updateAlertsStatus ids: ${JSON.stringify(ids)}`); return; } - const result = await scopedClusterClient.updateByQuery({ - index: sanitizedIndices, - conflicts: 'abort', - body: { - script: { - source: `ctx._source.signal.status = '${status}'`, - lang: 'painless', + try { + const result = await scopedClusterClient.updateByQuery({ + index: sanitizedIndices, + conflicts: 'abort', + body: { + script: { + source: `ctx._source.signal.status = '${status}'`, + lang: 'painless', + }, + query: { ids: { values: ids } }, }, - query: { ids: { values: ids } }, - }, - ignore_unavailable: true, - }); - - return result; + ignore_unavailable: true, + }); + + return result; + } catch (error) { + throw createCaseError({ + message: `Failed to update alert status ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger, + }); + } } public async getAlerts({ scopedClusterClient, ids, indices, + logger, }: GetAlertsArgs): Promise { const index = getValidIndices(indices); if (index.length <= 0) { + logger.warn(`Empty alert indices when retrieving alerts ids: ${JSON.stringify(ids)}`); return; } - const result = await scopedClusterClient.search({ - index, - body: { - query: { - bool: { - filter: { - ids: { - values: ids, + try { + const result = await scopedClusterClient.search({ + index, + body: { + query: { + bool: { + filter: { + ids: { + values: ids, + }, }, }, }, }, - }, - size: MAX_ALERTS_PER_SUB_CASE, - ignore_unavailable: true, - }); - - return result.body; + size: MAX_ALERTS_PER_SUB_CASE, + ignore_unavailable: true, + }); + + return result.body; + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve alerts ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger, + }); + } } } diff --git a/x-pack/plugins/case/server/services/connector_mappings/index.ts b/x-pack/plugins/case/server/services/connector_mappings/index.ts index cc387d8e6fe3ee..d4fda10276d2b5 100644 --- a/x-pack/plugins/case/server/services/connector_mappings/index.ts +++ b/x-pack/plugins/case/server/services/connector_mappings/index.ts @@ -41,7 +41,7 @@ export class ConnectorMappingsService { this.log.debug(`Attempting to find all connector mappings`); return await client.find({ ...options, type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT }); } catch (error) { - this.log.debug(`Attempting to find all connector mappings`); + this.log.error(`Attempting to find all connector mappings: ${error}`); throw error; } }, @@ -52,7 +52,7 @@ export class ConnectorMappingsService { references, }); } catch (error) { - this.log.debug(`Error on POST a new connector mappings: ${error}`); + this.log.error(`Error on POST a new connector mappings: ${error}`); throw error; } }, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index a9e5c269608308..f74e91ca102243 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -621,7 +621,7 @@ export class CaseService implements CaseServiceSetup { ], }); } catch (error) { - this.log.debug(`Error on POST a new sub case: ${error}`); + this.log.error(`Error on POST a new sub case for id ${caseId}: ${error}`); throw error; } } @@ -642,7 +642,7 @@ export class CaseService implements CaseServiceSetup { return subCases.saved_objects[0]; } catch (error) { - this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); + this.log.error(`Error finding the most recent sub case for case: ${caseId}: ${error}`); throw error; } } @@ -652,7 +652,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to DELETE sub case ${id}`); return await client.delete(SUB_CASE_SAVED_OBJECT, id); } catch (error) { - this.log.debug(`Error on DELETE sub case ${id}: ${error}`); + this.log.error(`Error on DELETE sub case ${id}: ${error}`); throw error; } } @@ -662,7 +662,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to DELETE case ${caseId}`); return await client.delete(CASE_SAVED_OBJECT, caseId); } catch (error) { - this.log.debug(`Error on DELETE case ${caseId}: ${error}`); + this.log.error(`Error on DELETE case ${caseId}: ${error}`); throw error; } } @@ -671,7 +671,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET comment ${commentId}`); return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); + this.log.error(`Error on GET comment ${commentId}: ${error}`); throw error; } } @@ -683,7 +683,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET case ${caseId}`); return await client.get(CASE_SAVED_OBJECT, caseId); } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); + this.log.error(`Error on GET case ${caseId}: ${error}`); throw error; } } @@ -692,7 +692,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET sub case ${id}`); return await client.get(SUB_CASE_SAVED_OBJECT, id); } catch (error) { - this.log.debug(`Error on GET sub case ${id}: ${error}`); + this.log.error(`Error on GET sub case ${id}: ${error}`); throw error; } } @@ -705,7 +705,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); return await client.bulkGet(ids.map((id) => ({ type: SUB_CASE_SAVED_OBJECT, id }))); } catch (error) { - this.log.debug(`Error on GET cases ${ids.join(', ')}: ${error}`); + this.log.error(`Error on GET cases ${ids.join(', ')}: ${error}`); throw error; } } @@ -720,7 +720,7 @@ export class CaseService implements CaseServiceSetup { caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) ); } catch (error) { - this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + this.log.error(`Error on GET cases ${caseIds.join(', ')}: ${error}`); throw error; } } @@ -732,7 +732,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET comment ${commentId}`); return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); + this.log.error(`Error on GET comment ${commentId}: ${error}`); throw error; } } @@ -749,7 +749,7 @@ export class CaseService implements CaseServiceSetup { type: CASE_SAVED_OBJECT, }); } catch (error) { - this.log.debug(`Error on find cases: ${error}`); + this.log.error(`Error on find cases: ${error}`); throw error; } } @@ -786,7 +786,7 @@ export class CaseService implements CaseServiceSetup { type: SUB_CASE_SAVED_OBJECT, }); } catch (error) { - this.log.debug(`Error on find sub cases: ${error}`); + this.log.error(`Error on find sub cases: ${error}`); throw error; } } @@ -824,7 +824,7 @@ export class CaseService implements CaseServiceSetup { }, }); } catch (error) { - this.log.debug( + this.log.error( `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` ); throw error; @@ -847,7 +847,7 @@ export class CaseService implements CaseServiceSetup { options, }: FindCommentsArgs): Promise> { try { - this.log.debug(`Attempting to GET all comments for id ${id}`); + this.log.debug(`Attempting to GET all comments for id ${JSON.stringify(id)}`); if (options?.page !== undefined || options?.perPage !== undefined) { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, @@ -874,7 +874,7 @@ export class CaseService implements CaseServiceSetup { ...options, }); } catch (error) { - this.log.debug(`Error on GET all comments for ${id}: ${error}`); + this.log.error(`Error on GET all comments for ${JSON.stringify(id)}: ${error}`); throw error; } } @@ -915,7 +915,7 @@ export class CaseService implements CaseServiceSetup { ); } - this.log.debug(`Attempting to GET all comments for case caseID ${id}`); + this.log.debug(`Attempting to GET all comments for case caseID ${JSON.stringify(id)}`); return this.getAllComments({ client, id, @@ -927,7 +927,7 @@ export class CaseService implements CaseServiceSetup { }, }); } catch (error) { - this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + this.log.error(`Error on GET all comments for case ${JSON.stringify(id)}: ${error}`); throw error; } } @@ -948,7 +948,7 @@ export class CaseService implements CaseServiceSetup { }; } - this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); + this.log.debug(`Attempting to GET all comments for sub case caseID ${JSON.stringify(id)}`); return this.getAllComments({ client, id, @@ -959,7 +959,7 @@ export class CaseService implements CaseServiceSetup { }, }); } catch (error) { - this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + this.log.error(`Error on GET all comments for sub case ${JSON.stringify(id)}: ${error}`); throw error; } } @@ -969,7 +969,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET all reporters`); return await readReporters({ client }); } catch (error) { - this.log.debug(`Error on GET all reporters: ${error}`); + this.log.error(`Error on GET all reporters: ${error}`); throw error; } } @@ -978,7 +978,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET all cases`); return await readTags({ client }); } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); + this.log.error(`Error on GET cases: ${error}`); throw error; } } @@ -1003,7 +1003,7 @@ export class CaseService implements CaseServiceSetup { email: null, }; } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); + this.log.error(`Error on GET cases: ${error}`); throw error; } } @@ -1012,7 +1012,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to POST a new case`); return await client.create(CASE_SAVED_OBJECT, { ...attributes }); } catch (error) { - this.log.debug(`Error on POST a new case: ${error}`); + this.log.error(`Error on POST a new case: ${error}`); throw error; } } @@ -1021,7 +1021,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to POST a new comment`); return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); } catch (error) { - this.log.debug(`Error on POST a new comment: ${error}`); + this.log.error(`Error on POST a new comment: ${error}`); throw error; } } @@ -1030,7 +1030,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to UPDATE case ${caseId}`); return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }, { version }); } catch (error) { - this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + this.log.error(`Error on UPDATE case ${caseId}: ${error}`); throw error; } } @@ -1046,7 +1046,7 @@ export class CaseService implements CaseServiceSetup { })) ); } catch (error) { - this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); + this.log.error(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); throw error; } } @@ -1062,7 +1062,7 @@ export class CaseService implements CaseServiceSetup { { version } ); } catch (error) { - this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + this.log.error(`Error on UPDATE comment ${commentId}: ${error}`); throw error; } } @@ -1080,7 +1080,7 @@ export class CaseService implements CaseServiceSetup { })) ); } catch (error) { - this.log.debug( + this.log.error( `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` ); throw error; @@ -1096,7 +1096,7 @@ export class CaseService implements CaseServiceSetup { { version } ); } catch (error) { - this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); + this.log.error(`Error on UPDATE sub case ${subCaseId}: ${error}`); throw error; } } @@ -1115,7 +1115,7 @@ export class CaseService implements CaseServiceSetup { })) ); } catch (error) { - this.log.debug( + this.log.error( `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` ); throw error; diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts index d05ada0dba30cd..785c81021b584f 100644 --- a/x-pack/plugins/case/server/services/user_actions/index.ts +++ b/x-pack/plugins/case/server/services/user_actions/index.ts @@ -66,7 +66,7 @@ export class CaseUserActionService { sortOrder: 'asc', }); } catch (error) { - this.log.debug(`Error on GET case user action: ${error}`); + this.log.error(`Error on GET case user action case id: ${caseId}: ${error}`); throw error; } }, @@ -77,7 +77,7 @@ export class CaseUserActionService { actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) ); } catch (error) { - this.log.debug(`Error on POST a new case user action: ${error}`); + this.log.error(`Error on POST a new case user action: ${error}`); throw error; } }, From 38bcde1aea0058fb226261f6ee13bd7be345ac17 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Sat, 27 Feb 2021 08:44:47 +0100 Subject: [PATCH 5/8] [APM] Include services with only metric documents (#92378) * [APM] Include services with only metric documents Closes #92075. * Explain as_mutable_array * Use kuery instead of uiFilters for API tests --- .../apm/common/utils/as_mutable_array.ts | 41 +++++++++ .../__snapshots__/queries.test.ts.snap | 51 ++++++++++- .../get_service_transaction_stats.ts | 13 ++- .../get_services_from_metric_documents.ts | 84 +++++++++++++++++++ .../get_services/get_services_items.ts | 43 +++++++--- .../tests/services/top_services.ts | 43 ++++++++++ 6 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/apm/common/utils/as_mutable_array.ts create mode 100644 x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts diff --git a/x-pack/plugins/apm/common/utils/as_mutable_array.ts b/x-pack/plugins/apm/common/utils/as_mutable_array.ts new file mode 100644 index 00000000000000..ce1d7e607ec4c5 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/as_mutable_array.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +// Sometimes we use `as const` to have a more specific type, +// because TypeScript by default will widen the value type of an +// array literal. Consider the following example: +// +// const filter = [ +// { term: { 'agent.name': 'nodejs' } }, +// { range: { '@timestamp': { gte: 'now-15m ' }} +// ]; + +// The result value type will be: + +// const filter: ({ +// term: { +// 'agent.name'?: string +// }; +// range?: undefined +// } | { +// term?: undefined; +// range: { +// '@timestamp': { +// gte: string +// } +// } +// })[]; + +// This can sometimes leads to issues. In those cases, we can +// use `as const`. However, the Readonly type is not compatible +// with Array. This function returns a mutable version of a type. + +export function asMutableArray>( + arr: T +): T extends Readonly<[...infer U]> ? U : unknown[] { + return arr as any; +} diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index dec5be8da32f45..7e7e073c0d2f6e 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -118,7 +118,6 @@ Array [ "environments": Object { "terms": Object { "field": "service.environment", - "missing": "", }, }, "outcomes": Object { @@ -197,6 +196,56 @@ Array [ "size": 0, }, }, + Object { + "apm": Object { + "events": Array [ + "metric", + ], + }, + "body": Object { + "aggs": Object { + "services": Object { + "aggs": Object { + "environments": Object { + "terms": Object { + "field": "service.environment", + }, + }, + "latest": Object { + "top_metrics": Object { + "metrics": Object { + "field": "agent.name", + }, + "sort": Object { + "@timestamp": "desc", + }, + }, + }, + }, + "terms": Object { + "field": "service.name", + "size": 500, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + ], + }, + }, + "size": 0, + }, + }, ] `; diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index 5f0302035462c5..10c7420d0f3b04 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -40,15 +40,15 @@ interface AggregationParams { kuery?: string; setup: ServicesItemsSetup; searchAggregatedTransactions: boolean; + maxNumServices: number; } -const MAX_NUMBER_OF_SERVICES = 500; - export async function getServiceTransactionStats({ environment, kuery, setup, searchAggregatedTransactions, + maxNumServices, }: AggregationParams) { return withApmSpan('get_service_transaction_stats', async () => { const { apmEventClient, start, end } = setup; @@ -92,7 +92,7 @@ export async function getServiceTransactionStats({ services: { terms: { field: SERVICE_NAME, - size: MAX_NUMBER_OF_SERVICES, + size: maxNumServices, }, aggs: { transactionType: { @@ -104,7 +104,6 @@ export async function getServiceTransactionStats({ environments: { terms: { field: SERVICE_ENVIRONMENT, - missing: '', }, }, sample: { @@ -147,9 +146,9 @@ export async function getServiceTransactionStats({ return { serviceName: bucket.key as string, transactionType: topTransactionTypeBucket.key as string, - environments: topTransactionTypeBucket.environments.buckets - .map((environmentBucket) => environmentBucket.key as string) - .filter(Boolean), + environments: topTransactionTypeBucket.environments.buckets.map( + (environmentBucket) => environmentBucket.key as string + ), agentName: topTransactionTypeBucket.sample.top[0].metrics[ AGENT_NAME ] as AgentName, diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts new file mode 100644 index 00000000000000..cabd44c1e6907a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_from_metric_documents.ts @@ -0,0 +1,84 @@ +/* + * 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 { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { + AGENT_NAME, + SERVICE_ENVIRONMENT, + SERVICE_NAME, +} from '../../../../common/elasticsearch_fieldnames'; +import { environmentQuery, kqlQuery, rangeQuery } from '../../../utils/queries'; +import { ProcessorEvent } from '../../../../common/processor_event'; +import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; + +export function getServicesFromMetricDocuments({ + environment, + setup, + maxNumServices, + kuery, +}: { + setup: Setup & SetupTimeRange; + environment?: string; + maxNumServices: number; + kuery?: string; +}) { + return withApmSpan('get_services_from_metric_documents', async () => { + const { apmEventClient, start, end } = setup; + + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: maxNumServices, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + }, + }, + latest: { + top_metrics: { + metrics: { field: AGENT_NAME } as const, + sort: { '@timestamp': 'desc' }, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.services.buckets.map((bucket) => { + return { + serviceName: bucket.key as string, + environments: bucket.environments.buckets.map( + (envBucket) => envBucket.key as string + ), + agentName: bucket.latest.top[0].metrics[AGENT_NAME] as AgentName, + }; + }) ?? [] + ); + }); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts index 1ddc7a6583c813..3b9792519d261c 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts @@ -6,15 +6,18 @@ */ import { Logger } from '@kbn/logging'; +import { asMutableArray } from '../../../../common/utils/as_mutable_array'; import { joinByKey } from '../../../../common/utils/join_by_key'; -import { getServicesProjection } from '../../../projections/services'; import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getHealthStatuses } from './get_health_statuses'; +import { getServicesFromMetricDocuments } from './get_services_from_metric_documents'; import { getServiceTransactionStats } from './get_service_transaction_stats'; export type ServicesItemsSetup = Setup & SetupTimeRange; +const MAX_NUMBER_OF_SERVICES = 500; + export async function getServicesItems({ environment, kuery, @@ -32,33 +35,49 @@ export async function getServicesItems({ const params = { environment, kuery, - projection: getServicesProjection({ - kuery, - setup, - searchAggregatedTransactions, - }), setup, searchAggregatedTransactions, + maxNumServices: MAX_NUMBER_OF_SERVICES, }; - const [transactionStats, healthStatuses] = await Promise.all([ + const [ + transactionStats, + servicesFromMetricDocuments, + healthStatuses, + ] = await Promise.all([ getServiceTransactionStats(params), + getServicesFromMetricDocuments(params), getHealthStatuses(params).catch((err) => { logger.error(err); return []; }), ]); - const apmServices = transactionStats.map(({ serviceName }) => serviceName); + const foundServiceNames = transactionStats.map( + ({ serviceName }) => serviceName + ); + + const servicesWithOnlyMetricDocuments = servicesFromMetricDocuments.filter( + ({ serviceName }) => !foundServiceNames.includes(serviceName) + ); + + const allServiceNames = foundServiceNames.concat( + servicesWithOnlyMetricDocuments.map(({ serviceName }) => serviceName) + ); // make sure to exclude health statuses from services // that are not found in APM data const matchedHealthStatuses = healthStatuses.filter(({ serviceName }) => - apmServices.includes(serviceName) + allServiceNames.includes(serviceName) ); - const allMetrics = [...transactionStats, ...matchedHealthStatuses]; - - return joinByKey(allMetrics, 'serviceName'); + return joinByKey( + asMutableArray([ + ...transactionStats, + ...servicesWithOnlyMetricDocuments, + ...matchedHealthStatuses, + ] as const), + 'serviceName' + ); }); } diff --git a/x-pack/test/apm_api_integration/tests/services/top_services.ts b/x-pack/test/apm_api_integration/tests/services/top_services.ts index 3896bc1c6fabc2..37f7b09e8b7d21 100644 --- a/x-pack/test/apm_api_integration/tests/services/top_services.ts +++ b/x-pack/test/apm_api_integration/tests/services/top_services.ts @@ -255,6 +255,49 @@ export default function ApiTest({ getService }: FtrProviderContext) { } ); + registry.when( + 'APM Services Overview with a basic license when data is loaded excluding transaction events', + { config: 'basic', archives: [archiveName] }, + () => { + it('includes services that only report metric data', async () => { + interface Response { + status: number; + body: APIReturnType<'GET /api/apm/services'>; + } + + const [unfilteredResponse, filteredResponse] = await Promise.all([ + supertest.get(`/api/apm/services?start=${start}&end=${end}`) as Promise, + supertest.get( + `/api/apm/services?start=${start}&end=${end}&kuery=${encodeURIComponent( + 'not (processor.event:transaction)' + )}` + ) as Promise, + ]); + + expect(unfilteredResponse.body.items.length).to.be.greaterThan(0); + + const unfilteredServiceNames = unfilteredResponse.body.items + .map((item) => item.serviceName) + .sort(); + + const filteredServiceNames = filteredResponse.body.items + .map((item) => item.serviceName) + .sort(); + + expect(unfilteredServiceNames).to.eql(filteredServiceNames); + + expect( + filteredResponse.body.items.every((item) => { + // make sure it did not query transaction data + return isEmpty(item.avgResponseTime); + }) + ).to.be(true); + + expect(filteredResponse.body.items.every((item) => !!item.agentName)).to.be(true); + }); + } + ); + registry.when( 'APM Services overview with a trial license when data is loaded', { config: 'trial', archives: [archiveName] }, From 58a5a7c67d661d7d6b2cdaa75728db5afc7ff897 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Sun, 28 Feb 2021 00:47:40 +0000 Subject: [PATCH 6/8] skip flaky suite (#89072) --- x-pack/test/functional/apps/uptime/overview.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 6c9eb24070d8fb..b9c1767e4a8cfa 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -15,7 +15,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); - describe('overview page', function () { + // FLAKY: https://github.com/elastic/kibana/issues/89072 + describe.skip('overview page', function () { const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; From c6336e50bfe53eaec616c9fd13c9a15ddaba7d75 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Sun, 28 Feb 2021 00:56:02 +0000 Subject: [PATCH 7/8] skip flaky suite (#91939) --- x-pack/test/accessibility/apps/search_profiler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/search_profiler.ts b/x-pack/test/accessibility/apps/search_profiler.ts index 7fba45175c8314..6559d58be62987 100644 --- a/x-pack/test/accessibility/apps/search_profiler.ts +++ b/x-pack/test/accessibility/apps/search_profiler.ts @@ -15,7 +15,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const a11y = getService('a11y'); const flyout = getService('flyout'); - describe('Accessibility Search Profiler Editor', () => { + // FLAKY: https://github.com/elastic/kibana/issues/91939 + describe.skip('Accessibility Search Profiler Editor', () => { before(async () => { await PageObjects.common.navigateToApp('searchProfiler'); await a11y.testAppSnapshot(); From c116477198a1cd78e2e43bfb38d413cb1f67a2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Mon, 1 Mar 2021 08:59:03 +0100 Subject: [PATCH 8/8] [Logs UI] Round up the end timestamp in the log stream embeddable (#92833) This fixes the interpretation of the end timestamp in the log stream embeddable such that it rounds it up. Without this a date range of `now/d` to `now/d` would be resolved to a zero-duration time range even though the date picker semantics are "Today". --- .../public/components/log_stream/log_stream_embeddable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx index e1427bc96e7e0b..c69c7ebff2b9e4 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx @@ -66,7 +66,7 @@ export class LogStreamEmbeddable extends Embeddable { } const startTimestamp = datemathToEpochMillis(this.input.timeRange.from); - const endTimestamp = datemathToEpochMillis(this.input.timeRange.to); + const endTimestamp = datemathToEpochMillis(this.input.timeRange.to, 'up'); if (!startTimestamp || !endTimestamp) { return;