From 13464453872ab1df93d33e9e0b005f968befd604 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 13 Jan 2021 14:17:50 -0500 Subject: [PATCH 01/47] Adding type field to client --- x-pack/plugins/case/common/api/cases/case.ts | 15 ++++ .../cases/__snapshots__/create.test.ts.snap | 34 ++++++++ .../case/server/client/cases/create.test.ts | 45 ++++------- .../case/server/client/cases/create.ts | 4 +- x-pack/plugins/case/server/client/types.ts | 4 +- .../case/server/connectors/case/index.ts | 6 +- x-pack/plugins/case/server/plugin.ts | 1 + .../case/server/routes/api/cases/post_case.ts | 4 +- .../plugins/case/server/routes/api/utils.ts | 3 +- .../case/server/saved_object_types/cases.ts | 5 +- .../server/saved_object_types/child_case.ts | 80 +++++++++++++++++++ .../server/saved_object_types/migrations.ts | 18 ++++- 12 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap create mode 100644 x-pack/plugins/case/server/saved_object_types/child_case.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 9b6945edfe729..861baa0cc58a1 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -26,6 +26,13 @@ const CaseStatusRt = rt.union([ export const caseStatuses = Object.values(CaseStatuses); +export enum CaseType { + parent = 'parent', + individual = 'individual', +} + +const CaseTypeRt = rt.union([rt.literal(CaseType.parent), rt.literal(CaseType.individual)]); + const SettingsRt = rt.type({ syncAlerts: rt.boolean, }); @@ -35,6 +42,7 @@ const CaseBasicRt = rt.type({ status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + type: CaseTypeRt, connector: CaseConnectorRt, settings: SettingsRt, }); @@ -79,6 +87,11 @@ export const CasePostRequestRt = rt.type({ settings: SettingsRt, }); +export const CaseClientPostRequestRt = rt.type({ + type: CaseTypeRt, + ...CasePostRequestRt.props, +}); + export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; export const CasesFindRequestRt = rt.partial({ @@ -126,6 +139,8 @@ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) export const CasesResponseRt = rt.array(CaseResponseRt); export type CaseAttributes = rt.TypeOf; +// TODO: document how this is different from the CasePostRequest +export type CaseClientPostRequest = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap b/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap new file mode 100644 index 0000000000000..22f11034c072f --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`create happy path it creates the case correctly 1`] = ` +Array [ + Object { + "attributes": Object { + "action": "create", + "action_at": "2019-11-25T21:54:48.952Z", + "action_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "action_field": Array [ + "description", + "status", + "tags", + "title", + "connector", + "settings", + ], + "new_value": "{\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"type\\":\\"individual\\",\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", + "old_value": null, + }, + "references": Array [ + Object { + "id": "mock-it", + "name": "associated-cases", + "type": "cases", + }, + ], + }, +] +`; 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 7c2091fe5e220..c7b0c6df83a47 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConnectorTypes, CasePostRequest, CaseStatuses } from '../../../common/api'; +import { ConnectorTypes, CaseStatuses, CaseType, CaseClientPostRequest } from '../../../common/api'; import { createMockSavedObjectsRepository, @@ -24,10 +24,11 @@ describe('create', () => { describe('happy path', () => { test('it creates the case correctly', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: '123', name: 'Jira', @@ -37,7 +38,7 @@ describe('create', () => { settings: { syncAlerts: true, }, - } as CasePostRequest; + }; const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, @@ -65,6 +66,7 @@ describe('create', () => { title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.individual, updated_at: null, updated_by: null, version: 'WzksMV0=', @@ -75,37 +77,15 @@ describe('create', () => { expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions - ).toEqual([ - { - attributes: { - action: 'create', - action_at: '2019-11-25T21:54:48.952Z', - action_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], - new_value: - '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}},"settings":{"syncAlerts":true}}', - old_value: null, - }, - references: [ - { - id: 'mock-it', - name: 'associated-cases', - type: 'cases', - }, - ], - }, - ]); + ).toMatchSnapshot(); }); test('it creates the case without connector in the configuration', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -137,6 +117,7 @@ describe('create', () => { title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.individual, updated_at: null, updated_by: null, version: 'WzksMV0=', @@ -147,10 +128,11 @@ describe('create', () => { }); test('Allow user to create case without authentication', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -189,6 +171,7 @@ describe('create', () => { title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.individual, updated_at: null, updated_by: null, version: 'WzksMV0=', @@ -337,6 +320,7 @@ describe('create', () => { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', tags: ['defacement'], + type: CaseType.individual, status: CaseStatuses.closed, connector: { id: 'none', @@ -361,10 +345,11 @@ describe('create', () => { }); it(`Returns an error if postNewCase throws`, async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'Throw an error', title: 'Super Bad Security Issue', tags: ['error'], + type: CaseType.individual, connector: { id: 'none', name: 'none', diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index 1dca025036c1e..42f3d76e6b225 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -12,11 +12,11 @@ import { identity } from 'fp-ts/lib/function'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { - CasePostRequestRt, throwErrors, excess, CaseResponseRt, CaseResponse, + CaseClientPostRequestRt, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -34,7 +34,7 @@ export const create = ({ request, }: CaseClientFactoryArguments) => async ({ theCase }: CaseClientCreate): Promise => { const query = pipe( - excess(CasePostRequestRt).decode(theCase), + excess(CaseClientPostRequestRt).decode(theCase), 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 ec83f1ec1ff7d..025729da0080c 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -7,7 +7,7 @@ import { KibanaRequest, SavedObjectsClientContract, RequestHandlerContext } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { - CasePostRequest, + CaseClientPostRequest, CaseResponse, CasesPatchRequest, CasesResponse, @@ -24,7 +24,7 @@ import { } from '../services'; import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; export interface CaseClientCreate { - theCase: CasePostRequest; + theCase: CaseClientPostRequest; } export interface CaseClientUpdate { diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 2195786f718ab..51a10d67c03f5 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -8,7 +8,7 @@ import { curry } from 'lodash'; import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { CasePatchRequest, CasePostRequest } from '../../../common/api'; +import { CasePatchRequest, CasePostRequest, CaseType } from '../../../common/api'; import { createCaseClient } from '../../client'; import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; import { @@ -88,7 +88,9 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create({ theCase: subActionParams as CasePostRequest }); + data = await caseClient.create({ + theCase: { ...(subActionParams as CasePostRequest), type: CaseType.individual }, + }); } if (subAction === 'update') { diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 915656895e8c8..c39364418d4be 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -143,6 +143,7 @@ export class CasePlugin { }); }; + // TODO I think we need to create a return type for the start function to include this return { getCaseClientWithRequestAndContext, }; 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 663d502d548d5..b71410f603acc 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 @@ -8,7 +8,7 @@ import { wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; -import { CasePostRequest } from '../../../../common/api'; +import { CasePostRequest, CaseType } from '../../../../common/api'; export function initPostCaseApi({ router }: RouteDeps) { router.post( @@ -27,7 +27,7 @@ export function initPostCaseApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.create({ theCase }), + body: await caseClient.create({ theCase: { ...theCase, type: CaseType.individual } }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 917afb487b1f4..f8dcf05cff8af 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -34,6 +34,7 @@ import { excess, throwErrors, CaseStatuses, + CaseClientPostRequest, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -52,7 +53,7 @@ export const transformNewCase = ({ createdDate: string; email?: string | null; full_name?: string | null; - newCase: CasePostRequest; + newCase: CaseClientPostRequest; username?: string | null; }): ESCaseAttributes => ({ ...newCase, diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 6468d4b3aa61d..a9bc91b31f283 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -117,7 +117,10 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, - + // parent or individual + type: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/child_case.ts b/x-pack/plugins/case/server/saved_object_types/child_case.ts new file mode 100644 index 0000000000000..a97170ccae56e --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/child_case.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsType } from 'src/core/server'; +import { caseMigrations } from './migrations'; + +export const CHILD_CASE_SAVED_OBJECT = 'child_case'; + +export const caseSavedObjectType: SavedObjectsType = { + name: CHILD_CASE_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + mappings: { + properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + status: { + type: 'keyword', + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + settings: { + properties: { + // TODO do we need this? + syncAlerts: { + type: 'boolean', + }, + }, + }, + }, + }, + // TODO migration +}; diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index 9124314ac3f5e..23e7c27871038 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; -import { ConnectorTypes, CommentType } from '../../common/api'; +import { ConnectorTypes, CommentType, CaseType } from '../../common/api'; interface UnsanitizedCaseConnector { connector_id: string; @@ -48,6 +48,10 @@ interface SanitizedCaseSettings { }; } +interface SanitizedCaseType { + type: string; +} + export const caseMigrations = { '7.10.0': ( doc: SavedObjectUnsanitizedDoc @@ -82,6 +86,18 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + type: CaseType.individual, + }, + references: doc.references || [], + }; + }, }; export const configureMigrations = { From 9f82a3d73d0bf90b11fbe7281a82ea12323d2717 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 14 Jan 2021 12:28:56 -0500 Subject: [PATCH 02/47] Removing context and adding association type --- .../plugins/case/common/api/cases/comment.ts | 31 ++++++--- .../client/alerts/update_status.test.ts | 5 +- .../server/client/alerts/update_status.ts | 9 +-- .../case/server/client/cases/create.test.ts | 1 + .../comments/__snapshots__/add.test.ts.snap | 62 +++++++++++++++++ .../case/server/client/comments/add.test.ts | 52 +-------------- .../case/server/client/comments/add.ts | 16 ++++- x-pack/plugins/case/server/client/index.ts | 66 ++----------------- x-pack/plugins/case/server/client/mocks.ts | 20 +----- x-pack/plugins/case/server/client/types.ts | 6 +- .../case/server/connectors/case/index.test.ts | 45 +++++-------- .../case/server/connectors/case/index.ts | 7 +- .../case/server/connectors/case/schema.ts | 18 +++-- x-pack/plugins/case/server/plugin.ts | 23 ++++++- .../routes/api/__fixtures__/route_contexts.ts | 8 ++- .../__snapshots__/post_case.test.ts.snap | 37 +++++++++++ .../__snapshots__/post_comment.test.ts.snap | 21 ++++++ .../api/cases/comments/post_comment.test.ts | 17 +---- .../server/routes/api/cases/post_case.test.ts | 31 +-------- .../plugins/case/server/routes/api/utils.ts | 5 +- 20 files changed, 238 insertions(+), 242 deletions(-) create mode 100644 x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap create mode 100644 x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap create mode 100644 x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 920858a1e39b4..48483c7d08802 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -8,7 +8,22 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; +/** + * this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only + * be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and + * sub case when it is created. For us to be able to filter out alert groups in a top-level case we need a field to + * use as a filter. + */ +export enum AssociationType { + case = 'case', + subCase = 'sub_case', +} + export const CommentAttributesBasicRt = rt.type({ + associationType: rt.union([ + rt.literal(AssociationType.case), + rt.literal(AssociationType.subCase), + ]), created_at: rt.string, created_by: UserRT, pushed_at: rt.union([rt.string, rt.null]), @@ -17,14 +32,19 @@ export const CommentAttributesBasicRt = rt.type({ updated_by: rt.union([UserRT, rt.null]), }); +export enum CommentType { + user = 'user', + alert = 'alert', +} + export const ContextTypeUserRt = rt.type({ comment: rt.string, - type: rt.literal('user'), + type: rt.literal(CommentType.user), }); export const ContextTypeAlertRt = rt.type({ - type: rt.literal('alert'), - alertId: rt.string, + type: rt.literal(CommentType.alert), + alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, }); @@ -73,11 +93,6 @@ export const CommentsResponseRt = rt.type({ total: rt.number, }); -export enum CommentType { - user = 'user', - alert = 'alert', -} - export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; 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 834a72b849f65..986832899997b 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 @@ -27,7 +27,8 @@ describe('updateAlertsStatus', () => { }); }); - describe('unhappy path', () => { + // TODO: maybe test the plugin code instead? + /* describe('unhappy path', () => { test('it throws when missing securitySolutionClient', async () => { expect.assertions(3); @@ -48,6 +49,6 @@ describe('updateAlertsStatus', () => { expect(e.output.statusCode).toBe(404); }); }); - }); + });*/ }); }); 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 d90424eb5fb15..a2846528c0291 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -4,22 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from '@hapi/boom'; import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; export const updateAlertsStatus = ({ alertsService, request, - context, + index, }: CaseClientFactoryArguments) => async ({ ids, status, }: CaseClientUpdateAlertsStatus): Promise => { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); - } - - const index = securitySolutionClient.getSignalsIndex(); await alertsService.updateAlertsStatus({ ids, status, index, request }); }; 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 c7b0c6df83a47..00ab1620059f8 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -77,6 +77,7 @@ describe('create', () => { expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions + // using a snapshot here so we don't have to update the text field manually each time it changes ).toMatchSnapshot(); }); diff --git a/x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap b/x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap new file mode 100644 index 0000000000000..b39544f1b03e2 --- /dev/null +++ b/x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addComment happy path it adds a comment correctly 1`] = ` +Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; + +exports[`addComment happy path it adds a comment of type alert correctly 1`] = ` +Object { + "alertId": "test-id", + "associationType": "case", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "index": "test-index", + "pushed_at": null, + "pushed_by": null, + "type": "alert", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; + +exports[`addComment happy path it allow user to create comments without authentications 1`] = ` +Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; 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 85967d4d79cc4..61da906651c24 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -41,22 +41,7 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchSnapshot(); }); test('it adds a comment of type alert correctly', async () => { @@ -78,23 +63,7 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchSnapshot(); }); test('it updates the case correctly after adding a comment', async () => { @@ -189,22 +158,7 @@ describe('addComment', () => { }); expect(res.id).toEqual('mock-id-1'); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchSnapshot(); }); test('it update the status of the alert if the case is synced with alerts', async () => { diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index bb61094cfa3bd..9248e5b1df1ba 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -18,6 +18,7 @@ import { CaseResponse, CommentType, CaseStatuses, + AssociationType, } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; @@ -46,6 +47,15 @@ export const addComment = ({ caseId, }); + /** + * TODO: check if myCase is a 'case' or a 'subCase' + * if case then the association type should be 'case' + * if subCase then the association should be 'subCase' + * + * Alternatively we could not save both references...need to figure out what the tradeoff is + */ + const associationType = AssociationType.case; + // An alert cannot be attach to a closed case. if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { throw Boom.badRequest('Alert cannot be attached to a closed case'); @@ -59,6 +69,7 @@ export const addComment = ({ caseService.postNewComment({ client: savedObjectsClient, attributes: transformNewComment({ + associationType, createdDate, ...query, username, @@ -86,8 +97,11 @@ export const addComment = ({ // If the case is synced with alerts the newly attached alert must match the status of the case. if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + const ids = Array.isArray(newComment.attributes.alertId) + ? newComment.attributes.alertId + : [newComment.attributes.alertId]; caseClient.updateAlertsStatus({ - ids: [newComment.attributes.alertId], + ids, status: myCase.attributes.status, }); } diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 70eb3282dd243..3bd7d71d7dd4d 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -14,67 +14,13 @@ import { updateAlertsStatus } from './alerts/update_status'; export { CaseClient } from './types'; -export const createCaseClient = ({ - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - alertsService, - context, -}: CaseClientFactoryArguments): CaseClient => { +export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { return { - create: create({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }), - update: update({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }), - addComment: addComment({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }), + create: create(clientArgs), + update: update(clientArgs), + addComment: addComment(clientArgs), getFields: getFields(), - getMappings: getMappings({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }), - updateAlertsStatus: updateAlertsStatus({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }), + getMappings: getMappings(clientArgs), + updateAlertsStatus: updateAlertsStatus(clientArgs), }; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 78cb7f71cef4c..d9a66c7dc5b0d 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -67,23 +67,6 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const alertsService = { initialize: jest.fn(), updateAlertsStatus: jest.fn() }; - const context = { - core: { - savedObjects: { - client: savedObjectsClient, - }, - }, - actions: { getActionsClient: () => actionsMock }, - case: { - getCaseClient: () => caseClient, - }, - securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - }, - }; - const caseClient = createCaseClient({ savedObjectsClient, request, @@ -92,7 +75,8 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ connectorMappingsService, userActionService, alertsService, - context: (omit(omitFromContext, context) as unknown) as RequestHandlerContext, + // TODO: refactor this to a variable across the tests + index: '.siem-signals', }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 025729da0080c..2f9b802625b39 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, SavedObjectsClientContract, RequestHandlerContext } from 'kibana/server'; +import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CaseClientPostRequest, @@ -43,8 +43,6 @@ export interface CaseClientUpdateAlertsStatus { status: CaseStatuses; } -type PartialExceptFor = Partial & Pick; - export interface CaseClientFactoryArguments { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; @@ -53,7 +51,7 @@ export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; - context?: PartialExceptFor; + index: string; } export interface ConfigureFields { 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 442b23da87c96..df328c1bd8a01 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -9,7 +9,13 @@ import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsMock } from '../../../../actions/server/mocks'; import { validateParams } from '../../../../actions/server/lib'; -import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + CommentType, + CaseStatuses, + CaseType, + AssociationType, +} from '../../../common/api'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -678,9 +684,7 @@ describe('case connector', () => { expect(validateParams(caseActionType, params)).toEqual(params); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('succeeds when type is an alert', () => { + it('succeeds when type is an alert', () => { const params: Record = { subAction: 'addComment', subActionParams: { @@ -706,26 +710,6 @@ describe('case connector', () => { }).toThrow(); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('fails when type is an alert', () => { - const params: Record = { - subAction: 'addComment', - subActionParams: { - caseId: 'case-id', - comment: { - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - }, - }, - }; - - expect(() => { - validateParams(caseActionType, params); - }).toThrow(); - }); - it('fails when missing attributes: type user', () => { const allParams = { type: CommentType.user, @@ -748,9 +732,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when missing attributes: type alert', () => { + it('fails when missing attributes: type alert', () => { const allParams = { type: CommentType.alert, comment: 'a comment', @@ -792,9 +774,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when excess attributes are provided: type alert', () => { + it('fails when excess attributes are provided: type alert', () => { ['comment'].forEach((attribute) => { const params: Record = { subAction: 'addComment', @@ -857,6 +837,7 @@ describe('case connector', () => { }, title: 'Case from case connector!!', tags: ['case', 'connector'], + type: CaseType.parent, description: 'Yo fields!!', external_service: null, status: CaseStatuses.open, @@ -917,6 +898,7 @@ describe('case connector', () => { parent: null, }, }, + type: CaseType.parent, }, }); }); @@ -952,6 +934,7 @@ describe('case connector', () => { tags: ['defacement'], title: 'Update title', totalComment: 0, + type: CaseType.parent, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', @@ -1024,11 +1007,13 @@ describe('case connector', () => { title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.parent, updated_at: null, updated_by: null, version: 'WzksMV0=', comments: [ { + associationType: AssociationType.case, comment: 'a comment', type: CommentType.user as const, created_at: '2020-10-23T21:54:48.952Z', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 51a10d67c03f5..11a0024275541 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -77,8 +77,8 @@ async function executor( connectorMappingsService, userActionService, alertsService, - // TODO: When case connector is enabled we should figure out how to pass the context. - context: {} as RequestHandlerContext, + // TODO: pass in the index + index: '.siem-index-todo', }); if (!supportedSubActions.includes(subAction)) { @@ -89,7 +89,8 @@ async function executor( if (subAction === 'create') { data = await caseClient.create({ - theCase: { ...(subActionParams as CasePostRequest), type: CaseType.individual }, + // TODO: is it possible for the action framework to create an individual case that is not associated with sub cases? + theCase: { ...(subActionParams as CasePostRequest), type: CaseType.parent }, }); } diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index d17c9ce6eb1cc..d350adc1aa757 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -4,17 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ import { schema } from '@kbn/config-schema'; +import { CommentType } from '../../../common/api'; import { validateConnector } from './validators'; // Reserved for future implementation export const CaseConfigurationSchema = schema.object({}); const ContextTypeUserSchema = schema.object({ - type: schema.literal('user'), + type: schema.literal(CommentType.user), comment: schema.string(), }); /** + * TODO: remove * ContextTypeAlertSchema has been deleted. * Comments of type alert need the siem signal index. * Case connector is not being passed the context which contains the @@ -25,16 +27,18 @@ const ContextTypeUserSchema = schema.object({ * * The schema: * - * const ContextTypeAlertSchema = schema.object({ - * type: schema.literal('alert'), - * alertId: schema.string(), - * index: schema.string(), - * }); * * Issue: https://github.com/elastic/kibana/issues/85750 * */ -export const CommentSchema = schema.oneOf([ContextTypeUserSchema]); +const ContextTypeAlertSchema = schema.object({ + type: schema.literal(CommentType.alert), + // allowing either an array or a single value to preserve the previous API of attaching a single alert ID + alertId: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + index: schema.string(), +}); + +export const CommentSchema = schema.oneOf([ContextTypeUserSchema, ContextTypeAlertSchema]); const JiraFieldsSchema = schema.object({ issueType: schema.string(), diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index c39364418d4be..cfe4e488452c9 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -15,6 +15,7 @@ import { } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; +import Boom from '@hapi/boom'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server'; import { APP_ID } from '../common/constants'; @@ -47,6 +48,21 @@ function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map((config) => config)); } +type PartialExceptFor = Partial & Pick; + +/** + * TODO: This is a temporary solution until we can figure out how to get access to + * the signals index while not using the context. + */ +function getSignalsIndex(context?: PartialExceptFor) { + const securitySolutionClient = context?.securitySolution?.getAppClient(); + if (securitySolutionClient == null) { + throw Boom.notFound('securitySolutionClient client have not been found'); + } + + return securitySolutionClient.getSignalsIndex(); +} + export interface PluginsSetup { security: SecurityPluginSetup; actions: ActionsPluginSetup; @@ -131,6 +147,8 @@ export class CasePlugin { context: RequestHandlerContext, request: KibanaRequest ) => { + const index = getSignalsIndex(context); + return createCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, @@ -139,7 +157,7 @@ export class CasePlugin { connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, - context, + index, }); }; @@ -170,6 +188,7 @@ export class CasePlugin { }): IContextProvider, typeof APP_ID> => { return async (context, request) => { const [{ savedObjects }] = await core.getStartServices(); + const index = getSignalsIndex(context); return { getCaseClient: () => { return createCaseClient({ @@ -180,7 +199,7 @@ export class CasePlugin { userActionService, alertsService, request, - context, + index, }); }, }; 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 b2d232dbb7cca..7f3fb6b8bdd16 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 @@ -44,11 +44,12 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, - securitySolution: { + // TODO: remove + /* securitySolution: { getAppClient: () => ({ getSignalsIndex: () => '.siem-signals', }), - }, + },*/ } as unknown) as RequestHandlerContext; const connectorMappingsService = await connectorMappingsServicePlugin.setup(); @@ -63,7 +64,8 @@ export const createRouteContext = async (client: any, badAuth = false) => { getUserActions: jest.fn(), }, alertsService, - context, + // TODO: move this to a variable shared across tests + index: '.siem-signals', }); return context; diff --git a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap new file mode 100644 index 0000000000000..b03eae2e9a481 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`POST cases Allow user to create case without authentication 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap new file mode 100644 index 0000000000000..bd4f80a872670 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`POST comment Allow user to create comments without authentications 1`] = ` +Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 2909aa40a4425..4b75d86efb146 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -298,21 +298,6 @@ describe('POST comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(response.payload.comments[response.payload.comments.length - 1]).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index ea59959b0e849..e5362710461c9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -188,35 +188,6 @@ describe('POST cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual({ - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - id: 'mock-it', - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(response.payload).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index f8dcf05cff8af..4574cea6a1a63 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -17,7 +17,6 @@ import { } from 'kibana/server'; import { - CasePostRequest, CaseResponse, CasesFindResponse, CommentResponse, @@ -35,6 +34,7 @@ import { throwErrors, CaseStatuses, CaseClientPostRequest, + AssociationType, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -69,6 +69,7 @@ export const transformNewCase = ({ }); type NewCommentArgs = CommentRequest & { + associationType: AssociationType; createdDate: string; email?: string | null; full_name?: string | null; @@ -76,6 +77,7 @@ type NewCommentArgs = CommentRequest & { }; export const transformNewComment = ({ + associationType, createdDate, email, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -83,6 +85,7 @@ export const transformNewComment = ({ username, ...comment }: NewCommentArgs): CommentAttributes => ({ + associationType, ...comment, created_at: createdDate, created_by: { email, full_name, username }, From edf97b327a2274b1d57d6a3c80507109adeaa443 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 14 Jan 2021 16:20:35 -0500 Subject: [PATCH 03/47] Handle alerts from multiple indices --- .../client/alerts/update_status.test.ts | 33 +-- .../server/client/alerts/update_status.ts | 4 +- .../cases/__snapshots__/update.test.ts.snap | 226 ++++++++++++++ .../case/server/client/cases/update.test.ts | 161 +--------- .../case/server/client/cases/update.ts | 20 +- .../case/server/client/comments/add.test.ts | 1 + .../case/server/client/comments/add.ts | 1 + x-pack/plugins/case/server/client/mocks.ts | 2 - x-pack/plugins/case/server/client/types.ts | 2 +- .../case/server/connectors/case/index.ts | 2 - x-pack/plugins/case/server/plugin.ts | 20 -- .../api/__fixtures__/mock_saved_objects.ts | 11 + .../api/__snapshots__/utils.test.ts.snap | 277 ++++++++++++++++++ .../__snapshots__/patch_cases.test.ts.snap | 135 +++++++++ .../routes/api/cases/patch_cases.test.ts | 89 +----- .../case/server/routes/api/utils.test.ts | 164 ++--------- .../case/server/services/alerts/index.test.ts | 17 +- .../case/server/services/alerts/index.ts | 16 +- 18 files changed, 749 insertions(+), 432 deletions(-) create mode 100644 x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap create mode 100644 x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap create mode 100644 x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap 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 986832899997b..16a8735efae27 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 @@ -9,26 +9,26 @@ import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__' import { createCaseClientWithMockSavedObjectsClient } from '../mocks'; describe('updateAlertsStatus', () => { - describe('happy path', () => { - test('it update the status of the alert correctly', async () => { - const savedObjectsClient = createMockSavedObjectsRepository(); + it('updates the status of the alert correctly', async () => { + const savedObjectsClient = createMockSavedObjectsRepository(); - const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - await caseClient.client.updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + await caseClient.client.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['.siem-signals']), + }); - expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ - ids: ['alert-id-1'], - index: '.siem-signals', - request: {}, - status: CaseStatuses.closed, - }); + expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ + ids: ['alert-id-1'], + indices: new Set(['.siem-signals']), + request: {}, + status: CaseStatuses.closed, }); + }); - // TODO: maybe test the plugin code instead? - /* describe('unhappy path', () => { + // TODO: maybe test the plugin code instead? + /* describe('unhappy path', () => { test('it throws when missing securitySolutionClient', async () => { expect.assertions(3); @@ -50,5 +50,4 @@ describe('updateAlertsStatus', () => { }); }); });*/ - }); }); 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 a2846528c0291..f0a09a5177e34 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -9,10 +9,10 @@ import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../typ export const updateAlertsStatus = ({ alertsService, request, - index, }: CaseClientFactoryArguments) => async ({ ids, status, + indices, }: CaseClientUpdateAlertsStatus): Promise => { - await alertsService.updateAlertsStatus({ ids, status, index, request }); + await alertsService.updateAlertsStatus({ ids, status, indices, request }); }; diff --git a/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap b/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap new file mode 100644 index 0000000000000..af88c8893e144 --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap @@ -0,0 +1,226 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`update happy path it change the status of case to in-progress correctly 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; + +exports[`update happy path it closes the case correctly 1`] = ` +Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; + +exports[`update happy path it opens the case correctly 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; + +exports[`update happy path it updates a case without a connector.id 1`] = ` +Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; + +exports[`update happy path it updates the connector correctly 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Bug", + "parent": null, + "priority": "Low", + }, + "id": "456", + "name": "My connector 2", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; 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 a3ddb5f61a5ce..80c429aa993b2 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -44,34 +44,7 @@ describe('update', () => { cases: patchCases, }); - expect(res).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions @@ -127,34 +100,7 @@ describe('update', () => { cases: patchCases, }); - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); }); test('it change the status of case to in-progress correctly', async () => { @@ -178,38 +124,7 @@ describe('update', () => { cases: patchCases, }); - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { - issueType: 'Task', - parent: null, - priority: 'High', - }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); }); test('it updates a case without a connector.id', async () => { @@ -233,34 +148,7 @@ describe('update', () => { cases: patchCases, }); - expect(res).toEqual([ - { - id: 'mock-no-connector_id', - comments: [], - totalComment: 0, - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', email: 'testemail@elastic.co', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.closed, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); }); test('it updates the connector correctly', async () => { @@ -289,42 +177,7 @@ describe('update', () => { cases: patchCases, }); - expect(res).toEqual([ - { - id: 'mock-id-3', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Bug', priority: 'Low', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - external_service: null, - title: 'Another bad one', - status: CaseStatuses.open, - tags: ['LOLBins'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'Awesome D00d', - email: 'd00d@awesome.com', - username: 'awesome', - }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); }); test('it updates alert status when the status is updated and syncAlerts=true', async () => { @@ -354,6 +207,7 @@ describe('update', () => { expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'closed', + indices: new Set(['test-index']), }); }); @@ -421,6 +275,7 @@ describe('update', () => { expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'open', + indices: new Set(['test-index']), }); }); @@ -491,11 +346,13 @@ describe('update', () => { expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(1, { ids: ['test-id', 'test-id-2'], status: 'open', + indices: new Set(['test-index', 'test-index-2']), }); expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(2, { ids: ['test-id', 'test-id-2'], status: 'closed', + indices: new Set(['test-index', 'test-index-2']), }); }); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 3dc3921c23cf4..70f263b375d4e 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -181,14 +181,26 @@ export const update = ({ perPage: totalComments.total, }, // The filter guarantees that the comments will be of type alert - })) as SavedObjectsFindResponse<{ alertId: string }>; + })) as SavedObjectsFindResponse<{ alertId: string | string[]; index: string }>; + + // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) + const idsAndIndices = caseComments.saved_objects.reduce( + (acc: { ids: string[]; indices: Set }, comment) => { + const alertId = comment.attributes.alertId; + const ids = Array.isArray(alertId) ? alertId : [alertId]; + acc.ids.push(...ids); + acc.indices.add(comment.attributes.index); + return acc; + }, + { ids: [], indices: new Set() } + ); - const commentIds = caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId); - if (commentIds.length > 0) { + if (idsAndIndices.ids.length > 0) { caseClient.updateAlertsStatus({ - ids: commentIds, + ids: idsAndIndices.ids, // Either there is a status update or the syncAlerts got turned on. status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, + indices: idsAndIndices.indices, }); } } 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 61da906651c24..03053ac498b7c 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -187,6 +187,7 @@ describe('addComment', () => { expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-alert'], status: 'open', + indices: new Set(['test-index']), }); }); diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 9248e5b1df1ba..d4b59a2f78e32 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -103,6 +103,7 @@ export const addComment = ({ caseClient.updateAlertsStatus({ ids, status: myCase.attributes.status, + indices: new Set([newComment.attributes.index]), }); } diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index d9a66c7dc5b0d..e070b4b6a4c92 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -75,8 +75,6 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ connectorMappingsService, userActionService, alertsService, - // TODO: refactor this to a variable across the tests - index: '.siem-signals', }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 2f9b802625b39..b58607eb60663 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -41,6 +41,7 @@ export interface CaseClientAddComment { export interface CaseClientUpdateAlertsStatus { ids: string[]; status: CaseStatuses; + indices: Set; } export interface CaseClientFactoryArguments { @@ -51,7 +52,6 @@ export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; - index: string; } export interface ConfigureFields { diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 11a0024275541..9cacaf46aa4f1 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -77,8 +77,6 @@ async function executor( connectorMappingsService, userActionService, alertsService, - // TODO: pass in the index - index: '.siem-index-todo', }); if (!supportedSubActions.includes(subAction)) { diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index cfe4e488452c9..b3dcf9b04901a 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -48,21 +48,6 @@ function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map((config) => config)); } -type PartialExceptFor = Partial & Pick; - -/** - * TODO: This is a temporary solution until we can figure out how to get access to - * the signals index while not using the context. - */ -function getSignalsIndex(context?: PartialExceptFor) { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); - } - - return securitySolutionClient.getSignalsIndex(); -} - export interface PluginsSetup { security: SecurityPluginSetup; actions: ActionsPluginSetup; @@ -147,8 +132,6 @@ export class CasePlugin { context: RequestHandlerContext, request: KibanaRequest ) => { - const index = getSignalsIndex(context); - return createCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, @@ -157,7 +140,6 @@ export class CasePlugin { connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, - index, }); }; @@ -188,7 +170,6 @@ export class CasePlugin { }): IContextProvider, typeof APP_ID> => { return async (context, request) => { const [{ savedObjects }] = await core.getStartServices(); - const index = getSignalsIndex(context); return { getCaseClient: () => { return createCaseClient({ @@ -199,7 +180,6 @@ export class CasePlugin { userActionService, alertsService, request, - index, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 3d4bc8f76815b..faa41e21fc5e0 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -6,7 +6,9 @@ import { SavedObject } from 'kibana/server'; import { + AssociationType, CaseStatuses, + CaseType, CommentAttributes, CommentType, ConnectorMappings, @@ -41,6 +43,7 @@ export const mockCases: Array> = [ title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.individual, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', @@ -78,6 +81,7 @@ export const mockCases: Array> = [ title: 'Damaging Data Destruction Detected', status: CaseStatuses.open, tags: ['Data Destruction'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', @@ -119,6 +123,7 @@ export const mockCases: Array> = [ title: 'Another bad one', status: CaseStatuses.open, tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -164,6 +169,7 @@ export const mockCases: Array> = [ status: CaseStatuses.closed, title: 'Another bad one', tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -226,6 +232,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-1', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T21:55:00.177Z', @@ -257,6 +264,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-2', attributes: { + associationType: AssociationType.case, comment: 'Well I decided to update my comment. So what? Deal with it.', type: CommentType.user, created_at: '2019-11-25T21:55:14.633Z', @@ -289,6 +297,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-3', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T22:32:30.608Z', @@ -320,6 +329,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-4', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index', alertId: 'test-id', @@ -352,6 +362,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-5', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index-2', alertId: 'test-id-2', diff --git a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap new file mode 100644 index 0000000000000..b1f104886aa71 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap @@ -0,0 +1,277 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Utils flattenCaseSavedObjects flattens correctly 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, +] +`; + +exports[`Utils flattenCaseSavedObjects it handles total comments correctly when caseId is not in extraCaseData 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, +] +`; + +exports[`Utils transformNewCase transform correctly 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, +} +`; + +exports[`Utils transformNewCase transform correctly with optional fields as null 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, +} +`; + +exports[`Utils transformNewCase transform correctly without optional fields 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, +} +`; + +exports[`Utils transformNewComment transform correctly with optional fields as null 1`] = ` +Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, +} +`; + +exports[`Utils transformNewComment transform correctly without optional fields 1`] = ` +Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, +} +`; + +exports[`Utils transformNewComment transforms correctly 1`] = ` +Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, +} +`; diff --git a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap new file mode 100644 index 0000000000000..a24997abcbaaf --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PATCH cases Change case to in-progress 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; + +exports[`PATCH cases Close a case 1`] = ` +Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; + +exports[`PATCH cases Open a case 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, +] +`; diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 6a6f5653375b8..6c538cab9f072 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -16,7 +16,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; +import { CaseStatuses } from '../../../../common/api'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -51,34 +51,7 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(response.payload).toMatchSnapshot(); }); it(`Open a case`, async () => { @@ -105,34 +78,7 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: '.jira', - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses.open, - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(response.payload).toMatchSnapshot(); }); it(`Change case to in-progress`, async () => { @@ -158,34 +104,7 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(response.payload).toMatchSnapshot(); }); it(`Patches a case without a connector.id`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 405da0df17542..91371fcc35aaa 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -23,7 +23,14 @@ import { mockCaseComments, mockCaseNoConnectorId, } from './__fixtures__/mock_saved_objects'; -import { ConnectorTypes, ESCaseConnector, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + ESCaseConnector, + CommentType, + CaseStatuses, + AssociationType, + CaseType, +} from '../../../common/api'; describe('Utils', () => { describe('transformNewCase', () => { @@ -39,7 +46,7 @@ describe('Utils', () => { }; it('transform correctly', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', @@ -49,46 +56,24 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchSnapshot(); }); it('transform correctly without optional fields', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', }; const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchSnapshot(); }); it('transform correctly with optional fields as null', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: null, @@ -98,18 +83,7 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchSnapshot(); }); }); @@ -122,19 +96,11 @@ describe('Utils', () => { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchSnapshot(); }); it('transform correctly without optional fields', () => { @@ -142,20 +108,12 @@ describe('Utils', () => { comment: 'A comment', type: CommentType.user as const, createdDate: '2020-04-09T09:43:51.778Z', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchSnapshot(); }); it('transform correctly with optional fields as null', () => { @@ -166,20 +124,12 @@ describe('Utils', () => { email: null, full_name: null, username: null, + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchSnapshot(); }); }); @@ -271,84 +221,14 @@ describe('Utils', () => { const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); }); it('it handles total comments correctly when caseId is not in extraCaseData', () => { const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); }); it('inserts missing connector', () => { 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 c0edf4516d3fb..e36f05dc8098c 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -16,7 +16,7 @@ describe('updateAlertsStatus', () => { let alertService: AlertServiceContract; const args = { ids: ['alert-id-1'], - index: '.siem-signals', + indices: new Set(['.siem-signals']), request: {} as KibanaRequest, status: CaseStatuses.closed, }; @@ -37,7 +37,7 @@ describe('updateAlertsStatus', () => { }, conflicts: 'abort', ignore_unavailable: true, - index: args.index, + index: [...args.indices], }); }); @@ -52,6 +52,19 @@ describe('updateAlertsStatus', () => { test('it throws when service is not initialized and try to update the status', async () => { await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); }); + + it('throws an error if no valid indices are provided', async () => { + alertService.initialize(esClientMock); + + expect(async () => { + await alertService.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['']), + request: {} as KibanaRequest, + }); + }).rejects.toThrow(); + }); }); }); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 4fb98278b8afa..a1a89ed5748d6 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -15,7 +15,7 @@ interface UpdateAlertsStatusArgs { request: KibanaRequest; ids: string[]; status: CaseStatuses; - index: string; + indices: Set; } export class AlertService { @@ -33,14 +33,24 @@ export class AlertService { this.esClient = esClient; } - public async updateAlertsStatus({ request, ids, status, index }: UpdateAlertsStatusArgs) { + public async updateAlertsStatus({ request, ids, status, indices }: UpdateAlertsStatusArgs) { if (!this.isInitialized) { throw new Error('AlertService not initialized'); } + /** + * remove empty strings from the indices, I'm not sure how likely this is but in the case that + * the document doesn't have _index set the security_solution code sets the value to an empty string + * instead + */ + const sanitizedIndices = [...indices].filter((index) => index !== ''); + if (sanitizedIndices.length <= 0) { + throw new Error('No valid indices found to update the alerts status'); + } + // The above check makes sure that esClient is defined. const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ - index, + index: sanitizedIndices, conflicts: 'abort', body: { script: { From ada3eb9ede1da6202e300b989fb1170b3795c9fa Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Fri, 15 Jan 2021 17:10:53 -0500 Subject: [PATCH 04/47] Adding flow for adding a sub case --- x-pack/plugins/case/common/api/cases/case.ts | 2 +- x-pack/plugins/case/common/api/cases/index.ts | 1 + .../plugins/case/common/api/cases/sub_case.ts | 79 +++++++++++++++++++ .../case/server/client/comments/add.ts | 30 ++++++- x-pack/plugins/case/server/client/mocks.ts | 3 +- .../case/server/connectors/case/index.ts | 4 +- .../routes/api/__fixtures__/route_contexts.ts | 2 - .../plugins/case/server/routes/api/utils.ts | 15 ++++ .../case/server/saved_object_types/index.ts | 1 + .../{child_case.ts => sub_case.ts} | 27 +------ x-pack/plugins/case/server/services/index.ts | 60 +++++++++++++- 11 files changed, 192 insertions(+), 32 deletions(-) create mode 100644 x-pack/plugins/case/common/api/cases/sub_case.ts rename x-pack/plugins/case/server/saved_object_types/{child_case.ts => sub_case.ts} (67%) diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 861baa0cc58a1..49a080f7274f6 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -18,7 +18,7 @@ export enum CaseStatuses { closed = 'closed', } -const CaseStatusRt = rt.union([ +export const CaseStatusRt = rt.union([ rt.literal(CaseStatuses.open), rt.literal(CaseStatuses['in-progress']), rt.literal(CaseStatuses.closed), diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index ffcd4d25eecf5..97c428ddf8947 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -9,3 +9,4 @@ export * from './configure'; export * from './comment'; export * from './status'; export * from './user_actions'; +export * from './sub_case'; diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts new file mode 100644 index 0000000000000..56ed3ef1c0caa --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { NumberFromString } from '../saved_object'; +import { UserRT } from '../user'; +import { CommentResponseRt } from './comment'; +import { CasesStatusResponseRt } from './status'; +import { CaseStatusRt } from './case'; + +// TODO: comments + +const SubCaseBasicRt = rt.type({ + status: CaseStatusRt, +}); + +export const SubCaseAttributesRt = rt.intersection([ + SubCaseBasicRt, + rt.type({ + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), + created_at: rt.string, + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const SubCasesFindRequestRt = rt.partial({ + status: CaseStatusRt, + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + fields: rt.array(rt.string), + page: NumberFromString, + perPage: NumberFromString, + search: rt.string, + searchFields: rt.array(rt.string), + sortField: rt.string, + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +}); + +export const SubCaseResponseRt = rt.intersection([ + SubCaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + version: rt.string, + }), + rt.partial({ + comments: rt.array(CommentResponseRt), + }), +]); + +export const SubCasesFindResponseRt = rt.intersection([ + rt.type({ + cases: rt.array(SubCaseResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, + }), + CasesStatusResponseRt, +]); + +export const SubCasePatchRequestRt = rt.intersection([ + rt.partial(SubCaseBasicRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export const SubCasesPatchRequestRt = rt.type({ cases: rt.array(SubCasePatchRequestRt) }); +export const SubCasesResponseRt = rt.array(SubCaseResponseRt); + +export type SubCaseAttributes = rt.TypeOf; +export type SubCaseResponse = rt.TypeOf; +export type SubCasesResponse = rt.TypeOf; +export type SubCasesFindResponse = rt.TypeOf; +export type SubCasePatchRequest = rt.TypeOf; +export type SubCasesPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index d4b59a2f78e32..b9e8046dc4569 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -9,6 +9,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 { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; import { @@ -19,11 +20,34 @@ import { CommentType, CaseStatuses, AssociationType, + CaseType, + SubCaseAttributes, } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; import { CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { CaseServiceSetup } from '../../services'; + +async function getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt, +}: { + caseService: CaseServiceSetup; + savedObjectsClient: SavedObjectsClientContract; + caseId: string; + createdAt: string; +}): Promise> { + const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); + if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { + return mostRecentSubCase; + } + + // else need to create a new sub case + return caseService.createSubCase(savedObjectsClient, createdAt, caseId); +} export const addComment = ({ savedObjectsClient, @@ -41,12 +65,16 @@ export const addComment = ({ ); decodeComment(comment); + const createdDate = new Date().toISOString(); const myCase = await caseService.getCase({ client: savedObjectsClient, caseId, }); + if (myCase.attributes.type === CaseType.parent) { + // get or create a sub case + } /** * TODO: check if myCase is a 'case' or a 'subCase' * if case then the association type should be 'case' @@ -63,7 +91,6 @@ export const addComment = ({ // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); - const createdDate = new Date().toISOString(); const [newComment, updatedCase] = await Promise.all([ caseService.postNewComment({ @@ -95,6 +122,7 @@ export const addComment = ({ }), ]); + // TODO: need to figure out what we do in this case for alerts that are attached to sub cases // If the case is synced with alerts the newly attached alert must match the status of the case. if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { const ids = Array.isArray(newComment.attributes.alertId) diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index e070b4b6a4c92..2925d9c0041c5 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { omit } from 'lodash/fp'; -import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; +import { KibanaRequest } from 'kibana/server'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsClientMock } from '../../../actions/server/mocks'; import { diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 9cacaf46aa4f1..fdf6feed54986 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 { KibanaRequest, RequestHandlerContext } from 'kibana/server'; +import { KibanaRequest } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest, CaseType } from '../../../common/api'; import { createCaseClient } from '../../client'; @@ -71,6 +71,7 @@ async function executor( const { savedObjectsClient } = services; const caseClient = createCaseClient({ savedObjectsClient, + // TODO: refactor this request: {} as KibanaRequest, caseService, caseConfigureService, @@ -88,6 +89,7 @@ async function executor( if (subAction === 'create') { data = await caseClient.create({ // TODO: is it possible for the action framework to create an individual case that is not associated with sub cases? + // TODO: I think this should be an individual case... theCase: { ...(subActionParams as CasePostRequest), type: CaseType.parent }, }); } 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 7f3fb6b8bdd16..f7bcedb503c7a 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 @@ -64,8 +64,6 @@ export const createRouteContext = async (client: any, badAuth = false) => { getUserActions: jest.fn(), }, alertsService, - // TODO: move this to a variable shared across tests - index: '.siem-signals', }); return context; diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 4574cea6a1a63..3f4c4a78a1357 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -35,11 +35,26 @@ import { CaseStatuses, CaseClientPostRequest, AssociationType, + SubCaseAttributes, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase, TotalCommentByCase } from './types'; +// TODO: refactor these functions to a common location, this is used by the caseClient too + +// TODO: maybe inline this +export const transformNewSubCase = (createdAt: string): SubCaseAttributes => { + return { + closed_at: null, + closed_by: null, + created_at: createdAt, + status: CaseStatuses.open, + updated_at: null, + updated_by: null, + }; +}; + export const transformNewCase = ({ connector, createdDate, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 36d38cad797b6..9f26edd828a4e 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -5,6 +5,7 @@ */ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; +export { subCaseSavedObjectType, SUB_CASE_SAVED_OBJECT } from './sub_case'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/child_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts similarity index 67% rename from x-pack/plugins/case/server/saved_object_types/child_case.ts rename to x-pack/plugins/case/server/saved_object_types/sub_case.ts index a97170ccae56e..f3daad5913c07 100644 --- a/x-pack/plugins/case/server/saved_object_types/child_case.ts +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -7,10 +7,10 @@ import { SavedObjectsType } from 'src/core/server'; import { caseMigrations } from './migrations'; -export const CHILD_CASE_SAVED_OBJECT = 'child_case'; +export const SUB_CASE_SAVED_OBJECT = 'sub_case'; -export const caseSavedObjectType: SavedObjectsType = { - name: CHILD_CASE_SAVED_OBJECT, +export const subCaseSavedObjectType: SavedObjectsType = { + name: SUB_CASE_SAVED_OBJECT, hidden: false, namespaceType: 'single', mappings: { @@ -34,19 +34,6 @@ export const caseSavedObjectType: SavedObjectsType = { created_at: { type: 'date', }, - created_by: { - properties: { - username: { - type: 'keyword', - }, - full_name: { - type: 'keyword', - }, - email: { - type: 'keyword', - }, - }, - }, status: { type: 'keyword', }, @@ -66,14 +53,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - settings: { - properties: { - // TODO do we need this? - syncAlerts: { - type: 'boolean', - }, - }, - }, }, }, // TODO migration diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index e75b597fa7af2..678f6432f9bde 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -24,8 +24,14 @@ import { SavedObjectFindOptions, User, CommentPatchAttributes, + SubCaseAttributes, } from '../../common/api'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; +import { transformNewSubCase } from '../routes/api/utils'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; @@ -102,6 +108,8 @@ interface GetUserArgs { interface CaseServiceDeps { authentication: SecurityPluginSetup['authc'] | null; } + +// TODO: split this up into comments, case, sub case, possibly more? export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; @@ -119,11 +127,61 @@ export interface CaseServiceSetup { patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; patchComments(args: PatchComments): Promise>; + getMostRecentSubCase( + client: SavedObjectsClientContract, + caseId: string + ): Promise | undefined>; + createSubCase( + client: SavedObjectsClientContract, + createdAt: string, + caseId: string + ): Promise>; } export class CaseService { constructor(private readonly log: Logger) {} public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ + createSubCase: async ( + client: SavedObjectsClientContract, + createdAt: string, + caseId: string + ): Promise> => { + try { + this.log.debug(`Attempting to POST a new sub case`); + return await client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase(createdAt), { + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], + }); + } catch (error) { + this.log.debug(`Error on POST a new sub case: ${error}`); + throw error; + } + }, + getMostRecentSubCase: async (client: SavedObjectsClientContract, caseId: string) => { + try { + this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); + const subCases: SavedObjectsFindResponse = await client.find({ + perPage: 1, + sortField: 'created_at', + sortOrder: 'desc', + type: SUB_CASE_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + if (subCases.saved_objects.length <= 0) { + return; + } + + return subCases.saved_objects[0]; + } catch (error) { + this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); + throw error; + } + }, deleteCase: async ({ client, caseId }: GetCaseArgs) => { try { this.log.debug(`Attempting to GET case ${caseId}`); From 695df14d894f62e04831947af8c6523e484213e7 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 19 Jan 2021 17:34:49 -0500 Subject: [PATCH 05/47] Making progress on creating alerts from rules --- x-pack/plugins/case/common/api/cases/case.ts | 1 + .../plugins/case/common/api/cases/comment.ts | 46 ++++- .../case/server/client/comments/add.ts | 178 ++++++++++++++++-- x-pack/plugins/case/server/client/index.ts | 3 +- x-pack/plugins/case/server/client/types.ts | 11 ++ .../case/server/connectors/case/index.ts | 2 +- .../case/server/connectors/case/schema.ts | 20 +- .../case/server/connectors/case/types.ts | 4 +- .../routes/api/cases/comments/post_comment.ts | 1 + .../plugins/case/server/routes/api/utils.ts | 103 ++++++++-- .../case/server/saved_object_types/cases.ts | 13 ++ .../server/saved_object_types/migrations.ts | 10 +- x-pack/plugins/case/server/services/index.ts | 45 +++++ .../components/connectors/case/fields.tsx | 3 +- 14 files changed, 399 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 49a080f7274f6..2dfe3eef579de 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -71,6 +71,7 @@ export const CaseAttributesRt = rt.intersection([ rt.type({ closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRT, rt.null]), + converted_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, external_service: CaseFullExternalServiceRt, diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 48483c7d08802..e338d6ce585e0 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -35,6 +35,7 @@ export const CommentAttributesBasicRt = rt.type({ export enum CommentType { user = 'user', alert = 'alert', + alertGroup = 'alert_group', } export const ContextTypeUserRt = rt.type({ @@ -48,12 +49,48 @@ export const ContextTypeAlertRt = rt.type({ index: rt.string, }); +const AlertIDRt = rt.type({ + _id: rt.string, +}); + +export const ContextTypeAlertGroupRt = rt.type({ + type: rt.literal(CommentType.alertGroup), + alerts: rt.union([rt.array(AlertIDRt), AlertIDRt]), + index: rt.string, + ruleId: rt.string, +}); + +export const ContextTypeAlertGroupAttributesRt = rt.type({ + type: rt.literal(CommentType.alertGroup), + alertIds: rt.union([rt.array(rt.string), rt.string]), + index: rt.string, + ruleId: rt.string, +}); + const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); -const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); +const AttributesTypeAlertGroupRt = rt.intersection([ + ContextTypeAlertGroupAttributesRt, + CommentAttributesBasicRt, +]); +const CommentAttributesRt = rt.union([ + AttributesTypeUserRt, + AttributesTypeAlertsRt, + AttributesTypeAlertGroupRt, +]); const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt]); +/** + * The internal comment request includes all types, this is to allow creation of all types of comments when using a rule + * but limits the external public HTTP API to only User and Alert + */ +export const InternalCommentRequestRt = rt.union([ + ContextTypeUserRt, + ContextTypeAlertRt, + ContextTypeAlertGroupRt, +]); + export const CommentRequestRt = ContextBasicRt; export const CommentResponseRt = rt.intersection([ @@ -96,6 +133,7 @@ export const CommentsResponseRt = rt.type({ export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; +export type InternalCommentRequest = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; export type AllCommentsResponse = rt.TypeOf; @@ -104,3 +142,9 @@ export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; export type CommentRequestAlertType = rt.TypeOf; + +// TODO: make sure this is right +export type CommentRequestAlertGroupType = rt.TypeOf; +export type NeedToFixCommentRequestAlertGroupType = rt.TypeOf< + typeof ContextTypeAlertGroupAttributesRt +>; diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index b9e8046dc4569..4ceed53c4e0ac 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,7 +10,12 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; -import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; +import { + decodeComment, + flattenCaseSavedObject, + flattenSubCaseSavedObject, + transformNewComment, +} from '../../routes/api/utils'; import { throwErrors, @@ -22,11 +27,20 @@ import { AssociationType, CaseType, SubCaseAttributes, + SubCaseResponseRt, + SubCaseResponse, + InternalCommentRequestRt, + InternalCommentRequest, + CommentRequest, } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; -import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; -import { CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { + CaseClientAddComment, + CaseClientAddInternalComment, + CaseClientFactoryArguments, +} from '../types'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; async function getSubCase({ @@ -49,6 +63,150 @@ async function getSubCase({ return caseService.createSubCase(savedObjectsClient, createdAt, caseId); } +function isUserOrAlertComment(comment: InternalCommentRequest): comment is CommentRequest { + return comment.type === CommentType.user || comment.type === CommentType.alert; +} + +export const addCommentFromRule = ({ + savedObjectsClient, + caseService, + userActionService, + request, +}: CaseClientFactoryArguments) => async ({ + caseClient, + caseId, + comment, +}: CaseClientAddInternalComment): Promise => { + const query = pipe( + InternalCommentRequestRt.decode(comment), + fold(throwErrors(Boom.badRequest), identity) + ); + + if (isUserOrAlertComment(comment)) { + return caseClient.addComment({ caseClient, caseId, comment }); + } + + decodeComment(comment); + const createdDate = new Date().toISOString(); + + const myCase = await caseService.getCase({ + client: savedObjectsClient, + caseId, + }); + + if (query.type === CommentType.alertGroup && myCase.attributes.type !== CaseType.parent) { + throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); + } + + const subCase = await getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt: createdDate, + }); + + const userDetails = { + username: myCase.attributes.converted_by?.username, + full_name: myCase.attributes.converted_by?.full_name, + email: myCase.attributes.converted_by?.email, + }; + + const [newComment, , updatedSubCase] = await Promise.all([ + // TODO: probably move this to the service layer + caseService.postNewComment({ + client: savedObjectsClient, + attributes: transformNewComment({ + associationType: AssociationType.subCase, + createdDate, + ...query, + ...userDetails, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: myCase.id, + }, + { + type: SUB_CASE_SAVED_OBJECT, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + id: subCase.id, + }, + ], + }), + caseService.patchCase({ + client: savedObjectsClient, + caseId, + updatedAttributes: { + updated_at: createdDate, + updated_by: { + ...userDetails, + }, + }, + version: myCase.version, + }), + caseService.patchSubCase({ + client: savedObjectsClient, + subCaseId: subCase.id, + updatedAttributes: { + updated_at: createdDate, + updated_by: { + ...userDetails, + }, + }, + version: subCase.version, + }), + ]); + + // TODO: handle updating the alert group status + + const totalCommentsFindBySubCase = await caseService.getAllSubCaseComments( + savedObjectsClient, + subCase.id, + { + fields: [], + page: 1, + perPage: 1, + } + ); + + const [comments] = await Promise.all([ + caseService.getAllSubCaseComments(savedObjectsClient, subCase.id, { + fields: [], + page: 1, + perPage: totalCommentsFindBySubCase.total, + }), + userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { ...userDetails }, + caseId: myCase.id, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }), + ]); + + // TODO: should we return anything? This will only return the sub case and comments + return SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: { + ...subCase, + ...updatedSubCase, + attributes: { ...subCase.attributes, ...updatedSubCase.attributes }, + version: updatedSubCase.version ?? subCase.version, + references: subCase.references, + }, + comments: comments.saved_objects, + }) + ); +}; + export const addComment = ({ savedObjectsClient, caseService, @@ -72,18 +230,6 @@ export const addComment = ({ caseId, }); - if (myCase.attributes.type === CaseType.parent) { - // get or create a sub case - } - /** - * TODO: check if myCase is a 'case' or a 'subCase' - * if case then the association type should be 'case' - * if subCase then the association should be 'subCase' - * - * Alternatively we could not save both references...need to figure out what the tradeoff is - */ - const associationType = AssociationType.case; - // An alert cannot be attach to a closed case. if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { throw Boom.badRequest('Alert cannot be attached to a closed case'); @@ -96,7 +242,7 @@ export const addComment = ({ caseService.postNewComment({ client: savedObjectsClient, attributes: transformNewComment({ - associationType, + associationType: AssociationType.case, createdDate, ...query, username, diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 3bd7d71d7dd4d..e5d1b9c502d54 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -7,7 +7,7 @@ import { CaseClientFactoryArguments, CaseClient } from './types'; import { create } from './cases/create'; import { update } from './cases/update'; -import { addComment } from './comments/add'; +import { addComment, addCommentFromRule } from './comments/add'; import { getFields } from './configure/get_fields'; import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; @@ -19,6 +19,7 @@ export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseCl create: create(clientArgs), update: update(clientArgs), addComment: addComment(clientArgs), + addCommentFromRule: addCommentFromRule(clientArgs), getFields: getFields(), getMappings: getMappings(clientArgs), updateAlertsStatus: updateAlertsStatus(clientArgs), diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index b58607eb60663..ce417dc60f30d 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -15,6 +15,8 @@ import { CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, + InternalCommentRequest, + SubCaseResponse, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -38,6 +40,12 @@ export interface CaseClientAddComment { comment: CommentRequest; } +export interface CaseClientAddInternalComment { + caseClient: CaseClient; + caseId: string; + comment: InternalCommentRequest; +} + export interface CaseClientUpdateAlertsStatus { ids: string[]; status: CaseStatuses; @@ -61,6 +69,9 @@ export interface ConfigureFields { } export interface CaseClient { addComment: (args: CaseClientAddComment) => Promise; + addCommentFromRule: ( + args: CaseClientAddInternalComment + ) => Promise; create: (args: CaseClientCreate) => Promise; getFields: (args: ConfigureFields) => Promise; getMappings: (args: MappingsClient) => Promise; diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index fdf6feed54986..4e5fc33f5c248 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -111,7 +111,7 @@ async function executor( if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseClient, caseId, comment }); + data = await caseClient.addCommentFromRule({ caseClient, caseId, comment }); } return { status: 'ok', data: data ?? {}, actionId }; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index d350adc1aa757..d6633c27cf620 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -31,6 +31,20 @@ const ContextTypeUserSchema = schema.object({ * Issue: https://github.com/elastic/kibana/issues/85750 * */ +const AlertIDSchema = schema.object( + { + _id: schema.string(), + }, + { unknowns: 'ignore' } +); + +const ContextTypeAlertGroupSchema = schema.object({ + type: schema.literal(CommentType.alertGroup), + alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), + index: schema.string(), + ruleId: schema.string(), +}); + const ContextTypeAlertSchema = schema.object({ type: schema.literal(CommentType.alert), // allowing either an array or a single value to preserve the previous API of attaching a single alert ID @@ -38,7 +52,11 @@ const ContextTypeAlertSchema = schema.object({ index: schema.string(), }); -export const CommentSchema = schema.oneOf([ContextTypeUserSchema, ContextTypeAlertSchema]); +export const CommentSchema = schema.oneOf([ + ContextTypeUserSchema, + ContextTypeAlertSchema, + ContextTypeAlertGroupSchema, +]); const JiraFieldsSchema = schema.object({ issueType: schema.string(), diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index da15f64a5718f..4a7cfc6d0a525 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -15,7 +15,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse, SubCaseResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -28,7 +28,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse | SubCaseResponse; export type CaseActionType = ActionType< CaseConfiguration, 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 139fb7c5f27a4..33aded8fee7f4 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 @@ -28,6 +28,7 @@ export function initPostCommentApi({ router }: RouteDeps) { const caseClient = context.case.getCaseClient(); const caseId = request.params.case_id; + // TODO: is it bad if this allows alert groups? Currently it will not allow it const comment = request.body as CommentRequest; try { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 3f4c4a78a1357..9007e2c628308 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -36,6 +36,11 @@ import { CaseClientPostRequest, AssociationType, SubCaseAttributes, + SubCaseResponse, + InternalCommentRequest, + CommentRequestAlertGroupType, + ContextTypeAlertGroupRt, + NeedToFixCommentRequestAlertGroupType, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -83,7 +88,7 @@ export const transformNewCase = ({ updated_by: null, }); -type NewCommentArgs = CommentRequest & { +type NewCommentArgs = InternalCommentRequest & { associationType: AssociationType; createdDate: string; email?: string | null; @@ -99,16 +104,47 @@ export const transformNewComment = ({ full_name, username, ...comment -}: NewCommentArgs): CommentAttributes => ({ - associationType, - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, -}); +}: NewCommentArgs): CommentAttributes => { + if (isAlertGroupRequest(comment)) { + const ids: string[] = []; + if (Array.isArray(comment.alerts)) { + ids.push( + ...comment.alerts.map((alert: { _id: string }) => { + return alert._id; + }) + ); + } else { + ids.push(comment.alerts._id); + } + + return { + associationType, + // TODO: if this is an alert group, reduce to an array of ids + alertIds: ids, + index: comment.index, + ruleId: comment.ruleId, + type: comment.type, + created_at: createdDate, + created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + } else { + return { + associationType, + // TODO: if this is an alert group, reduce to an array of ids + ...comment, + created_at: createdDate, + created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; + } +}; export function wrapError(error: any): CustomHttpResponseOptions { const options = { statusCode: error.statusCode ?? 500 }; @@ -168,6 +204,22 @@ export const flattenCaseSavedObject = ({ connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), }); +export const flattenSubCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = comments.length, +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; +}): SubCaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + ...savedObject.attributes, +}); + export const transformComments = ( comments: SavedObjectsFindResponse ): CommentsResponse => ({ @@ -209,32 +261,51 @@ export const sortToSnake = (sortField: string): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { +const isUserContext = ( + context: InternalCommentRequest | CommentAttributes +): context is CommentRequestUserType => { return context.type === CommentType.user; }; -const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { +const isAlertContext = ( + context: InternalCommentRequest | CommentAttributes +): context is CommentRequestAlertType => { return context.type === CommentType.alert; }; -export const decodeComment = (comment: CommentRequest) => { +const isAlertGroupRequest = ( + context: InternalCommentRequest +): context is CommentRequestAlertGroupType => { + return context.type === CommentType.alertGroup; +}; + +export const decodeComment = (comment: InternalCommentRequest) => { if (isUserContext(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); } else if (isAlertContext(comment)) { pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isAlertGroupRequest(comment)) { + pipe(excess(ContextTypeAlertGroupRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; export const getCommentContextFromAttributes = ( attributes: CommentAttributes -): CommentRequestUserType | CommentRequestAlertType => +): CommentRequestUserType | CommentRequestAlertType | NeedToFixCommentRequestAlertGroupType => isUserContext(attributes) ? { type: CommentType.user, comment: attributes.comment, } - : { + : isAlertContext(attributes) + ? { type: CommentType.alert, alertId: attributes.alertId, index: attributes.index, + } + : { + type: CommentType.alertGroup, + alertIds: attributes.alertIds, + index: attributes.index, + ruleId: attributes.ruleId, }; diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index a9bc91b31f283..99d1a16f9ba54 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -31,6 +31,19 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, + converted_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, created_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index 23e7c27871038..48b97a91758c7 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -48,8 +48,13 @@ interface SanitizedCaseSettings { }; } -interface SanitizedCaseType { +interface SanitizedConvertedByCaseType { type: string; + converted_by: { + username: string; + full_name: string; + email: string; + } | null; } export const caseMigrations = { @@ -88,12 +93,13 @@ export const caseMigrations = { }, '7.12.0': ( doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { + ): SavedObjectSanitizedDoc => { return { ...doc, attributes: { ...doc.attributes, type: CaseType.individual, + converted_by: null, }, references: doc.references || [], }; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 678f6432f9bde..1ad91adc15f9a 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -100,6 +100,13 @@ interface PatchComments extends ClientArgs { comments: PatchComment[]; } +interface PatchSubCase { + client: SavedObjectsClientContract; + subCaseId: string; + updatedAttributes: Partial; + version?: string; +} + interface GetUserArgs { request: KibanaRequest; response?: KibanaResponseFactory; @@ -114,7 +121,13 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; findCases(args: FindCasesArgs): Promise>; + // TODO: refactor these because they use the same parameters and implementation getAllCaseComments(args: FindCommentsArgs): Promise>; + getAllSubCaseComments( + client: SavedObjectsClientContract, + id: string, + options?: SavedObjectFindOptions + ): Promise>; getCase(args: GetCaseArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; @@ -136,6 +149,7 @@ export interface CaseServiceSetup { createdAt: string, caseId: string ): Promise>; + patchSubCase(args: PatchSubCase): Promise>; } export class CaseService { @@ -251,6 +265,23 @@ export class CaseService { throw error; } }, + getAllSubCaseComments: async ( + client: SavedObjectsClientContract, + id: string, + options?: SavedObjectFindOptions + ) => { + try { + this.log.debug(`Attempting to GET all comments for sub case ${id}`); + return await client.find({ + ...options, + type: CASE_COMMENT_SAVED_OBJECT, + hasReference: { type: SUB_CASE_SAVED_OBJECT, id }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + throw error; + } + }, getReporters: async ({ client }: ClientArgs) => { try { this.log.debug(`Attempting to GET all reporters`); @@ -377,5 +408,19 @@ export class CaseService { throw error; } }, + patchSubCase: async ({ client, subCaseId, updatedAttributes, version }: PatchSubCase) => { + try { + this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); + return await client.update( + CASE_SAVED_OBJECT, + subCaseId, + { ...updatedAttributes }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); + throw error; + } + }, }); } diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/fields.tsx index 91087138e52d5..9a83607be214a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/fields.tsx @@ -26,8 +26,9 @@ const Container = styled.div` const defaultAlertComment = { type: CommentType.alert, - alertId: '{{context.rule.id}}', + alerts: '{{context.alerts}}', index: '{{context.rule.output_index}}', + ruleId: '{{context.rule.id}}', }; const CaseParamsFields: React.FunctionComponent> = ({ From 7c012c79c9682f83ae0b5878e2da9a7c28583b0c Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 20 Jan 2021 16:50:36 -0500 Subject: [PATCH 06/47] Refactored add comment to handle case and sub case --- x-pack/plugins/case/common/api/cases/case.ts | 2 + .../plugins/case/common/api/cases/sub_case.ts | 21 ++- .../plugins/case/common/api/saved_object.ts | 1 + .../case/server/client/cases/update.ts | 4 +- .../case/server/client/comments/add.ts | 161 +++++++++++------- .../server/client/comments/combined_case.ts | 125 ++++++++++++++ x-pack/plugins/case/server/client/types.ts | 7 +- .../case/server/connectors/case/types.ts | 4 +- .../api/cases/comments/delete_all_comments.ts | 2 +- .../api/cases/comments/find_comments.ts | 4 +- .../api/cases/comments/get_all_comment.ts | 2 +- .../api/cases/comments/patch_comment.ts | 6 +- .../server/routes/api/cases/delete_cases.ts | 4 +- .../server/routes/api/cases/find_cases.ts | 2 +- .../case/server/routes/api/cases/get_case.ts | 4 +- .../case/server/routes/api/cases/push_case.ts | 6 +- .../plugins/case/server/routes/api/utils.ts | 19 +++ x-pack/plugins/case/server/services/index.ts | 51 +++--- 18 files changed, 313 insertions(+), 112 deletions(-) create mode 100644 x-pack/plugins/case/server/client/comments/combined_case.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 2dfe3eef579de..a83d010918258 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -132,6 +132,7 @@ export const CasesFindResponseRt = rt.intersection([ ]); export const CasePatchRequestRt = rt.intersection([ + // TODO: need to refactor so type is not updatable using patch rt.partial(CaseBasicRt.props), rt.type({ id: rt.string, version: rt.string }), ]); @@ -150,6 +151,7 @@ export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CaseExternalServiceRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; +export type CaseSettings = rt.TypeOf; export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; export type ESCasePatchRequest = Omit & { diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts index 56ed3ef1c0caa..7c8ecb486258a 100644 --- a/x-pack/plugins/case/common/api/cases/sub_case.ts +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -10,7 +10,7 @@ import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; -import { CaseStatusRt } from './case'; +import { CaseAttributesRt, CaseStatusRt } from './case'; // TODO: comments @@ -29,6 +29,11 @@ export const SubCaseAttributesRt = rt.intersection([ }), ]); +export const CombinedCaseAttributesRt = rt.type({ + subCase: rt.union([SubCaseAttributesRt, rt.null]), + caseCollection: CaseAttributesRt, +}); + export const SubCasesFindRequestRt = rt.partial({ status: CaseStatusRt, defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), @@ -41,6 +46,18 @@ export const SubCasesFindRequestRt = rt.partial({ sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), }); +export const CombinedCaseResponseRt = rt.intersection([ + CombinedCaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + version: rt.string, + }), + rt.partial({ + comments: rt.array(CommentResponseRt), + }), +]); + export const SubCaseResponseRt = rt.intersection([ SubCaseAttributesRt, rt.type({ @@ -71,6 +88,8 @@ export const SubCasePatchRequestRt = rt.intersection([ export const SubCasesPatchRequestRt = rt.type({ cases: rt.array(SubCasePatchRequestRt) }); export const SubCasesResponseRt = rt.array(SubCaseResponseRt); +export type CombinedCaseAttributes = rt.TypeOf; +export type CombinedCaseResponse = rt.TypeOf; export type SubCaseAttributes = rt.TypeOf; export type SubCaseResponse = rt.TypeOf; export type SubCasesResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts index 58cbc676d2e6a..7535e51abd6fa 100644 --- a/x-pack/plugins/case/common/api/saved_object.ts +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -21,6 +21,7 @@ export const NumberFromString = new rt.Type( export const SavedObjectFindOptionsRt = rt.partial({ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + hasReferenceOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), hasReference: rt.type({ id: rt.string, type: rt.string }), fields: rt.array(rt.string), filter: rt.string, diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 70f263b375d4e..1f20e0e29deb3 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -162,7 +162,7 @@ export const update = ({ const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); const totalComments = await caseService.getAllCaseComments({ client: savedObjectsClient, - caseId: theCase.id, + id: theCase.id, options: { fields: [], filter: 'cases-comments.attributes.type: alert', @@ -173,7 +173,7 @@ export const update = ({ const caseComments = (await caseService.getAllCaseComments({ client: savedObjectsClient, - caseId: theCase.id, + id: theCase.id, options: { fields: [], filter: 'cases-comments.attributes.type: alert', diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 4ceed53c4e0ac..047c67057a1ec 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -12,14 +12,13 @@ import { identity } from 'fp-ts/lib/function'; import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import { decodeComment, - flattenCaseSavedObject, + flattenCombinedCaseSavedObject, flattenSubCaseSavedObject, transformNewComment, } from '../../routes/api/utils'; import { throwErrors, - CaseResponseRt, CommentRequestRt, CaseResponse, CommentType, @@ -32,6 +31,8 @@ import { InternalCommentRequestRt, InternalCommentRequest, CommentRequest, + CombinedCaseResponseRt, + CombinedCaseResponse, } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; @@ -42,6 +43,7 @@ import { } from '../types'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; +import { CombinedCase } from './combined_case'; async function getSubCase({ caseService, @@ -76,7 +78,7 @@ export const addCommentFromRule = ({ caseClient, caseId, comment, -}: CaseClientAddInternalComment): Promise => { +}: CaseClientAddInternalComment): Promise => { const query = pipe( InternalCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) @@ -91,7 +93,7 @@ export const addCommentFromRule = ({ const myCase = await caseService.getCase({ client: savedObjectsClient, - caseId, + id: caseId, }); if (query.type === CommentType.alertGroup && myCase.attributes.type !== CaseType.parent) { @@ -111,7 +113,7 @@ export const addCommentFromRule = ({ email: myCase.attributes.converted_by?.email, }; - const [newComment, , updatedSubCase] = await Promise.all([ + const [newComment, updatedCase, updatedSubCase] = await Promise.all([ // TODO: probably move this to the service layer caseService.postNewComment({ client: savedObjectsClient, @@ -160,21 +162,25 @@ export const addCommentFromRule = ({ // TODO: handle updating the alert group status - const totalCommentsFindBySubCase = await caseService.getAllSubCaseComments( - savedObjectsClient, - subCase.id, - { + const totalCommentsFindBySubCase = await caseService.getAllCaseComments({ + client: savedObjectsClient, + id: subCase.id, + options: { fields: [], page: 1, perPage: 1, - } - ); + }, + }); const [comments] = await Promise.all([ - caseService.getAllSubCaseComments(savedObjectsClient, subCase.id, { - fields: [], - page: 1, - perPage: totalCommentsFindBySubCase.total, + caseService.getAllCaseComments({ + client: savedObjectsClient, + id: subCase.id, + options: { + fields: [], + page: 1, + perPage: totalCommentsFindBySubCase.total, + }, }), userActionService.postUserActions({ client: savedObjectsClient, @@ -183,7 +189,7 @@ export const addCommentFromRule = ({ action: 'create', actionAt: createdDate, actionBy: { ...userDetails }, - caseId: myCase.id, + caseId: subCase.id, commentId: newComment.id, fields: ['comment'], newValue: JSON.stringify(query), @@ -193,20 +199,68 @@ export const addCommentFromRule = ({ ]); // TODO: should we return anything? This will only return the sub case and comments - return SubCaseResponseRt.encode( - flattenSubCaseSavedObject({ - savedObject: { - ...subCase, - ...updatedSubCase, - attributes: { ...subCase.attributes, ...updatedSubCase.attributes }, - version: updatedSubCase.version ?? subCase.version, - references: subCase.references, - }, + return CombinedCaseResponseRt.encode( + flattenCombinedCaseSavedObject({ comments: comments.saved_objects, + combinedCase: new CombinedCase( + { + ...myCase, + ...updatedCase, + attributes: { + ...myCase.attributes, + ...updatedCase.attributes, + }, + version: updatedCase.version ?? myCase.version, + references: myCase.references, + }, + { + ...subCase, + ...updatedSubCase, + attributes: { ...subCase.attributes, ...updatedSubCase.attributes }, + version: updatedSubCase.version ?? subCase.version, + references: subCase.references, + } + ), }) ); }; +async function getCombinedCase( + service: CaseServiceSetup, + client: SavedObjectsClientContract, + id: string +): Promise { + const [casePromise, subCasePromise] = await Promise.allSettled([ + service.getCase({ + client, + id, + }), + service.getSubCase({ + client, + id, + }), + ]); + + if (subCasePromise.status === 'fulfilled') { + if (subCasePromise.value.references.length > 0) { + const caseValue = await service.getCase({ + client, + id: subCasePromise.value.references[0].id, + }); + return new CombinedCase(caseValue, subCasePromise.value); + } else { + // TODO: throw a boom instead? + throw Error('Sub case found without reference to collection'); + } + } + + if (casePromise.status === 'rejected') { + throw casePromise.reason; + } else { + return new CombinedCase(casePromise.value); + } +} + export const addComment = ({ savedObjectsClient, caseService, @@ -216,7 +270,7 @@ export const addComment = ({ caseClient, caseId, comment, -}: CaseClientAddComment): Promise => { +}: CaseClientAddComment): Promise => { const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) @@ -225,13 +279,15 @@ export const addComment = ({ decodeComment(comment); const createdDate = new Date().toISOString(); - const myCase = await caseService.getCase({ + const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); + + /* const myCase = await caseService.getCase({ client: savedObjectsClient, - caseId, - }); + id: caseId, + });*/ // An alert cannot be attach to a closed case. - if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { + if (query.type === CommentType.alert && combinedCase.status === CaseStatuses.closed) { throw Boom.badRequest('Alert cannot be attached to a closed case'); } @@ -249,41 +305,34 @@ export const addComment = ({ full_name, email, }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: myCase.id, - }, - ], + references: combinedCase.buildRefsToCase(), }), - caseService.patchCase({ - client: savedObjectsClient, - caseId, - updatedAttributes: { - updated_at: createdDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, + // This will return a full new CombinedCase object that has the updated and base fields + // merged together so let's use the return value from now on + combinedCase.update({ + service: caseService, + soClient: savedObjectsClient, + date: createdDate, + user: { username, full_name, email }, }), ]); // TODO: need to figure out what we do in this case for alerts that are attached to sub cases // If the case is synced with alerts the newly attached alert must match the status of the case. - if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { const ids = Array.isArray(newComment.attributes.alertId) ? newComment.attributes.alertId : [newComment.attributes.alertId]; caseClient.updateAlertsStatus({ ids, - status: myCase.attributes.status, + status: updatedCase.status, indices: new Set([newComment.attributes.index]), }); } const totalCommentsFindByCases = await caseService.getAllCaseComments({ client: savedObjectsClient, - caseId, + id: caseId, options: { fields: [], page: 1, @@ -294,7 +343,7 @@ export const addComment = ({ const [comments] = await Promise.all([ caseService.getAllCaseComments({ client: savedObjectsClient, - caseId, + id: caseId, options: { fields: [], page: 1, @@ -308,7 +357,7 @@ export const addComment = ({ action: 'create', actionAt: createdDate, actionBy: { username, full_name, email }, - caseId: myCase.id, + caseId: updatedCase.id, commentId: newComment.id, fields: ['comment'], newValue: JSON.stringify(query), @@ -317,15 +366,9 @@ export const addComment = ({ }), ]); - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, + return CombinedCaseResponseRt.encode( + flattenCombinedCaseSavedObject({ + combinedCase: updatedCase, comments: comments.saved_objects, }) ); diff --git a/x-pack/plugins/case/server/client/comments/combined_case.ts b/x-pack/plugins/case/server/client/comments/combined_case.ts new file mode 100644 index 0000000000000..33f48a4f18051 --- /dev/null +++ b/x-pack/plugins/case/server/client/comments/combined_case.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectReference, SavedObjectsClientContract } from 'src/core/server'; +import { + CaseSettings, + CaseStatuses, + CombinedCaseAttributes, + ESCaseAttributes, + SubCaseAttributes, +} from '../../../common/api'; +import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { CaseServiceSetup } from '../../services'; + +interface UserInfo { + username: string | null | undefined; + full_name: string | null | undefined; + email: string | null | undefined; +} + +export class CombinedCase { + constructor( + private collection: SavedObject, + private subCase?: SavedObject + ) {} + + public get status(): CaseStatuses { + return this.subCase?.attributes.status ?? this.collection.attributes.status; + } + + public get id(): string { + return this.subCase?.id ?? this.collection.id; + } + + public get version(): string | undefined { + return this.subCase?.version ?? this.collection.version; + } + + public get settings(): CaseSettings { + return this.collection.attributes.settings; + } + + public get attributes(): CombinedCaseAttributes { + return { + subCase: this.subCase?.attributes ?? null, + caseCollection: { + ...this.collection.attributes, + connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), + }, + }; + } + + // TODO: refactor this, we shouldn't really need to know the saved object type? + public buildRefsToCase(): SavedObjectReference[] { + const type = this.subCase ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + return [ + { + type, + name: `associated-${type}`, + id: this.id, + }, + ]; + } + + public async update({ + service, + soClient, + date, + user, + }: { + service: CaseServiceSetup; + soClient: SavedObjectsClientContract; + date: string; + user: UserInfo; + }): Promise { + if (this.subCase) { + const updated = await service.patchSubCase({ + client: soClient, + subCaseId: this.subCase.id, + updatedAttributes: { + updated_at: date, + updated_by: { + ...user, + }, + }, + version: this.subCase.version, + }); + + return new CombinedCase(this.collection, { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updated.attributes, + }, + version: updated.version ?? this.subCase.version, + }); + } + + const updated = await service.patchCase({ + client: soClient, + caseId: this.collection.id, + updatedAttributes: { + updated_at: date, + updated_by: { ...user }, + }, + version: this.collection.version, + }); + + return new CombinedCase( + { + ...this.collection, + attributes: { + ...this.collection.attributes, + ...updated.attributes, + }, + version: updated.version ?? this.collection.version, + }, + this.subCase + ); + } +} diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index ce417dc60f30d..128cfb3e3e769 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -12,6 +12,7 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, + CombinedCaseResponse, CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, @@ -68,10 +69,8 @@ export interface ConfigureFields { connectorType: string; } export interface CaseClient { - addComment: (args: CaseClientAddComment) => Promise; - addCommentFromRule: ( - args: CaseClientAddInternalComment - ) => Promise; + addComment: (args: CaseClientAddComment) => Promise; + addCommentFromRule: (args: CaseClientAddInternalComment) => Promise; create: (args: CaseClientCreate) => Promise; getFields: (args: ConfigureFields) => Promise; getMappings: (args: MappingsClient) => Promise; diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index 4a7cfc6d0a525..272df01ea1666 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -15,7 +15,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse, SubCaseResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse, CombinedCaseResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -28,7 +28,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse | SubCaseResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse | CombinedCaseResponse; 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 0bf8ad89ce470..6ba961fb6900e 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 @@ -30,7 +30,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic const comments = await caseService.getAllCaseComments({ client, - caseId: request.params.case_id, + id: request.params.case_id, }); await Promise.all( comments.saved_objects.map((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 a4b89353ef3fe..d6f594f4855fd 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 @@ -42,7 +42,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { const args = query ? { client, - caseId: request.params.case_id, + id: request.params.case_id, options: { ...query, sortField: 'created_at', @@ -50,7 +50,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { } : { client, - caseId: request.params.case_id, + id: request.params.case_id, }; const theComments = await caseService.getAllCaseComments(args); 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 8d7820d4e8fec..a29824570e418 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 @@ -26,7 +26,7 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { const client = context.core.savedObjects.client; const comments = await caseService.getAllCaseComments({ client, - caseId: request.params.case_id, + id: request.params.case_id, }); return response.ok({ body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), 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 82fe3fce67653..04224c971f51f 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 @@ -48,7 +48,7 @@ export function initPatchCommentApi({ const myCase = await caseService.getCase({ client, - caseId, + id: caseId, }); const myComment = await caseService.getComment({ @@ -102,7 +102,7 @@ export function initPatchCommentApi({ const totalCommentsFindByCases = await caseService.getAllCaseComments({ client, - caseId, + id: caseId, options: { fields: [], page: 1, @@ -113,7 +113,7 @@ export function initPatchCommentApi({ const [comments] = await Promise.all([ caseService.getAllCaseComments({ client, - caseId: request.params.case_id, + id: request.params.case_id, options: { fields: [], page: 1, 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 db7bd6b9a76c8..0141d9582e854 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 @@ -28,7 +28,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R request.query.ids.map((id) => caseService.deleteCase({ client, - caseId: id, + id, }) ) ); @@ -36,7 +36,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R request.query.ids.map((id) => caseService.getAllCaseComments({ client, - caseId: id, + id, }) ) ); 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 b034e86b4f0d4..5737a9a5eeec2 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 @@ -100,7 +100,7 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: cases.saved_objects.map((c) => caseService.getAllCaseComments({ client, - caseId: c.id, + id: c.id, options: { fields: [], page: 1, 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 973beacc10f7c..93c492a7805db 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 @@ -32,7 +32,7 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro const [theCase] = await Promise.all([ caseService.getCase({ client, - caseId: request.params.case_id, + id: request.params.case_id, }), ]); @@ -48,7 +48,7 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro const theComments = await caseService.getAllCaseComments({ client, - caseId: request.params.case_id, + id: request.params.case_id, options: { sortField: 'created_at', sortOrder: 'asc', 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 6a6b09dc3f87a..59a9d85936b13 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 @@ -67,12 +67,12 @@ export function initPushCaseUserActionApi({ const [myCase, myCaseConfigure, totalCommentsFindByCases, connectors] = await Promise.all([ caseService.getCase({ client, - caseId: request.params.case_id, + id: request.params.case_id, }), caseConfigureService.find({ client }), caseService.getAllCaseComments({ client, - caseId, + id: caseId, options: { fields: [], page: 1, @@ -90,7 +90,7 @@ export function initPushCaseUserActionApi({ const comments = await caseService.getAllCaseComments({ client, - caseId, + id: caseId, options: { fields: [], page: 1, diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 9007e2c628308..e73d1c8cd3718 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -41,10 +41,13 @@ import { CommentRequestAlertGroupType, ContextTypeAlertGroupRt, NeedToFixCommentRequestAlertGroupType, + CombinedCaseResponse, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase, TotalCommentByCase } from './types'; +// TODO: figure out where the class should actually be stored +import { CombinedCase } from '../../client/comments/combined_case'; // TODO: refactor these functions to a common location, this is used by the caseClient too @@ -204,6 +207,22 @@ export const flattenCaseSavedObject = ({ connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), }); +export const flattenCombinedCaseSavedObject = ({ + combinedCase, + comments = [], + totalComment = comments.length, +}: { + combinedCase: CombinedCase; + comments?: Array>; + totalComment?: number; +}): CombinedCaseResponse => ({ + id: combinedCase.id, + version: combinedCase.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + ...combinedCase.attributes, +}); + export const flattenSubCaseSavedObject = ({ savedObject, comments = [], diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 1ad91adc15f9a..191d33419d822 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -50,7 +50,7 @@ interface PushedArgs { } interface GetCaseArgs extends ClientArgs { - caseId: string; + id: string; } interface GetCasesArgs extends ClientArgs { @@ -123,12 +123,8 @@ export interface CaseServiceSetup { findCases(args: FindCasesArgs): Promise>; // TODO: refactor these because they use the same parameters and implementation getAllCaseComments(args: FindCommentsArgs): Promise>; - getAllSubCaseComments( - client: SavedObjectsClientContract, - id: string, - options?: SavedObjectFindOptions - ): Promise>; getCase(args: GetCaseArgs): Promise>; + getSubCase(args: GetCaseArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; @@ -196,7 +192,7 @@ export class CaseService { throw error; } }, - deleteCase: async ({ client, caseId }: GetCaseArgs) => { + deleteCase: async ({ client, id: caseId }: GetCaseArgs) => { try { this.log.debug(`Attempting to GET case ${caseId}`); return await client.delete(CASE_SAVED_OBJECT, caseId); @@ -214,7 +210,7 @@ export class CaseService { throw error; } }, - getCase: async ({ client, caseId }: GetCaseArgs) => { + getCase: async ({ client, id: caseId }: GetCaseArgs) => { try { this.log.debug(`Attempting to GET case ${caseId}`); return await client.get(CASE_SAVED_OBJECT, caseId); @@ -223,6 +219,15 @@ export class CaseService { throw error; } }, + getSubCase: async ({ client, id }: GetCaseArgs) => { + try { + 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}`); + throw error; + } + }, getCases: async ({ client, caseIds }: GetCasesArgs) => { try { this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); @@ -252,33 +257,21 @@ export class CaseService { throw error; } }, - getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { + getAllCaseComments: async ({ client, id, options }: FindCommentsArgs) => { try { - this.log.debug(`Attempting to GET all comments for case ${caseId}`); + this.log.debug(`Attempting to GET all comments for case ${id}`); return await client.find({ - ...options, type: CASE_COMMENT_SAVED_OBJECT, - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, - }); - } catch (error) { - this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); - throw error; - } - }, - getAllSubCaseComments: async ( - client: SavedObjectsClientContract, - id: string, - options?: SavedObjectFindOptions - ) => { - try { - this.log.debug(`Attempting to GET all comments for sub case ${id}`); - return await client.find({ + hasReferenceOperator: 'OR', + hasReference: [ + { type: CASE_SAVED_OBJECT, id }, + { type: SUB_CASE_SAVED_OBJECT, id }, + ], + // spread the options after so the caller can override the default behavior if they want ...options, - type: CASE_COMMENT_SAVED_OBJECT, - hasReference: { type: SUB_CASE_SAVED_OBJECT, id }, }); } catch (error) { - this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + this.log.debug(`Error on GET all comments for case ${id}: ${error}`); throw error; } }, From 914a263197da36db59c404fb700d2ebe8f0a5a3a Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 21 Jan 2021 17:26:40 -0500 Subject: [PATCH 07/47] Starting sub case API and refactoring of case client --- x-pack/plugins/case/common/api/cases/case.ts | 25 +- x-pack/plugins/case/common/constants.ts | 5 + .../server/client/alerts/update_status.ts | 18 +- .../case/server/client/cases/create.ts | 22 +- .../case/server/client/cases/update.ts | 25 +- .../case/server/client/comments/add.ts | 43 +- .../server/client/configure/get_fields.ts | 2 +- .../server/client/configure/get_mappings.ts | 19 +- x-pack/plugins/case/server/client/index.ts | 177 +++++- x-pack/plugins/case/server/client/types.ts | 23 +- x-pack/plugins/case/server/plugin.ts | 9 +- .../routes/api/__fixtures__/route_contexts.ts | 8 +- .../server/routes/api/cases/convert_case.ts | 37 ++ .../server/routes/api/cases/delete_cases.ts | 77 +++ .../server/routes/api/cases/patch_cases.ts | 2 +- .../routes/api/cases/sub_case/get_sub_case.ts | 71 +++ .../plugins/case/server/routes/api/index.ts | 6 + .../plugins/case/server/routes/api/utils.ts | 3 +- x-pack/plugins/case/server/services/index.ts | 581 ++++++++++-------- 19 files changed, 830 insertions(+), 323 deletions(-) create mode 100644 x-pack/plugins/case/server/routes/api/cases/convert_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index a83d010918258..e0afca807865d 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -37,7 +37,7 @@ const SettingsRt = rt.type({ syncAlerts: rt.boolean, }); -const CaseBasicRt = rt.type({ +const CaseBasicNoTypeRt = rt.type({ description: rt.string, status: CaseStatusRt, tags: rt.array(rt.string), @@ -47,6 +47,11 @@ const CaseBasicRt = rt.type({ settings: SettingsRt, }); +const CaseBasicRt = rt.type({ + ...CaseBasicNoTypeRt.props, + type: CaseTypeRt, +}); + const CaseExternalServiceBasicRt = rt.type({ connector_id: rt.string, connector_name: rt.string, @@ -132,13 +137,25 @@ export const CasesFindResponseRt = rt.intersection([ ]); export const CasePatchRequestRt = rt.intersection([ - // TODO: need to refactor so type is not updatable using patch + rt.partial(CaseBasicNoTypeRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +/** + * This is so the convert request can just pass the request along to the internal + * update functionality. We don't want to expose the type field in the API request though + * so users can't change a collection back to a normal case. + */ +export const CaseUpdateRequestRt = rt.intersection([ rt.partial(CaseBasicRt.props), rt.type({ id: rt.string, version: rt.string }), ]); +export const CaseConvertRequestRt = rt.type({ id: rt.string, version: rt.string }); + export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); +export const CasesUpdateRequestRt = rt.type({ cases: rt.array(CaseUpdateRequestRt) }); export type CaseAttributes = rt.TypeOf; // TODO: document how this is different from the CasePostRequest @@ -148,6 +165,8 @@ export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; +// The update request is different from the patch request in that it allow updating the type field +export type CasesUpdateRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CaseExternalServiceRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; @@ -157,3 +176,5 @@ export type ESCaseAttributes = Omit & { connector: export type ESCasePatchRequest = Omit & { connector?: ESCaseConnector; }; + +export type CaseConvertRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index f4823e81a468b..ab5be099e13f3 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -13,9 +13,14 @@ export const APP_ID = 'case'; export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; +export const CASE_COLLECTION_URL = `${CASES_URL}/collection`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`; export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`; + +export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; +export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; + export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`; 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 f0a09a5177e34..73536e56f41b2 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -4,15 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; +import { KibanaRequest } from 'src/core/server'; +import { CaseStatuses } from '../../../common/api'; +import { AlertServiceContract } from '../../services'; -export const updateAlertsStatus = ({ +interface UpdateAlertsStatusArgs { + alertsService: AlertServiceContract; + request: KibanaRequest; + ids: string[]; + status: CaseStatuses; + indices: Set; +} + +// TODO: remove this file +export const updateAlertsStatus = async ({ alertsService, request, -}: CaseClientFactoryArguments) => async ({ ids, status, indices, -}: CaseClientUpdateAlertsStatus): Promise => { +}: UpdateAlertsStatusArgs): Promise => { await alertsService.updateAlertsStatus({ ids, status, indices, request }); }; diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index 42f3d76e6b225..7e72959d37822 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -9,6 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { @@ -17,6 +18,7 @@ import { CaseResponseRt, CaseResponse, CaseClientPostRequestRt, + CaseClientPostRequest, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -24,15 +26,29 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientCreate, CaseClientFactoryArguments } from '../types'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; + +interface CreateCaseArgs { + caseConfigureService: CaseConfigureServiceSetup; + caseService: CaseServiceSetup; + request: KibanaRequest; + savedObjectsClient: SavedObjectsClientContract; + userActionService: CaseUserActionServiceSetup; + theCase: CaseClientPostRequest; +} -export const create = ({ +export const create = async ({ savedObjectsClient, caseService, caseConfigureService, userActionService, request, -}: CaseClientFactoryArguments) => async ({ theCase }: CaseClientCreate): Promise => { + theCase, +}: CreateCaseArgs): Promise => { const query = pipe( excess(CaseClientPostRequestRt).decode(theCase), fold(throwErrors(Boom.badRequest), identity) diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 1f20e0e29deb3..d7db558d4c219 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -9,7 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsFindResponse } from 'kibana/server'; +import { KibanaRequest, SavedObjectsClientContract, SavedObjectsFindResponse } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { @@ -21,6 +21,9 @@ import { CasePatchRequest, CasesResponse, CaseStatuses, + CasesPatchRequest, + CasesUpdateRequest, + CasesUpdateRequestRt, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -29,18 +32,28 @@ import { } from '../../routes/api/cases/helpers'; import { CaseClientUpdate, CaseClientFactoryArguments } from '../types'; - -export const update = ({ +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { CaseClientImpl } from '..'; + +interface UpdateArgs { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + request: KibanaRequest; + caseClient: CaseClientImpl; + cases: CasesUpdateRequest; +} + +export const update = async ({ savedObjectsClient, caseService, userActionService, request, -}: CaseClientFactoryArguments) => async ({ caseClient, cases, -}: CaseClientUpdate): Promise => { +}: UpdateArgs): Promise => { const query = pipe( - excess(CasesPatchRequestRt).decode(cases), + excess(CasesUpdateRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) ); diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 047c67057a1ec..22b4f92caaeec 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -9,7 +9,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 { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server'; import { decodeComment, flattenCombinedCaseSavedObject, @@ -36,14 +36,11 @@ import { } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; -import { - CaseClientAddComment, - CaseClientAddInternalComment, - CaseClientFactoryArguments, -} from '../types'; +import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; -import { CaseServiceSetup } from '../../services'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CombinedCase } from './combined_case'; +import { CaseClientImpl } from '..'; async function getSubCase({ caseService, @@ -69,23 +66,30 @@ function isUserOrAlertComment(comment: InternalCommentRequest): comment is Comme return comment.type === CommentType.user || comment.type === CommentType.alert; } -export const addCommentFromRule = ({ +interface AddCommentFromRuleArgs { + caseClient: CaseClientImpl; + caseId: string; + comment: InternalCommentRequest; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; +} + +export const addCommentFromRule = async ({ savedObjectsClient, caseService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ caseClient, caseId, comment, -}: CaseClientAddInternalComment): Promise => { +}: AddCommentFromRuleArgs): Promise => { const query = pipe( InternalCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); if (isUserOrAlertComment(comment)) { - return caseClient.addComment({ caseClient, caseId, comment }); + return caseClient.addComment(caseId, comment); } decodeComment(comment); @@ -261,16 +265,25 @@ async function getCombinedCase( } } -export const addComment = ({ +interface AddCommentArgs { + caseClient: CaseClientImpl; + caseId: string; + comment: CommentRequest; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + request: KibanaRequest; +} + +export const addComment = async ({ savedObjectsClient, caseService, userActionService, request, -}: CaseClientFactoryArguments) => async ({ caseClient, caseId, comment, -}: CaseClientAddComment): Promise => { +}: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) diff --git a/x-pack/plugins/case/server/client/configure/get_fields.ts b/x-pack/plugins/case/server/client/configure/get_fields.ts index 9f8988d6355ac..0c1aaf37e872f 100644 --- a/x-pack/plugins/case/server/client/configure/get_fields.ts +++ b/x-pack/plugins/case/server/client/configure/get_fields.ts @@ -10,7 +10,7 @@ import { GetFieldsResponse } from '../../../common/api'; import { ConfigureFields } from '../types'; import { createDefaultMapping, formatFields } from './utils'; -export const getFields = () => async ({ +export const getFields = async ({ actionsClient, connectorType, connectorId, 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 4b58bd085f391..ce2e1c4f98960 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -4,20 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SavedObjectsClientContract } from 'src/core/server'; +import { ActionsClient } from '../../../../actions/server'; import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; -import { CaseClientFactoryArguments, MappingsClient } from '../types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; +import { ConnectorMappingsServiceSetup } from '../../services'; +import { CaseClientImpl } from '..'; -export const getMappings = ({ +interface GetMappingsArgs { + savedObjectsClient: SavedObjectsClientContract; + connectorMappingsService: ConnectorMappingsServiceSetup; + actionsClient: ActionsClient; + caseClient: CaseClientImpl; + connectorType: string; + connectorId: string; +} + +export const getMappings = async ({ savedObjectsClient, connectorMappingsService, -}: CaseClientFactoryArguments) => async ({ actionsClient, caseClient, connectorType, connectorId, -}: MappingsClient): Promise => { +}: GetMappingsArgs): Promise => { if (connectorType === ConnectorTypes.none) { return []; } diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index e5d1b9c502d54..232dd99ad54ab 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -4,17 +4,50 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaseClientFactoryArguments, CaseClient } from './types'; +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { + CaseClientFactoryArguments, + CaseClient, + ConfigureFields, + MappingsClient, + CaseClientUpdateAlertsStatus, +} from './types'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment, addCommentFromRule } from './comments/add'; import { getFields } from './configure/get_fields'; import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + ConnectorMappingsServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '../services'; +import { + CaseClientPostRequest, + CaseConvertRequest, + CaseConvertRequestRt, + CaseResponse, + CasesPatchRequest, + CasesPatchRequestRt, + CasesResponse, + CaseType, + CommentRequest, + excess, + InternalCommentRequest, + throwErrors, +} from '../../common/api'; export { CaseClient } from './types'; -export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { +/* export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { return { create: create(clientArgs), update: update(clientArgs), @@ -24,4 +57,144 @@ export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseCl getMappings: getMappings(clientArgs), updateAlertsStatus: updateAlertsStatus(clientArgs), }; +};*/ + +export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { + return new CaseClientImpl(clientArgs); }; + +// TODO: rename +export class CaseClientImpl implements CaseClient { + private readonly _caseConfigureService: CaseConfigureServiceSetup; + private readonly _caseService: CaseServiceSetup; + private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; + private readonly request: KibanaRequest; + private readonly _savedObjectsClient: SavedObjectsClientContract; + private readonly _userActionService: CaseUserActionServiceSetup; + private readonly _alertsService: AlertServiceContract; + + // TODO: refactor so these are created in the constructor instead of passed in + constructor(clientArgs: CaseClientFactoryArguments) { + this._caseConfigureService = clientArgs.caseConfigureService; + this._caseService = clientArgs.caseService; + this._connectorMappingsService = clientArgs.connectorMappingsService; + // TODO: extract this out so we just pass in the user information + this.request = clientArgs.request; + this._savedObjectsClient = clientArgs.savedObjectsClient; + this._userActionService = clientArgs.userActionService; + this._alertsService = clientArgs.alertsService; + } + + public get caseService(): CaseServiceSetup { + return this._caseService; + } + + public get caseConfigureService(): CaseConfigureServiceSetup { + return this._caseConfigureService; + } + + public get connectorMappingsService(): ConnectorMappingsServiceSetup { + return this._connectorMappingsService; + } + + public get userActionService(): CaseUserActionServiceSetup { + return this._userActionService; + } + + public get alertsService(): AlertServiceContract { + return this._alertsService; + } + + public async create(caseInfo: CaseClientPostRequest) { + return create({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + caseConfigureService: this._caseConfigureService, + userActionService: this._userActionService, + request: this.request, + theCase: caseInfo, + }); + } + + /** + * This enforces the restriction of not changing the case type field + * @param cases requested cases to be updated + */ + public async update(cases: CasesPatchRequest) { + const validatedCases = pipe( + excess(CasesPatchRequestRt).decode(cases), + fold(throwErrors(Boom.badRequest), identity) + ); + + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + request: this.request, + cases: validatedCases, + caseClient: this, + }); + } + + public async convertCaseToCollection(caseInfo: CaseConvertRequest) { + const validatedRequest = pipe( + excess(CaseConvertRequestRt).decode(caseInfo), + fold(throwErrors(Boom.badRequest), identity) + ); + + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + request: this.request, + cases: { + cases: [{ ...validatedRequest, type: CaseType.parent }], + }, + caseClient: this, + }); + } + + public async addCommentFromRule(caseId: string, comment: InternalCommentRequest) { + return addCommentFromRule({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + }); + } + + public async addComment(caseId: string, comment: CommentRequest) { + return addComment({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + request: this.request, + }); + } + + public async getFields(fields: ConfigureFields) { + return getFields(fields); + } + + public async getMappings(args: MappingsClient) { + return getMappings({ + ...args, + savedObjectsClient: this._savedObjectsClient, + connectorMappingsService: this._connectorMappingsService, + caseClient: this, + }); + } + + public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { + return updateAlertsStatus({ + ...args, + alertsService: this._alertsService, + request: this.request, + }); + } +} diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 128cfb3e3e769..49ebee57f3be4 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -8,6 +8,7 @@ import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CaseClientPostRequest, + CaseConvertRequest, CaseResponse, CasesPatchRequest, CasesResponse, @@ -26,12 +27,13 @@ import { AlertServiceContract, } from '../services'; import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; + +// TODO: Remove unused types export interface CaseClientCreate { theCase: CaseClientPostRequest; } export interface CaseClientUpdate { - caseClient: CaseClient; cases: CasesPatchRequest; } @@ -42,7 +44,6 @@ export interface CaseClientAddComment { } export interface CaseClientAddInternalComment { - caseClient: CaseClient; caseId: string; comment: InternalCommentRequest; } @@ -69,13 +70,17 @@ export interface ConfigureFields { connectorType: string; } export interface CaseClient { - addComment: (args: CaseClientAddComment) => Promise; - addCommentFromRule: (args: CaseClientAddInternalComment) => Promise; - create: (args: CaseClientCreate) => Promise; - getFields: (args: ConfigureFields) => Promise; - getMappings: (args: MappingsClient) => Promise; - update: (args: CaseClientUpdate) => Promise; - updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; + addComment: (caseId: string, comment: CommentRequest) => Promise; + addCommentFromRule: ( + caseId: string, + comment: InternalCommentRequest + ) => Promise; + create(theCase: CaseClientPostRequest): Promise; + convertCaseToCollection(caseInfo: CaseConvertRequest): Promise; + getFields(args: ConfigureFields): Promise; + getMappings(args: MappingsClient): Promise; + update(cases: CasesPatchRequest): Promise; + updateAlertsStatus(args: CaseClientUpdateAlertsStatus): Promise; } export interface MappingsClient { diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index b3dcf9b04901a..30bdc09471097 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -28,6 +28,7 @@ import { caseConnectorMappingsSavedObjectType, caseSavedObjectType, caseUserActionSavedObjectType, + subCaseSavedObjectType, } from './saved_object_types'; import { CaseConfigureService, @@ -76,6 +77,7 @@ export class CasePlugin { core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); core.savedObjects.registerType(caseSavedObjectType); + core.savedObjects.registerType(subCaseSavedObjectType); core.savedObjects.registerType(caseUserActionSavedObjectType); this.log.debug( @@ -84,9 +86,10 @@ export class CasePlugin { )}] and plugins [${Object.keys(plugins)}]` ); - this.caseService = await new CaseService(this.log).setup({ - authentication: plugins.security != null ? plugins.security.authc : null, - }); + this.caseService = new CaseService( + this.log, + plugins.security != null ? plugins.security.authc : undefined + ); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); 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 f7bcedb503c7a..2710ff45c5e37 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 @@ -23,13 +23,13 @@ export const createRouteContext = async (client: any, badAuth = false) => { const log = loggingSystemMock.create().get('case'); const esClientMock = elasticsearchServiceMock.createClusterClient(); - const caseServicePlugin = new CaseService(log); + const caseService = new CaseService( + log, + badAuth ? authenticationMock.createInvalid() : authenticationMock.create() + ); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const alertsService = new AlertService(); alertsService.initialize(esClientMock); diff --git a/x-pack/plugins/case/server/routes/api/cases/convert_case.ts b/x-pack/plugins/case/server/routes/api/cases/convert_case.ts new file mode 100644 index 0000000000000..d361c7b5530e3 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/convert_case.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { wrapError, escapeHatch } from '../utils'; + +import { RouteDeps } from '../types'; +import { CASE_COLLECTION_URL } from '../../../../common/constants'; +import { CaseConvertRequest } from '../../../../common/api'; + +export function initConvertCaseToCollectionApi({ router }: RouteDeps) { + router.post( + { + path: CASE_COLLECTION_URL, + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + + const caseClient = context.case.getCaseClient(); + const body = request.body as CaseConvertRequest; + return response.ok({ + body: await caseClient.convertCaseToCollection({ id: body.id, version: body.version }), + }); + } catch (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 0141d9582e854..f668c0424759e 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 @@ -6,10 +6,70 @@ import { schema } from '@kbn/config-schema'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { CaseType } from '../../../../common/api'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; import { CASES_URL } from '../../../../common/constants'; +import { CaseServiceSetup } from '../../../services'; + +// TODO: move this to the service layer +async function unremovableCases({ + caseService, + client, + ids, + force, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + ids: string[]; + force: boolean | undefined; +}): Promise { + // if the force flag was included then we can skip checking whether the cases are collections and go ahead + // and delete them + if (force) { + return []; + } + + const cases = await caseService.getCases({ caseIds: ids, client }); + const parentCases = cases.saved_objects.filter( + (caseObj) => caseObj.attributes.type === CaseType.parent + ); + + return parentCases.map((parentCase) => parentCase.id); +} + +// TODO: move this to the service layer +async function deleteSubCases({ + caseService, + client, + caseIds, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseIds: string[]; +}) { + const subCasesForCaseIds = await Promise.all( + caseIds.map((id) => caseService.findSubCases(client, id)) + ); + + const commentsForSubCases = await Promise.all( + caseIds.map((id) => caseService.getAllCaseComments({ client, id })) + ); + + await Promise.all( + commentsForSubCases + .flatMap((comment) => comment.saved_objects) + .map((commentSO) => caseService.deleteComment({ client, commentId: commentSO.id })) + ); + + await Promise.all( + subCasesForCaseIds + .flatMap((subCase) => subCase.saved_objects) + .map((subCaseSO) => caseService.deleteSubCase(client, subCaseSO.id)) + ); +} export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -18,12 +78,26 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R validate: { query: schema.object({ ids: schema.arrayOf(schema.string()), + force: schema.maybe(schema.boolean()), }), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; + const unremovable = await unremovableCases({ + caseService, + client, + ids: request.query.ids, + force: request.query.force, + }); + + if (unremovable.length > 0) { + return response.badRequest({ + body: `Case IDs: [${unremovable.join(' ,')}] are not removable`, + }); + } + await Promise.all( request.query.ids.map((id) => caseService.deleteCase({ @@ -55,6 +129,9 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R ) ); } + + await deleteSubCases({ caseService, client, caseIds: request.query.ids }); + // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request, response }); const deleteDate = new Date().toISOString(); 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 178e40520d9d2..050e36227c271 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 @@ -27,7 +27,7 @@ export function initPatchCasesApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.update({ caseClient, cases }), + body: await caseClient.update(cases), }); } catch (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 new file mode 100644 index 0000000000000..2b1b1e37db1a5 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { SubCaseResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenSubCaseSavedObject, wrapError } from '../../utils'; +import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; + +export function initGetSubCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: SUB_CASE_DETAILS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + sub_case_id: schema.string(), + }), + query: schema.object({ + includeComments: schema.string({ defaultValue: 'true' }), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const includeComments = JSON.parse(request.query.includeComments); + + const subCase = await caseService.getSubCase({ + client, + id: request.params.sub_case_id, + }); + + if (!includeComments) { + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + }) + ), + }); + } + + const theComments = await caseService.getAllCaseComments({ + client, + id: request.params.case_id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 15817b425021e..3b866a572e7ba 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -5,6 +5,7 @@ */ import { initDeleteCasesApi } from './cases/delete_cases'; +import { initConvertCaseToCollectionApi } from './cases/convert_case'; import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; @@ -30,6 +31,7 @@ import { initPostCaseConfigure } from './cases/configure/post_configure'; import { initPostPushToService } from './cases/configure/post_push_to_service'; import { RouteDeps } from './types'; +import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; export function initCaseApi(deps: RouteDeps) { // Cases @@ -40,6 +42,10 @@ export function initCaseApi(deps: RouteDeps) { initPostCaseApi(deps); initPushCaseUserActionApi(deps); initGetAllUserActionsApi(deps); + initConvertCaseToCollectionApi(deps); + // Sub cases + initGetSubCaseApi(deps); + // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index e73d1c8cd3718..7dc420d2257b2 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -82,6 +82,7 @@ export const transformNewCase = ({ ...newCase, closed_at: null, closed_by: null, + converted_by: null, connector, created_at: createdDate, created_by: { email, full_name, username }, @@ -122,7 +123,6 @@ export const transformNewComment = ({ return { associationType, - // TODO: if this is an alert group, reduce to an array of ids alertIds: ids, index: comment.index, ruleId: comment.ruleId, @@ -137,7 +137,6 @@ export const transformNewComment = ({ } else { return { associationType, - // TODO: if this is an alert group, reduce to an array of ids ...comment, created_at: createdDate, created_by: { email, full_name, username }, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 191d33419d822..aae0c087a59b4 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -112,16 +112,16 @@ interface GetUserArgs { response?: KibanaResponseFactory; } -interface CaseServiceDeps { - authentication: SecurityPluginSetup['authc'] | null; -} - // TODO: split this up into comments, case, sub case, possibly more? export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; + deleteSubCase(client: SavedObjectsClientContract, id: string): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - // TODO: refactor these because they use the same parameters and implementation + findSubCases( + client: SavedObjectsClientContract, + caseId: string + ): Promise>; getAllCaseComments(args: FindCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; getSubCase(args: GetCaseArgs): Promise>; @@ -148,272 +148,319 @@ export interface CaseServiceSetup { patchSubCase(args: PatchSubCase): Promise>; } -export class CaseService { - constructor(private readonly log: Logger) {} - public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ - createSubCase: async ( - client: SavedObjectsClientContract, - createdAt: string, - caseId: string - ): Promise> => { - try { - this.log.debug(`Attempting to POST a new sub case`); - return await client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase(createdAt), { - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: caseId, - }, - ], - }); - } catch (error) { - this.log.debug(`Error on POST a new sub case: ${error}`); - throw error; - } - }, - getMostRecentSubCase: async (client: SavedObjectsClientContract, caseId: string) => { - try { - this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); - const subCases: SavedObjectsFindResponse = await client.find({ - perPage: 1, - sortField: 'created_at', - sortOrder: 'desc', - type: SUB_CASE_SAVED_OBJECT, - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, - }); - if (subCases.saved_objects.length <= 0) { - return; - } +export class CaseService implements CaseServiceSetup { + constructor( + private readonly log: Logger, + private readonly authentication?: SecurityPluginSetup['authc'] + ) {} - return subCases.saved_objects[0]; - } catch (error) { - this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); - throw error; - } - }, - deleteCase: async ({ client, id: caseId }: GetCaseArgs) => { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.delete(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); - throw error; - } - }, - deleteComment: async ({ client, commentId }: GetCommentArgs) => { - try { - 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}`); - throw error; - } - }, - getCase: async ({ client, id: caseId }: GetCaseArgs) => { - try { - 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}`); - throw error; - } - }, - getSubCase: async ({ client, id }: GetCaseArgs) => { - try { - 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}`); - throw error; - } - }, - getCases: async ({ client, caseIds }: GetCasesArgs) => { - try { - this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - return await client.bulkGet( - caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) - ); - } catch (error) { - this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); - throw error; - } - }, - getComment: async ({ client, commentId }: GetCommentArgs) => { - try { - 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}`); - throw error; - } - }, - findCases: async ({ client, options }: FindCasesArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await client.find({ ...options, type: CASE_SAVED_OBJECT }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; - } - }, - getAllCaseComments: async ({ client, id, options }: FindCommentsArgs) => { - try { - this.log.debug(`Attempting to GET all comments for case ${id}`); - return await client.find({ - type: CASE_COMMENT_SAVED_OBJECT, - hasReferenceOperator: 'OR', - hasReference: [ - { type: CASE_SAVED_OBJECT, id }, - { type: SUB_CASE_SAVED_OBJECT, id }, - ], - // spread the options after so the caller can override the default behavior if they want - ...options, - }); - } catch (error) { - this.log.debug(`Error on GET all comments for case ${id}: ${error}`); - throw error; - } - }, - getReporters: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all reporters`); - return await readReporters({ client }); - } catch (error) { - this.log.debug(`Error on GET all reporters: ${error}`); - throw error; - } - }, - getTags: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await readTags({ client }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; - } - }, - getUser: async ({ request, response }: GetUserArgs) => { - try { - this.log.debug(`Attempting to authenticate a user`); - if (authentication != null) { - const user = authentication.getCurrentUser(request); - if (!user) { - return { - username: null, - full_name: null, - email: null, - }; - } - return user; - } - return { - username: null, - full_name: null, - email: null, - }; - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; - } - }, - postNewCase: async ({ client, attributes }: PostCaseArgs) => { - try { - 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}`); - throw error; - } - }, - postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { - try { - 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}`); - throw error; - } - }, - patchCase: async ({ client, caseId, updatedAttributes, version }: PatchCaseArgs) => { - try { - 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}`); - throw error; - } - }, - patchCases: async ({ client, cases }: PatchCasesArgs) => { - try { - this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); - return await client.bulkUpdate( - cases.map((c) => ({ + public async createSubCase( + client: SavedObjectsClientContract, + createdAt: string, + caseId: string + ): Promise> { + try { + this.log.debug(`Attempting to POST a new sub case`); + return client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase(createdAt), { + references: [ + { type: CASE_SAVED_OBJECT, - id: c.caseId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); - throw error; + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], + }); + } catch (error) { + this.log.debug(`Error on POST a new sub case: ${error}`); + throw error; + } + } + + public async getMostRecentSubCase(client: SavedObjectsClientContract, caseId: string) { + try { + this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); + const subCases: SavedObjectsFindResponse = await client.find({ + perPage: 1, + sortField: 'created_at', + sortOrder: 'desc', + type: SUB_CASE_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + if (subCases.saved_objects.length <= 0) { + return; } - }, - patchComment: async ({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) => { - try { - this.log.debug(`Attempting to UPDATE comment ${commentId}`); - return await client.update( - CASE_COMMENT_SAVED_OBJECT, - commentId, + + return subCases.saved_objects[0]; + } catch (error) { + this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); + throw error; + } + } + + public async deleteSubCase(client: SavedObjectsClientContract, id: string) { + try { + 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}`); + throw error; + } + } + + public async deleteCase({ client, id: caseId }: GetCaseArgs) { + try { + 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}`); + throw error; + } + } + public async deleteComment({ client, commentId }: GetCommentArgs) { + try { + 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}`); + throw error; + } + } + public async getCase({ + client, + id: caseId, + }: GetCaseArgs): Promise> { + try { + 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}`); + throw error; + } + } + public async getSubCase({ client, id }: GetCaseArgs): Promise> { + try { + 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}`); + throw error; + } + } + public async getCases({ + client, + caseIds, + }: GetCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); + return await client.bulkGet( + caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) + ); + } catch (error) { + this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + throw error; + } + } + public async getComment({ + client, + commentId, + }: GetCommentArgs): Promise> { + try { + 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}`); + throw error; + } + } + public async findCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET all cases`); + return await client.find({ ...options, type: CASE_SAVED_OBJECT }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + + public async findSubCases( + client: SavedObjectsClientContract, + caseId: string + ): Promise> { + try { + this.log.debug(`Attempting to GET sub cases for case collection id ${caseId}`); + return client.find({ + type: SUB_CASE_SAVED_OBJECT, + hasReference: [ { - ...updatedAttributes, + type: CASE_SAVED_OBJECT, + id: caseId, }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); - throw error; - } - }, - patchComments: async ({ client, comments }: PatchComments) => { - try { - this.log.debug( - `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` - ); - return await client.bulkUpdate( - comments.map((c) => ({ - type: CASE_COMMENT_SAVED_OBJECT, - id: c.commentId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.debug( - `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` - ); - throw error; - } - }, - patchSubCase: async ({ client, subCaseId, updatedAttributes, version }: PatchSubCase) => { - try { - this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); - return await client.update( - CASE_SAVED_OBJECT, - subCaseId, - { ...updatedAttributes }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); - throw error; + ], + }); + } catch (error) { + this.log.debug(`Error on GET all sub cases for case collection ids ${caseId}: ${error}`); + throw error; + } + } + + public async getAllCaseComments({ + client, + id, + options, + }: FindCommentsArgs): Promise> { + try { + this.log.debug(`Attempting to GET all comments for case ${id}`); + return await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReferenceOperator: 'OR', + hasReference: [ + { type: CASE_SAVED_OBJECT, id }, + { type: SUB_CASE_SAVED_OBJECT, id }, + ], + // spread the options after so the caller can override the default behavior if they want + ...options, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + throw error; + } + } + public async getReporters({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all reporters`); + return await readReporters({ client }); + } catch (error) { + this.log.debug(`Error on GET all reporters: ${error}`); + throw error; + } + } + public async getTags({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all cases`); + return await readTags({ client }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + public async getUser({ request, response }: GetUserArgs) { + try { + this.log.debug(`Attempting to authenticate a user`); + if (this.authentication != null) { + const user = this.authentication.getCurrentUser(request); + if (!user) { + return { + username: null, + full_name: null, + email: null, + }; + } + return user; } - }, - }); + return { + username: null, + full_name: null, + email: null, + }; + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + public async postNewCase({ client, attributes }: PostCaseArgs) { + try { + 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}`); + throw error; + } + } + public async postNewComment({ client, attributes, references }: PostCommentArgs) { + try { + 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}`); + throw error; + } + } + public async patchCase({ client, caseId, updatedAttributes, version }: PatchCaseArgs) { + try { + 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}`); + throw error; + } + } + public async patchCases({ client, cases }: PatchCasesArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); + return await client.bulkUpdate( + cases.map((c) => ({ + type: CASE_SAVED_OBJECT, + id: c.caseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); + throw error; + } + } + public async patchComment({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update( + CASE_COMMENT_SAVED_OBJECT, + commentId, + { + ...updatedAttributes, + }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + } + public async patchComments({ client, comments }: PatchComments) { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map((c) => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + } + public async patchSubCase({ client, subCaseId, updatedAttributes, version }: PatchSubCase) { + try { + this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); + return await client.update( + CASE_SAVED_OBJECT, + subCaseId, + { ...updatedAttributes }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); + throw error; + } + } } From 26a02fc9f4fe27d86b1ea9aa82bf81da8880226c Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Mon, 25 Jan 2021 18:58:49 -0500 Subject: [PATCH 08/47] Fleshing out find cases --- x-pack/plugins/case/common/api/cases/case.ts | 4 + .../plugins/case/common/api/cases/comment.ts | 26 +-- .../plugins/case/common/api/cases/sub_case.ts | 14 +- .../plugins/case/common/api/saved_object.ts | 4 +- .../case/server/client/cases/update.ts | 6 +- .../case/server/client/comments/add.ts | 96 +++++--- x-pack/plugins/case/server/client/index.ts | 8 +- x-pack/plugins/case/server/client/types.ts | 14 +- x-pack/plugins/case/server/common/index.ts | 7 + .../models/commentable_case.ts} | 16 +- .../case/server/common/models/index.ts | 7 + .../case/server/connectors/case/index.ts | 13 +- .../case/server/connectors/case/types.ts | 4 +- .../server/routes/api/cases/delete_cases.ts | 2 +- .../server/routes/api/cases/find_cases.ts | 220 +++++++++++++++++- .../plugins/case/server/routes/api/utils.ts | 73 +++--- x-pack/plugins/case/server/services/index.ts | 43 ++-- 17 files changed, 416 insertions(+), 141 deletions(-) create mode 100644 x-pack/plugins/case/server/common/index.ts rename x-pack/plugins/case/server/{client/comments/combined_case.ts => common/models/commentable_case.ts} (89%) create mode 100644 x-pack/plugins/case/server/common/models/index.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index e0afca807865d..03a7bf6a410eb 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -11,6 +11,7 @@ import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; import { CaseConnectorRt, ESCaseConnector } from '../connectors'; +import { SubCaseResponseRt } from './sub_case'; export enum CaseStatuses { open = 'open', @@ -101,6 +102,7 @@ export const CaseClientPostRequestRt = rt.type({ export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; export const CasesFindRequestRt = rt.partial({ + type: CaseTypeRt, tags: rt.union([rt.array(rt.string), rt.string]), status: CaseStatusRt, reporters: rt.union([rt.array(rt.string), rt.string]), @@ -122,6 +124,7 @@ export const CaseResponseRt = rt.intersection([ version: rt.string, }), rt.partial({ + subCases: rt.array(SubCaseResponseRt), comments: rt.array(CommentResponseRt), }), ]); @@ -163,6 +166,7 @@ export type CaseClientPostRequest = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; +export type CasesFindRequest = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; // The update request is different from the patch request in that it allow updating the type field diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index e338d6ce585e0..1ca38d4d61d8c 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -62,7 +62,7 @@ export const ContextTypeAlertGroupRt = rt.type({ export const ContextTypeAlertGroupAttributesRt = rt.type({ type: rt.literal(CommentType.alertGroup), - alertIds: rt.union([rt.array(rt.string), rt.string]), + alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, ruleId: rt.string, }); @@ -79,17 +79,7 @@ const CommentAttributesRt = rt.union([ AttributesTypeAlertGroupRt, ]); -const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt]); - -/** - * The internal comment request includes all types, this is to allow creation of all types of comments when using a rule - * but limits the external public HTTP API to only User and Alert - */ -export const InternalCommentRequestRt = rt.union([ - ContextTypeUserRt, - ContextTypeAlertRt, - ContextTypeAlertGroupRt, -]); +const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt, ContextTypeAlertGroupRt]); export const CommentRequestRt = ContextBasicRt; @@ -119,7 +109,11 @@ export const CommentPatchRequestRt = rt.intersection([ * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. */ export const CommentPatchAttributesRt = rt.intersection([ - rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(ContextTypeAlertRt.props)]), + rt.union([ + rt.partial(CommentAttributesBasicRt.props), + rt.partial(ContextTypeAlertRt.props), + rt.partial(ContextTypeAlertGroupAttributesRt.props), + ]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -133,7 +127,6 @@ export const CommentsResponseRt = rt.type({ export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; -export type InternalCommentRequest = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; export type AllCommentsResponse = rt.TypeOf; @@ -143,8 +136,5 @@ export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; export type CommentRequestAlertType = rt.TypeOf; -// TODO: make sure this is right export type CommentRequestAlertGroupType = rt.TypeOf; -export type NeedToFixCommentRequestAlertGroupType = rt.TypeOf< - typeof ContextTypeAlertGroupAttributesRt ->; +export type CommentAlertGroupAttributesType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts index 7c8ecb486258a..a5685579efe79 100644 --- a/x-pack/plugins/case/common/api/cases/sub_case.ts +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -29,7 +29,7 @@ export const SubCaseAttributesRt = rt.intersection([ }), ]); -export const CombinedCaseAttributesRt = rt.type({ +export const CollectionSubCaseAttributesRt = rt.type({ subCase: rt.union([SubCaseAttributesRt, rt.null]), caseCollection: CaseAttributesRt, }); @@ -46,14 +46,15 @@ export const SubCasesFindRequestRt = rt.partial({ sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), }); -export const CombinedCaseResponseRt = rt.intersection([ - CombinedCaseAttributesRt, +export const CollectWithSubCaseResponseRt = rt.intersection([ + CollectionSubCaseAttributesRt, rt.type({ id: rt.string, totalComment: rt.number, version: rt.string, }), rt.partial({ + totalAlerts: rt.number, comments: rt.array(CommentResponseRt), }), ]); @@ -63,6 +64,7 @@ export const SubCaseResponseRt = rt.intersection([ rt.type({ id: rt.string, totalComment: rt.number, + totalAlerts: rt.number, version: rt.string, }), rt.partial({ @@ -88,8 +90,10 @@ export const SubCasePatchRequestRt = rt.intersection([ export const SubCasesPatchRequestRt = rt.type({ cases: rt.array(SubCasePatchRequestRt) }); export const SubCasesResponseRt = rt.array(SubCaseResponseRt); -export type CombinedCaseAttributes = rt.TypeOf; -export type CombinedCaseResponse = rt.TypeOf; +// TODO: extract these to their own file and rename the types +export type CollectionWithSubCaseAttributes = rt.TypeOf; +export type CollectionWithSubCaseResponse = rt.TypeOf; + export type SubCaseAttributes = rt.TypeOf; export type SubCaseResponse = rt.TypeOf; export type SubCasesResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts index 7535e51abd6fa..58cde745ada5c 100644 --- a/x-pack/plugins/case/common/api/saved_object.ts +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -19,10 +19,12 @@ export const NumberFromString = new rt.Type( String ); +const ReferenceRt = rt.type({ id: rt.string, type: rt.string }); + export const SavedObjectFindOptionsRt = rt.partial({ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), hasReferenceOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), - hasReference: rt.type({ id: rt.string, type: rt.string }), + hasReference: rt.union([rt.array(ReferenceRt), ReferenceRt]), fields: rt.array(rt.string), filter: rt.string, page: NumberFromString, diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index d7db558d4c219..fa76c536652d2 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -24,6 +24,7 @@ import { CasesPatchRequest, CasesUpdateRequest, CasesUpdateRequestRt, + CommentType, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -33,6 +34,7 @@ import { import { CaseClientUpdate, CaseClientFactoryArguments } from '../types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { CaseClientImpl } from '..'; interface UpdateArgs { @@ -178,7 +180,7 @@ export const update = async ({ id: theCase.id, options: { fields: [], - filter: 'cases-comments.attributes.type: alert', + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alertGroup}`, page: 1, perPage: 1, }, @@ -189,7 +191,7 @@ export const update = async ({ id: theCase.id, options: { fields: [], - filter: 'cases-comments.attributes.type: alert', + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alertGroup}`, page: 1, perPage: totalComments.total, }, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 22b4f92caaeec..7c0762044d037 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -9,11 +9,18 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { + KibanaRequest, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from 'src/core/server'; import { decodeComment, - flattenCombinedCaseSavedObject, + flattenCommentableCaseSavedObject, flattenSubCaseSavedObject, + getAlertIds, + isAlertGroupContext, transformNewComment, } from '../../routes/api/utils'; @@ -28,18 +35,19 @@ import { SubCaseAttributes, SubCaseResponseRt, SubCaseResponse, - InternalCommentRequestRt, - InternalCommentRequest, CommentRequest, - CombinedCaseResponseRt, - CombinedCaseResponse, + CollectWithSubCaseResponseRt, + CollectionWithSubCaseResponse, + ContextTypeAlertGroupRt, + CommentRequestAlertGroupType, + CommentAttributes, } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CombinedCase } from './combined_case'; +import { CommentableCase } from '../../common'; import { CaseClientImpl } from '..'; async function getSubCase({ @@ -62,36 +70,45 @@ async function getSubCase({ return caseService.createSubCase(savedObjectsClient, createdAt, caseId); } -function isUserOrAlertComment(comment: InternalCommentRequest): comment is CommentRequest { - return comment.type === CommentType.user || comment.type === CommentType.alert; +function countAlerts(comments: SavedObjectsFindResponse): number { + let totalAlerts = 0; + for (const comment of comments.saved_objects) { + if ( + comment.attributes.type === CommentType.alert || + comment.attributes.type === CommentType.alertGroup + ) { + if (Array.isArray(comment.attributes.alertId)) { + totalAlerts += comment.attributes.alertId.length; + } else { + totalAlerts++; + } + } + } + return totalAlerts; } interface AddCommentFromRuleArgs { caseClient: CaseClientImpl; caseId: string; - comment: InternalCommentRequest; + comment: CommentRequestAlertGroupType; savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; } -export const addCommentFromRule = async ({ +export const addAlertGroup = async ({ savedObjectsClient, caseService, userActionService, caseClient, caseId, comment, -}: AddCommentFromRuleArgs): Promise => { +}: AddCommentFromRuleArgs): Promise => { const query = pipe( - InternalCommentRequestRt.decode(comment), + ContextTypeAlertGroupRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - if (isUserOrAlertComment(comment)) { - return caseClient.addComment(caseId, comment); - } - decodeComment(comment); const createdDate = new Date().toISOString(); @@ -164,7 +181,18 @@ export const addCommentFromRule = async ({ }), ]); - // TODO: handle updating the alert group status + if ( + (newComment.attributes.type === CommentType.alert || + newComment.attributes.type === CommentType.alertGroup) && + myCase.attributes.settings.syncAlerts + ) { + const ids = getAlertIds(query); + caseClient.updateAlertsStatus({ + ids, + status: myCase.attributes.status, + indices: new Set([newComment.attributes.index]), + }); + } const totalCommentsFindBySubCase = await caseService.getAllCaseComments({ client: savedObjectsClient, @@ -202,11 +230,11 @@ export const addCommentFromRule = async ({ }), ]); - // TODO: should we return anything? This will only return the sub case and comments - return CombinedCaseResponseRt.encode( - flattenCombinedCaseSavedObject({ + return CollectWithSubCaseResponseRt.encode( + flattenCommentableCaseSavedObject({ + totalAlerts: countAlerts(comments), comments: comments.saved_objects, - combinedCase: new CombinedCase( + combinedCase: new CommentableCase( { ...myCase, ...updatedCase, @@ -233,7 +261,7 @@ async function getCombinedCase( service: CaseServiceSetup, client: SavedObjectsClientContract, id: string -): Promise { +): Promise { const [casePromise, subCasePromise] = await Promise.allSettled([ service.getCase({ client, @@ -251,7 +279,7 @@ async function getCombinedCase( client, id: subCasePromise.value.references[0].id, }); - return new CombinedCase(caseValue, subCasePromise.value); + return new CommentableCase(caseValue, subCasePromise.value); } else { // TODO: throw a boom instead? throw Error('Sub case found without reference to collection'); @@ -261,7 +289,7 @@ async function getCombinedCase( if (casePromise.status === 'rejected') { throw casePromise.reason; } else { - return new CombinedCase(casePromise.value); + return new CommentableCase(casePromise.value); } } @@ -283,22 +311,21 @@ export const addComment = async ({ caseClient, caseId, comment, -}: AddCommentArgs): Promise => { +}: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); + if (isAlertGroupContext(comment)) { + return caseClient.addAlertGroup(caseId, comment); + } + decodeComment(comment); const createdDate = new Date().toISOString(); const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); - /* const myCase = await caseService.getCase({ - client: savedObjectsClient, - id: caseId, - });*/ - // An alert cannot be attach to a closed case. if (query.type === CommentType.alert && combinedCase.status === CaseStatuses.closed) { throw Boom.badRequest('Alert cannot be attached to a closed case'); @@ -330,8 +357,6 @@ export const addComment = async ({ }), ]); - // TODO: need to figure out what we do in this case for alerts that are attached to sub cases - // If the case is synced with alerts the newly attached alert must match the status of the case. if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { const ids = Array.isArray(newComment.attributes.alertId) ? newComment.attributes.alertId @@ -379,10 +404,11 @@ export const addComment = async ({ }), ]); - return CombinedCaseResponseRt.encode( - flattenCombinedCaseSavedObject({ + return CollectWithSubCaseResponseRt.encode( + flattenCommentableCaseSavedObject({ combinedCase: updatedCase, comments: comments.saved_objects, + totalAlerts: countAlerts(comments), }) ); }; diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 232dd99ad54ab..3a77bba752973 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -19,7 +19,7 @@ import { } from './types'; import { create } from './cases/create'; import { update } from './cases/update'; -import { addComment, addCommentFromRule } from './comments/add'; +import { addComment, addAlertGroup } from './comments/add'; import { getFields } from './configure/get_fields'; import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; @@ -40,8 +40,8 @@ import { CasesResponse, CaseType, CommentRequest, + CommentRequestAlertGroupType, excess, - InternalCommentRequest, throwErrors, } from '../../common/api'; @@ -154,8 +154,8 @@ export class CaseClientImpl implements CaseClient { }); } - public async addCommentFromRule(caseId: string, comment: InternalCommentRequest) { - return addCommentFromRule({ + public async addAlertGroup(caseId: string, comment: CommentRequestAlertGroupType) { + return addAlertGroup({ savedObjectsClient: this._savedObjectsClient, caseService: this._caseService, userActionService: this._userActionService, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 8df4f1ff56d37..b4a961e868892 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -13,11 +13,11 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, - CombinedCaseResponse, + CollectionWithSubCaseResponse, CommentRequest, + CommentRequestAlertGroupType, ConnectorMappingsAttributes, GetFieldsResponse, - InternalCommentRequest, SubCaseResponse, } from '../../common/api'; import { @@ -47,7 +47,7 @@ export interface CaseClientAddComment { export interface CaseClientAddInternalComment { caseId: string; - comment: InternalCommentRequest; + comment: CommentRequest; } export interface CaseClientUpdateAlertsStatus { @@ -73,11 +73,11 @@ export interface ConfigureFields { connectorType: string; } export interface CaseClient { - addComment: (caseId: string, comment: CommentRequest) => Promise; - addCommentFromRule: ( + addComment: (caseId: string, comment: CommentRequest) => Promise; + addAlertGroup: ( caseId: string, - comment: InternalCommentRequest - ) => Promise; + comment: CommentRequestAlertGroupType + ) => Promise; create(theCase: CaseClientPostRequest): Promise; convertCaseToCollection(caseInfo: CaseConvertRequest): Promise; getFields(args: ConfigureFields): Promise; diff --git a/x-pack/plugins/case/server/common/index.ts b/x-pack/plugins/case/server/common/index.ts new file mode 100644 index 0000000000000..602b351653eeb --- /dev/null +++ b/x-pack/plugins/case/server/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './models'; diff --git a/x-pack/plugins/case/server/client/comments/combined_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts similarity index 89% rename from x-pack/plugins/case/server/client/comments/combined_case.ts rename to x-pack/plugins/case/server/common/models/commentable_case.ts index 33f48a4f18051..2446eef29b7d1 100644 --- a/x-pack/plugins/case/server/client/comments/combined_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -8,7 +8,7 @@ import { SavedObject, SavedObjectReference, SavedObjectsClientContract } from 's import { CaseSettings, CaseStatuses, - CombinedCaseAttributes, + CollectionWithSubCaseAttributes, ESCaseAttributes, SubCaseAttributes, } from '../../../common/api'; @@ -22,7 +22,11 @@ interface UserInfo { email: string | null | undefined; } -export class CombinedCase { +/** + * This class represents a case that can have a comment attached to it. This includes + * a Sub Case, Case, and Collection. + */ +export class CommentableCase { constructor( private collection: SavedObject, private subCase?: SavedObject @@ -44,7 +48,7 @@ export class CombinedCase { return this.collection.attributes.settings; } - public get attributes(): CombinedCaseAttributes { + public get attributes(): CollectionWithSubCaseAttributes { return { subCase: this.subCase?.attributes ?? null, caseCollection: { @@ -76,7 +80,7 @@ export class CombinedCase { soClient: SavedObjectsClientContract; date: string; user: UserInfo; - }): Promise { + }): Promise { if (this.subCase) { const updated = await service.patchSubCase({ client: soClient, @@ -90,7 +94,7 @@ export class CombinedCase { version: this.subCase.version, }); - return new CombinedCase(this.collection, { + return new CommentableCase(this.collection, { ...this.subCase, attributes: { ...this.subCase.attributes, @@ -110,7 +114,7 @@ export class CombinedCase { version: this.collection.version, }); - return new CombinedCase( + return new CommentableCase( { ...this.collection, attributes: { diff --git a/x-pack/plugins/case/server/common/models/index.ts b/x-pack/plugins/case/server/common/models/index.ts new file mode 100644 index 0000000000000..2771f9ab68a9f --- /dev/null +++ b/x-pack/plugins/case/server/common/models/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './commentable_case'; diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 5f32f495779be..046221bfe2164 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -89,11 +89,11 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create({ + data = await caseClient.create( // TODO: is it possible for the action framework to create an individual case that is not associated with sub cases? // TODO: I think this should be an individual case... - theCase: { ...(subActionParams as CasePostRequest), type: CaseType.parent }, - }); + { ...(subActionParams as CasePostRequest), type: CaseType.parent } + ); } if (subAction === 'update') { @@ -105,15 +105,12 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ - caseClient, - cases: { cases: [updateParamsWithoutNullValues] }, - }); + data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addCommentFromRule({ caseClient, caseId, comment }); + data = await caseClient.addComment(caseId, comment); } return { status: 'ok', data: data ?? {}, actionId }; diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index 272df01ea1666..32fdfc7c8f7a3 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -15,7 +15,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse, CombinedCaseResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse, CollectionWithSubCaseResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -28,7 +28,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse | CombinedCaseResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse | CollectionWithSubCaseResponse; export type CaseActionType = ActionType< CaseConfiguration, 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 f668c0424759e..191adf4c7d498 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 @@ -51,7 +51,7 @@ async function deleteSubCases({ caseIds: string[]; }) { const subCasesForCaseIds = await Promise.all( - caseIds.map((id) => caseService.findSubCases(client, id)) + caseIds.map((id) => caseService.findSubCasesByCaseId(client, id)) ); const commentsForSubCases = await Promise.all( 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 5737a9a5eeec2..193f683594302 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 @@ -10,26 +10,47 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { isEmpty } from 'lodash'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsFindResult, +} from 'kibana/server'; +import { groupBy } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors, CaseStatuses, caseStatuses, + CasesFindRequest, + SubCaseResponse, + ESCaseAttributes, + SubCaseAttributes, + CommentType, + CommentAttributes, } from '../../../../common/api'; import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; import { RouteDeps, TotalCommentByCase } from '../types'; -import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; +import { CaseServiceSetup } from '../../../services'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => filters?.filter((i) => i !== '').join(` ${operator} `); -const getStatusFilter = (status: CaseStatuses, appendFilter?: string) => - `${CASE_SAVED_OBJECT}.attributes.status: ${status}${ - !isEmpty(appendFilter) ? ` AND ${appendFilter}` : '' - }`; +// TODO: make this pass in the saved object name string +const addStatusFilter = (status: CaseStatuses | undefined, appendFilter?: string) => { + const filters: string[] = []; + if (status) { + filters.push(`${CASE_SAVED_OBJECT}.attributes.status: ${status}`); + } + + if (appendFilter) { + filters.push(appendFilter); + } + return combineFilters(filters, 'AND'); +}; const buildFilter = ( filters: string | string[] | undefined, @@ -45,6 +66,180 @@ const buildFilter = ( : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` : ''; +interface SubCaseStats { + commentTotals: Map; + alertTotals: Map; +} + +async function getSubCaseCommentStats({ + client, + caseService, + ids, +}: { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + ids: string[]; +}): Promise { + const allComments = await Promise.all( + ids.map((id) => + caseService.getAllCaseComments({ + client, + id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }) + ) + ); + + const alerts = await Promise.all( + ids.map((id) => + caseService.getAllCaseComments({ + client, + id, + options: { + page: 1, + // TODO: fix this + perPage: 10000, + filter: `${SUB_CASE_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${SUB_CASE_SAVED_OBJECT}.attributes.type: ${CommentType.alertGroup}`, + }, + }) + ) + ); + + const getID = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.length > 0 + ? comments.saved_objects[0].references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id + : undefined; + }; + + const countAlerts = (comments: SavedObjectsFindResponse) => { + let totalAlerts = 0; + for (const comment of comments.saved_objects) { + if ( + comment.attributes.type === CommentType.alert || + comment.attributes.type === CommentType.alertGroup + ) { + if (Array.isArray(comment.attributes.alertId)) { + totalAlerts += comment.attributes.alertId.length; + } else { + totalAlerts++; + } + } + } + return totalAlerts; + }; + + const groupedComments = allComments.reduce((acc, comments) => { + const id = getID(comments); + if (id) { + acc.set(id, comments.total); + } + return acc; + }, new Map()); + + const groupedAlerts = alerts.reduce((acc, alertsInfo) => { + const id = getID(alertsInfo); + if (id) { + const totalAlerts = acc.get(id); + if (totalAlerts !== undefined) { + acc.set(id, totalAlerts + countAlerts(alertsInfo)); + } + acc.set(id, alertsInfo.total); + } + return acc; + }, new Map()); + return { commentTotals: groupedComments, alertTotals: groupedAlerts }; +} + +async function findSubCases({ + client, + caseService, + query, + filtersWithoutStatus, + filteredCases, +}: { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + query: CasesFindRequest; + filtersWithoutStatus: string; + filteredCases: SavedObjectsFindResponse; +}): SavedObjectsFindResponse { + const ids = [...filteredCases.saved_objects.map((caseInfo) => caseInfo.id)]; + const cases: Map> = new Map( + filteredCases.saved_objects.map((caseInfo) => { + return [caseInfo.id, caseInfo]; + }) + ); + + if (query.status) { + // TODO: count the total comments for these cases + const casesWithoutStatusFilter = await caseService.findCases({ + client, + options: { + ...query, + filter: filtersWithoutStatus, + sortField: sortToSnake(query.sortField ?? ''), + }, + }); + ids.push(...casesWithoutStatusFilter.saved_objects.map((so) => so.id)); + + for (const caseInfo of casesWithoutStatusFilter.saved_objects) { + cases.set(caseInfo.id, caseInfo); + } + } + + // TODO: count the totals for open, closed, and in-progress sub cases + const foundSubCases = await caseService.findSubCases({ + client, + options: { + filter: addStatusFilter(query.status), + hasReference: ids.map((id) => { + return { + id, + type: SUB_CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const subCasesComments = await getSubCaseCommentStats({ + client, + caseService, + ids: foundSubCases.saved_objects.map((so) => so.id), + }); + + const caseCollections = new Map< + string, + { + subCases?: Array>; + caseInfo: SavedObjectsFindResult; + } + >( + filteredCases.saved_objects.map((caseInfo) => { + return [caseInfo.id, { caseInfo }]; + }) + ); + + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; + + const subCasesGroupedByCaseID = groupBy(foundSubCases.saved_objects, getCaseID); + + for (const [id, subCases] of Object.entries(subCasesGroupedByCaseID)) { + const caseInfo = cases.get(id); + if (caseInfo) { + caseCollections.set(id, { caseInfo, subCases }); + } + } + + // TODO: merge sub cases and new found cases together with comment stats? + return caseCollections; +} + export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( { @@ -66,8 +261,9 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: const reportersFilters = buildFilter(reporters, 'created_by.username', 'OR'); const myFilters = combineFilters([tagsFilter, reportersFilters], 'AND'); - const filter = status != null ? getStatusFilter(status, myFilters) : myFilters; + const filter = addStatusFilter(status, myFilters); + // TODO: I think this is a bug, queryParams will always be defined const args = queryParams ? { client, @@ -87,7 +283,7 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: fields: [], page: 1, perPage: 1, - filter: getStatusFilter(caseStatus, myFilters), + filter: addStatusFilter(caseStatus, myFilters), }, })); @@ -113,10 +309,10 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: const totalCommentsByCases = totalCommentsFindByCases.reduce( (acc, itemFind) => { if (itemFind.saved_objects.length > 0) { - const caseId = - itemFind.saved_objects[0].references.find((r) => r.type === CASE_SAVED_OBJECT) - ?.id ?? null; - if (caseId != null) { + const caseId = itemFind.saved_objects[0].references.find( + (r) => r.type === CASE_SAVED_OBJECT + )?.id; + if (caseId) { return [...acc, { caseId, totalComments: itemFind.total }]; } } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 7dc420d2257b2..a42a404ea7154 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -37,17 +37,16 @@ import { AssociationType, SubCaseAttributes, SubCaseResponse, - InternalCommentRequest, CommentRequestAlertGroupType, ContextTypeAlertGroupRt, - NeedToFixCommentRequestAlertGroupType, - CombinedCaseResponse, + CommentAlertGroupAttributesType, + CollectionWithSubCaseResponse, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase, TotalCommentByCase } from './types'; // TODO: figure out where the class should actually be stored -import { CombinedCase } from '../../client/comments/combined_case'; +import { CommentableCase } from '../../common'; // TODO: refactor these functions to a common location, this is used by the caseClient too @@ -92,7 +91,7 @@ export const transformNewCase = ({ updated_by: null, }); -type NewCommentArgs = InternalCommentRequest & { +type NewCommentArgs = CommentRequest & { associationType: AssociationType; createdDate: string; email?: string | null; @@ -100,16 +99,13 @@ type NewCommentArgs = InternalCommentRequest & { username?: string | null; }; -export const transformNewComment = ({ - associationType, - createdDate, - email, - // eslint-disable-next-line @typescript-eslint/naming-convention - full_name, - username, - ...comment -}: NewCommentArgs): CommentAttributes => { - if (isAlertGroupRequest(comment)) { +/** + * Return the IDs from the comment. + * + * @param comment the comment from the add comment request + */ +export const getAlertIds = (comment: CommentRequest): string[] => { + if (isAlertGroupContext(comment)) { const ids: string[] = []; if (Array.isArray(comment.alerts)) { ids.push( @@ -120,10 +116,29 @@ export const transformNewComment = ({ } else { ids.push(comment.alerts._id); } + return ids; + } else if (isAlertContext(comment)) { + return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + } else { + return []; + } +}; + +export const transformNewComment = ({ + associationType, + createdDate, + email, + // eslint-disable-next-line @typescript-eslint/naming-convention + full_name, + username, + ...comment +}: NewCommentArgs): CommentAttributes => { + if (isAlertGroupContext(comment)) { + const ids = getAlertIds(comment); return { associationType, - alertIds: ids, + alertId: ids, index: comment.index, ruleId: comment.ruleId, type: comment.type, @@ -206,15 +221,16 @@ export const flattenCaseSavedObject = ({ connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), }); -export const flattenCombinedCaseSavedObject = ({ +export const flattenCommentableCaseSavedObject = ({ combinedCase, comments = [], totalComment = comments.length, }: { - combinedCase: CombinedCase; + combinedCase: CommentableCase; comments?: Array>; totalComment?: number; -}): CombinedCaseResponse => ({ + totalAlerts?: number; +}): CollectionWithSubCaseResponse => ({ id: combinedCase.id, version: combinedCase.version ?? '0', comments: flattenCommentSavedObjects(comments), @@ -226,15 +242,18 @@ export const flattenSubCaseSavedObject = ({ savedObject, comments = [], totalComment = comments.length, + totalAlerts = 0, }: { savedObject: SavedObject; comments?: Array>; totalComment?: number; + totalAlerts?: number; }): SubCaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + totalAlerts, ...savedObject.attributes, }); @@ -280,36 +299,36 @@ export const sortToSnake = (sortField: string): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); const isUserContext = ( - context: InternalCommentRequest | CommentAttributes + context: CommentRequest | CommentAttributes ): context is CommentRequestUserType => { return context.type === CommentType.user; }; const isAlertContext = ( - context: InternalCommentRequest | CommentAttributes + context: CommentRequest | CommentAttributes ): context is CommentRequestAlertType => { return context.type === CommentType.alert; }; -const isAlertGroupRequest = ( - context: InternalCommentRequest +export const isAlertGroupContext = ( + context: CommentRequest ): context is CommentRequestAlertGroupType => { return context.type === CommentType.alertGroup; }; -export const decodeComment = (comment: InternalCommentRequest) => { +export const decodeComment = (comment: CommentRequest) => { if (isUserContext(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); } else if (isAlertContext(comment)) { pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isAlertGroupRequest(comment)) { + } else if (isAlertGroupContext(comment)) { pipe(excess(ContextTypeAlertGroupRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; export const getCommentContextFromAttributes = ( attributes: CommentAttributes -): CommentRequestUserType | CommentRequestAlertType | NeedToFixCommentRequestAlertGroupType => +): CommentRequestUserType | CommentRequestAlertType | CommentAlertGroupAttributesType => isUserContext(attributes) ? { type: CommentType.user, @@ -323,7 +342,7 @@ export const getCommentContextFromAttributes = ( } : { type: CommentType.alertGroup, - alertIds: attributes.alertIds, + alertId: attributes.alertId, index: attributes.index, ruleId: attributes.ruleId, }; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index aae0c087a59b4..968236a77bfbe 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -118,7 +118,8 @@ export interface CaseServiceSetup { deleteComment(args: GetCommentArgs): Promise<{}>; deleteSubCase(client: SavedObjectsClientContract, id: string): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - findSubCases( + findSubCases(args: FindCasesArgs): Promise>; + findSubCasesByCaseId( client: SavedObjectsClientContract, caseId: string ): Promise>; @@ -272,36 +273,52 @@ export class CaseService implements CaseServiceSetup { throw error; } } + public async findCases({ client, options, }: FindCasesArgs): Promise> { try { - this.log.debug(`Attempting to GET all cases`); + this.log.debug(`Attempting to find cases`); return await client.find({ ...options, type: CASE_SAVED_OBJECT }); } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); + this.log.debug(`Error on find cases: ${error}`); throw error; } } - public async findSubCases( + public async findSubCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find sub cases`); + return await client.find({ ...options, type: SUB_CASE_SAVED_OBJECT }); + } catch (error) { + this.log.debug(`Error on find sub cases: ${error}`); + throw error; + } + } + + public async findSubCasesByCaseId( client: SavedObjectsClientContract, caseId: string ): Promise> { try { this.log.debug(`Attempting to GET sub cases for case collection id ${caseId}`); - return client.find({ - type: SUB_CASE_SAVED_OBJECT, - hasReference: [ - { - type: CASE_SAVED_OBJECT, - id: caseId, - }, - ], + return this.findSubCases({ + client, + options: { + hasReference: [ + { + type: CASE_SAVED_OBJECT, + id: caseId, + }, + ], + }, }); } catch (error) { - this.log.debug(`Error on GET all sub cases for case collection ids ${caseId}: ${error}`); + this.log.debug(`Error on GET all sub cases for case collection id ${caseId}: ${error}`); throw error; } } From 2d1b4e96fbbe7aead815dc7cbce90f2270ef1849 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 26 Jan 2021 17:40:43 -0500 Subject: [PATCH 09/47] Finished the find cases api --- x-pack/plugins/case/common/api/cases/case.ts | 1 + .../case/server/client/comments/add.ts | 33 +- x-pack/plugins/case/server/common/index.ts | 1 + x-pack/plugins/case/server/common/utils.ts | 25 + .../server/routes/api/cases/find_cases.ts | 454 ++++++++++++------ .../plugins/case/server/routes/api/utils.ts | 40 +- x-pack/plugins/case/server/services/index.ts | 62 ++- 7 files changed, 416 insertions(+), 200 deletions(-) create mode 100644 x-pack/plugins/case/server/common/utils.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 03a7bf6a410eb..f27c4d97dfe96 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -121,6 +121,7 @@ export const CaseResponseRt = rt.intersection([ rt.type({ id: rt.string, totalComment: rt.number, + totalAlerts: rt.number, version: rt.string, }), rt.partial({ diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 7c0762044d037..a354ef1fe5d0a 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -9,16 +9,10 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - KibanaRequest, - SavedObject, - SavedObjectsClientContract, - SavedObjectsFindResponse, -} from 'src/core/server'; +import { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server'; import { decodeComment, flattenCommentableCaseSavedObject, - flattenSubCaseSavedObject, getAlertIds, isAlertGroupContext, transformNewComment, @@ -27,27 +21,22 @@ import { import { throwErrors, CommentRequestRt, - CaseResponse, CommentType, CaseStatuses, AssociationType, CaseType, SubCaseAttributes, - SubCaseResponseRt, - SubCaseResponse, CommentRequest, CollectWithSubCaseResponseRt, CollectionWithSubCaseResponse, ContextTypeAlertGroupRt, CommentRequestAlertGroupType, - CommentAttributes, } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; -import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CommentableCase } from '../../common'; +import { CommentableCase, countAlerts } from '../../common'; import { CaseClientImpl } from '..'; async function getSubCase({ @@ -70,23 +59,6 @@ async function getSubCase({ return caseService.createSubCase(savedObjectsClient, createdAt, caseId); } -function countAlerts(comments: SavedObjectsFindResponse): number { - let totalAlerts = 0; - for (const comment of comments.saved_objects) { - if ( - comment.attributes.type === CommentType.alert || - comment.attributes.type === CommentType.alertGroup - ) { - if (Array.isArray(comment.attributes.alertId)) { - totalAlerts += comment.attributes.alertId.length; - } else { - totalAlerts++; - } - } - } - return totalAlerts; -} - interface AddCommentFromRuleArgs { caseClient: CaseClientImpl; caseId: string; @@ -281,7 +253,6 @@ async function getCombinedCase( }); return new CommentableCase(caseValue, subCasePromise.value); } else { - // TODO: throw a boom instead? throw Error('Sub case found without reference to collection'); } } diff --git a/x-pack/plugins/case/server/common/index.ts b/x-pack/plugins/case/server/common/index.ts index 602b351653eeb..3213bfb4a4062 100644 --- a/x-pack/plugins/case/server/common/index.ts +++ b/x-pack/plugins/case/server/common/index.ts @@ -5,3 +5,4 @@ */ export * from './models'; +export * from './utils'; diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts new file mode 100644 index 0000000000000..bd75599a4bcdb --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsFindResponse } from 'kibana/server'; +import { CommentAttributes, CommentType } from '../../common/api'; + +export const countAlerts = (comments: SavedObjectsFindResponse) => { + let totalAlerts = 0; + for (const comment of comments.saved_objects) { + if ( + comment.attributes.type === CommentType.alert || + comment.attributes.type === CommentType.alertGroup + ) { + if (Array.isArray(comment.attributes.alertId)) { + totalAlerts += comment.attributes.alertId.length; + } else { + totalAlerts++; + } + } + } + return totalAlerts; +}; 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 193f683594302..e253ac61e856d 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 @@ -11,39 +11,54 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { - SavedObject, SavedObjectsClientContract, SavedObjectsFindResponse, SavedObjectsFindResult, } from 'kibana/server'; -import { groupBy } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors, CaseStatuses, caseStatuses, - CasesFindRequest, SubCaseResponse, ESCaseAttributes, SubCaseAttributes, CommentType, CommentAttributes, + CaseType, + SavedObjectFindOptions, + CaseResponse, } from '../../../../common/api'; -import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps, TotalCommentByCase } from '../types'; +import { + transformCases, + sortToSnake, + wrapError, + escapeHatch, + flattenSubCaseSavedObject, + flattenCaseSavedObject, +} from '../utils'; +import { RouteDeps } from '../types'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; import { CaseServiceSetup } from '../../../services'; +import { countAlerts } from '../../../common'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => filters?.filter((i) => i !== '').join(` ${operator} `); -// TODO: make this pass in the saved object name string -const addStatusFilter = (status: CaseStatuses | undefined, appendFilter?: string) => { +const addStatusFilter = ({ + status, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + status: CaseStatuses | undefined; + appendFilter?: string; + type?: string; +}) => { const filters: string[] = []; if (status) { - filters.push(`${CASE_SAVED_OBJECT}.attributes.status: ${status}`); + filters.push(`${type}.attributes.status: ${status}`); } if (appendFilter) { @@ -52,18 +67,24 @@ const addStatusFilter = (status: CaseStatuses | undefined, appendFilter?: string return combineFilters(filters, 'AND'); }; -const buildFilter = ( - filters: string | string[] | undefined, - field: string, - operator: 'OR' | 'AND' -): string => +const buildFilter = ({ + filters, + field, + operator, + type = CASE_SAVED_OBJECT, +}: { + filters: string | string[] | undefined; + field: string; + operator: 'OR' | 'AND'; + type?: string; +}): string => filters != null && filters.length > 0 ? Array.isArray(filters) ? // Be aware of the surrounding parenthesis (as string inside literal) around filters. `(${filters - .map((filter) => `${CASE_SAVED_OBJECT}.attributes.${field}: ${filter}`) + .map((filter) => `${type}.attributes.${field}: ${filter}`) ?.join(` ${operator} `)})` - : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` + : `${type}.attributes.${field}: ${filters}` : ''; interface SubCaseStats { @@ -71,14 +92,16 @@ interface SubCaseStats { alertTotals: Map; } -async function getSubCaseCommentStats({ +async function getCaseCommentStats({ client, caseService, ids, + type, }: { client: SavedObjectsClientContract; caseService: CaseServiceSetup; ids: string[]; + type: typeof SUB_CASE_SAVED_OBJECT | typeof CASE_SAVED_OBJECT; }): Promise { const allComments = await Promise.all( ids.map((id) => @@ -100,10 +123,7 @@ async function getSubCaseCommentStats({ client, id, options: { - page: 1, - // TODO: fix this - perPage: 10000, - filter: `${SUB_CASE_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${SUB_CASE_SAVED_OBJECT}.attributes.type: ${CommentType.alertGroup}`, + filter: `${type}.attributes.type: ${CommentType.alert} OR ${type}.attributes.type: ${CommentType.alertGroup}`, }, }) ) @@ -111,27 +131,10 @@ async function getSubCaseCommentStats({ const getID = (comments: SavedObjectsFindResponse) => { return comments.saved_objects.length > 0 - ? comments.saved_objects[0].references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id + ? comments.saved_objects[0].references.find((ref) => ref.type === type)?.id : undefined; }; - const countAlerts = (comments: SavedObjectsFindResponse) => { - let totalAlerts = 0; - for (const comment of comments.saved_objects) { - if ( - comment.attributes.type === CommentType.alert || - comment.attributes.type === CommentType.alertGroup - ) { - if (Array.isArray(comment.attributes.alertId)) { - totalAlerts += comment.attributes.alertId.length; - } else { - totalAlerts++; - } - } - } - return totalAlerts; - }; - const groupedComments = allComments.reduce((acc, comments) => { const id = getID(comments); if (id) { @@ -154,48 +157,115 @@ async function getSubCaseCommentStats({ return { commentTotals: groupedComments, alertTotals: groupedAlerts }; } +/** + * Constructs the filters used for finding cases and sub cases. + */ +function constructQueries({ + tags, + reporters, + status, + sortByField, + caseType, +}: { + tags?: string | string[]; + reporters?: string | string[]; + status?: CaseStatuses; + sortByField?: string; + caseType?: CaseType; +}): { case: SavedObjectFindOptions; subCase?: SavedObjectFindOptions } { + const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'OR' }); + const reportersFilter = buildFilter({ + filters: reporters, + field: 'created_by.username', + operator: 'OR', + }); + const sortField = sortToSnake(sortByField); + + switch (caseType) { + case CaseType.individual: { + // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" + // The subCase filter will be undefined because we don't need to find sub cases if type === individual + + // We do not want to support multiple type's being used, so force it to be a single filter value + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const caseFilters = addStatusFilter({ + status, + appendFilter: combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'), + }); + return { + case: { + filter: caseFilters, + sortField, + }, + }; + } + case CaseType.parent: { + // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" + // The sub case filter will use the query.status if it exists + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; + const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + default: { + // The cases filter will result in this structure "(status == open or type == parent) and (tags == blah) and (reporter == yo)" + // The sub case filter will use the query.status if it exists + const statusFilter = addStatusFilter({ status }); + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; + const statusAndType = combineFilters([statusFilter, typeFilter], 'OR'); + const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + } +} + +/** + * Returns all the sub cases for a set of case IDs. Optionally includes the comment statistics as well. + */ async function findSubCases({ client, + subCaseOptions, caseService, - query, - filtersWithoutStatus, - filteredCases, + ids, + includeCommentsStats, }: { client: SavedObjectsClientContract; + subCaseOptions?: SavedObjectFindOptions; caseService: CaseServiceSetup; - query: CasesFindRequest; - filtersWithoutStatus: string; - filteredCases: SavedObjectsFindResponse; -}): SavedObjectsFindResponse { - const ids = [...filteredCases.saved_objects.map((caseInfo) => caseInfo.id)]; - const cases: Map> = new Map( - filteredCases.saved_objects.map((caseInfo) => { - return [caseInfo.id, caseInfo]; - }) - ); - - if (query.status) { - // TODO: count the total comments for these cases - const casesWithoutStatusFilter = await caseService.findCases({ - client, - options: { - ...query, - filter: filtersWithoutStatus, - sortField: sortToSnake(query.sortField ?? ''), - }, - }); - ids.push(...casesWithoutStatusFilter.saved_objects.map((so) => so.id)); + ids: string[]; + includeCommentsStats: boolean; +}): Promise> { + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; - for (const caseInfo of casesWithoutStatusFilter.saved_objects) { - cases.set(caseInfo.id, caseInfo); - } + if (!subCaseOptions) { + return new Map(); } - // TODO: count the totals for open, closed, and in-progress sub cases - const foundSubCases = await caseService.findSubCases({ + const subCases = await caseService.findSubCases({ client, options: { - filter: addStatusFilter(query.status), + ...subCaseOptions, hasReference: ids.map((id) => { return { id, @@ -205,39 +275,139 @@ async function findSubCases({ }, }); - const subCasesComments = await getSubCaseCommentStats({ + let subCaseComments: SubCaseStats = { + commentTotals: new Map(), + alertTotals: new Map(), + }; + + if (includeCommentsStats) { + subCaseComments = await getCaseCommentStats({ + client, + caseService, + ids: subCases.saved_objects.map((subCase) => subCase.id), + type: SUB_CASE_SAVED_OBJECT, + }); + } + + return subCases.saved_objects.reduce((accMap, subCase) => { + const id = getCaseID(subCase); + if (id) { + const subCaseFromMap = accMap.get(id); + + if (subCaseFromMap === undefined) { + const subCasesForID = [ + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, + }), + ]; + accMap.set(id, subCasesForID); + } else { + subCaseFromMap.push( + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, + }) + ); + } + } + return accMap; + }, new Map()); +} + +interface Collection { + case: SavedObjectsFindResult; + subCases?: SubCaseResponse[]; +} + +interface CasesMapWithPageInfo { + casesMap: Map; + page: number; + perPage: number; +} + +/** + * Returns a map of all cases combined with their sub cases if they are collections and + * optionally includes the statistics for the cases' comments. + * + * @param includeEmptyCollections is a flag for whether to include collections that don't + * have any sub cases + */ +async function findCases({ + client, + caseOptions, + subCaseOptions, + caseService, + includeEmptyCollections, + includeCommentsStats, +}: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + caseService: CaseServiceSetup; + includeEmptyCollections: boolean; + includeCommentsStats: boolean; +}): Promise { + const cases = await caseService.findCases({ + client, + options: caseOptions, + }); + + const subCases = await findSubCases({ client, + subCaseOptions, caseService, - ids: foundSubCases.saved_objects.map((so) => so.id), + ids: cases.saved_objects.map((caseInfo) => caseInfo.id), + includeCommentsStats, }); - const caseCollections = new Map< - string, - { - subCases?: Array>; - caseInfo: SavedObjectsFindResult; + const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { + const subCasesForCase = subCases.get(caseInfo.id); + // if we don't have the sub cases for the case and the case is a collection then ignore it + // unless we're forcing retrieval of empty collections + if ( + (subCasesForCase && caseInfo.attributes.type === CaseType.parent) || + includeEmptyCollections + ) { + accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); } - >( - filteredCases.saved_objects.map((caseInfo) => { - return [caseInfo.id, { caseInfo }]; - }) - ); + return accMap; + }, new Map()); - const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { - return subCase.references.length > 0 ? subCase.references[0].id : undefined; + let totalCommentsForCases: SubCaseStats = { + commentTotals: new Map(), + alertTotals: new Map(), }; - const subCasesGroupedByCaseID = groupBy(foundSubCases.saved_objects, getCaseID); + if (includeCommentsStats) { + totalCommentsForCases = await getCaseCommentStats({ + client, + caseService, + ids: Array.from(casesMap.keys()), + type: CASE_SAVED_OBJECT, + }); + } - for (const [id, subCases] of Object.entries(subCasesGroupedByCaseID)) { - const caseInfo = cases.get(id); - if (caseInfo) { - caseCollections.set(id, { caseInfo, subCases }); - } + const casesWithComments = new Map(); + for (const [id, caseInfo] of casesMap.entries()) { + casesWithComments.set( + id, + flattenCaseSavedObject({ + savedObject: caseInfo.case, + totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, + totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, + subCases: caseInfo.subCases, + }) + ); } - // TODO: merge sub cases and new found cases together with comment stats? - return caseCollections; + return { + casesMap: casesWithComments, + page: cases.page, + perPage: cases.per_page, + }; } export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { @@ -256,80 +426,54 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: fold(throwErrors(Boom.badRequest), identity) ); - const { tags, reporters, status, ...query } = queryParams; - const tagsFilter = buildFilter(tags, 'tags', 'OR'); - const reportersFilters = buildFilter(reporters, 'created_by.username', 'OR'); - - const myFilters = combineFilters([tagsFilter, reportersFilters], 'AND'); - const filter = addStatusFilter(status, myFilters); + const queryArgs = { + tags: queryParams.tags, + reporters: queryParams.reporters, + sortByField: queryParams.sortField, + status: queryParams.status, + caseType: queryParams.type, + }; - // TODO: I think this is a bug, queryParams will always be defined - const args = queryParams - ? { - client, - options: { - ...query, - filter, - sortField: sortToSnake(query.sortField ?? ''), - }, - } - : { - client, - }; - - const statusArgs = caseStatuses.map((caseStatus) => ({ - client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: addStatusFilter(caseStatus, myFilters), - }, - })); + const caseQueries = constructQueries(queryArgs); const [cases, openCases, inProgressCases, closedCases] = await Promise.all([ - caseService.findCases(args), - ...statusArgs.map((arg) => caseService.findCases(arg)), - ]); - - const totalCommentsFindByCases = await Promise.all( - cases.saved_objects.map((c) => - caseService.getAllCaseComments({ + findCases({ + client, + caseOptions: { ...queryParams, ...caseQueries.case }, + subCaseOptions: caseQueries.subCase, + caseService, + includeEmptyCollections: queryParams.type === CaseType.parent || !queryParams.status, + includeCommentsStats: true, + }), + ...caseStatuses.map((status) => { + const statusQuery = constructQueries({ ...queryArgs, status }); + return findCases({ client, - id: c.id, - options: { - fields: [], + caseOptions: { + ...statusQuery.case, + fields: ['attributes.type'], page: 1, perPage: 1, }, - }) - ) - ); - - const totalCommentsByCases = totalCommentsFindByCases.reduce( - (acc, itemFind) => { - if (itemFind.saved_objects.length > 0) { - const caseId = itemFind.saved_objects[0].references.find( - (r) => r.type === CASE_SAVED_OBJECT - )?.id; - if (caseId) { - return [...acc, { caseId, totalComments: itemFind.total }]; - } - } - return [...acc]; - }, - [] - ); + subCaseOptions: statusQuery.subCase, + caseService, + includeEmptyCollections: false, + // we don't need the comment stats because we're just trying to get the total open, closed, and in-progress + // cases + includeCommentsStats: false, + }); + }), + ]); return response.ok({ body: CasesFindResponseRt.encode( - transformCases( - cases, - openCases.total ?? 0, - inProgressCases.total ?? 0, - closedCases.total ?? 0, - totalCommentsByCases - ) + transformCases({ + ...cases, + countOpenCases: openCases.casesMap.size, + countInProgressCases: inProgressCases.casesMap.size, + countClosedCases: closedCases.casesMap.size, + total: cases.casesMap.size, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index a42a404ea7154..9e2ca116f2833 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -173,17 +173,27 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const transformCases = ( - cases: SavedObjectsFindResponse, - countOpenCases: number, - countInProgressCases: number, - countClosedCases: number, - totalCommentByCase: TotalCommentByCase[] -): CasesFindResponse => ({ - page: cases.page, - per_page: cases.per_page, - total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), +export const transformCases = ({ + casesMap, + countOpenCases, + countInProgressCases, + countClosedCases, + page, + perPage, + total, +}: { + casesMap: Map; + countOpenCases: number; + countInProgressCases: number; + countClosedCases: number; + page: number; + perPage: number; + total: number; +}): CasesFindResponse => ({ + page, + per_page: perPage, + total, + cases: Array.from(casesMap.values()), count_open_cases: countOpenCases, count_in_progress_cases: countInProgressCases, count_closed_cases: countClosedCases, @@ -208,17 +218,23 @@ export const flattenCaseSavedObject = ({ savedObject, comments = [], totalComment = comments.length, + totalAlerts = 0, + subCases, }: { savedObject: SavedObject; comments?: Array>; totalComment?: number; + totalAlerts?: number; + subCases?: SubCaseResponse[]; }): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + totalAlerts, ...savedObject.attributes, connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), + subCases, }); export const flattenCommentableCaseSavedObject = ({ @@ -281,7 +297,7 @@ export const flattenCommentSavedObject = ( ...savedObject.attributes, }); -export const sortToSnake = (sortField: string): SortFieldCase => { +export const sortToSnake = (sortField: string | undefined): SortFieldCase => { switch (sortField) { case 'status': return SortFieldCase.status; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 968236a77bfbe..28e46f13ee61a 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -293,13 +293,40 @@ export class CaseService implements CaseServiceSetup { }: FindCasesArgs): Promise> { try { this.log.debug(`Attempting to find sub cases`); - return await client.find({ ...options, type: SUB_CASE_SAVED_OBJECT }); + // if the page or perPage options are set then respect those instead of trying to + // grab all sub cases + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + } + + const stats = await client.find({ + fields: [], + page: 1, + perPage: 1, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + return client.find({ + page: 1, + perPage: stats.total, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); } catch (error) { this.log.debug(`Error on find sub cases: ${error}`); throw error; } } + /** + * Find sub cases using a collection's ID. This would try to retrieve the maximum amount of sub cases + * by default. + * + * @param caseId the saved object ID of the parent collection to find sub cases for. + */ public async findSubCasesByCaseId( client: SavedObjectsClientContract, caseId: string @@ -323,6 +350,10 @@ export class CaseService implements CaseServiceSetup { } } + /** + * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). + * to override this pass in the either the page or perPage options. + */ public async getAllCaseComments({ client, id, @@ -330,16 +361,43 @@ export class CaseService implements CaseServiceSetup { }: FindCommentsArgs): Promise> { try { this.log.debug(`Attempting to GET all comments for case ${id}`); - return await client.find({ + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReferenceOperator: 'OR', + hasReference: [ + { type: CASE_SAVED_OBJECT, id }, + { type: SUB_CASE_SAVED_OBJECT, id }, + ], + ...options, + }); + } + // get the total number of comments that are in ES then we'll grab them all in one go + const stats = await client.find({ type: CASE_COMMENT_SAVED_OBJECT, hasReferenceOperator: 'OR', hasReference: [ { type: CASE_SAVED_OBJECT, id }, { type: SUB_CASE_SAVED_OBJECT, id }, ], + fields: [], + page: 1, + perPage: 1, // spread the options after so the caller can override the default behavior if they want ...options, }); + + return client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReferenceOperator: 'OR', + hasReference: [ + { type: CASE_SAVED_OBJECT, id }, + { type: SUB_CASE_SAVED_OBJECT, id }, + ], + page: 1, + perPage: stats.total, + ...options, + }); } catch (error) { this.log.debug(`Error on GET all comments for case ${id}: ${error}`); throw error; From c58ddb0404cbfb1d8814db644489940824a72305 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 26 Jan 2021 17:50:43 -0500 Subject: [PATCH 10/47] Filtering comments by association type --- x-pack/plugins/case/server/routes/api/cases/find_cases.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 e253ac61e856d..7756102a85014 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 @@ -29,6 +29,7 @@ import { CaseType, SavedObjectFindOptions, CaseResponse, + AssociationType, } from '../../../../common/api'; import { transformCases, @@ -117,13 +118,16 @@ async function getCaseCommentStats({ ) ); + const associationType = + type === SUB_CASE_SAVED_OBJECT ? AssociationType.subCase : AssociationType.case; + const alerts = await Promise.all( ids.map((id) => caseService.getAllCaseComments({ client, id, options: { - filter: `${type}.attributes.type: ${CommentType.alert} OR ${type}.attributes.type: ${CommentType.alertGroup}`, + filter: `(${type}.attributes.type: ${CommentType.alert} OR ${type}.attributes.type: ${CommentType.alertGroup}) AND ${type}.attributes.associationType: ${associationType}`, }, }) ) From fcc40c00825d1b5b879c1e2f2e9d8a407da065ef Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 27 Jan 2021 19:06:10 -0500 Subject: [PATCH 11/47] Fixing tests and types --- x-pack/plugins/case/common/api/cases/case.ts | 16 +- .../plugins/case/common/api/cases/comment.ts | 46 ++-- .../case/common/api/cases/commentable_case.ts | 34 +++ x-pack/plugins/case/common/api/cases/index.ts | 1 + .../plugins/case/common/api/cases/status.ts | 14 ++ .../plugins/case/common/api/cases/sub_case.ts | 23 +- .../cases/__snapshots__/create.test.ts.snap | 121 ++++++++++ .../cases/__snapshots__/update.test.ts.snap | 14 ++ .../case/server/client/cases/create.test.ts | 88 +------- .../case/server/client/cases/update.test.ts | 61 ++---- .../case/server/client/cases/update.ts | 7 +- x-pack/plugins/case/server/client/client.ts | 181 +++++++++++++++ .../case/server/client/comments/add.test.ts | 12 - .../case/server/client/comments/add.ts | 179 ++++++--------- .../plugins/case/server/client/index.test.ts | 67 +----- x-pack/plugins/case/server/client/index.ts | 206 ++---------------- x-pack/plugins/case/server/client/mocks.ts | 21 +- x-pack/plugins/case/server/client/types.ts | 27 ++- .../server/common/models/commentable_case.ts | 129 ++++++++--- x-pack/plugins/case/server/common/utils.ts | 2 +- .../case/server/connectors/case/index.test.ts | 29 ++- .../case/server/connectors/case/index.ts | 9 +- .../case/server/connectors/case/schema.ts | 2 +- x-pack/plugins/case/server/plugin.ts | 6 +- .../__fixtures__/create_mock_so_repository.ts | 12 +- .../routes/api/__fixtures__/mock_router.ts | 8 +- .../api/__fixtures__/mock_saved_objects.ts | 4 + .../routes/api/__fixtures__/route_contexts.ts | 4 +- .../api/__snapshots__/utils.test.ts.snap | 185 ++++++++++++++++ .../routes/api/cases/comments/post_comment.ts | 2 +- .../server/routes/api/cases/find_cases.ts | 2 +- .../case/server/routes/api/utils.test.ts | 42 ++-- .../plugins/case/server/routes/api/utils.ts | 60 ++--- .../server/saved_object_types/sub_case.ts | 2 - .../cases/components/case_view/helpers.ts | 3 +- .../components/user_action_tree/index.tsx | 9 +- .../containers/use_post_push_to_service.tsx | 9 +- 37 files changed, 906 insertions(+), 731 deletions(-) create mode 100644 x-pack/plugins/case/common/api/cases/commentable_case.ts create mode 100644 x-pack/plugins/case/server/client/client.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index f27c4d97dfe96..d07bb9fb9e97d 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -9,24 +9,10 @@ import * as rt from 'io-ts'; import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; -import { CasesStatusResponseRt } from './status'; +import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt, ESCaseConnector } from '../connectors'; import { SubCaseResponseRt } from './sub_case'; -export enum CaseStatuses { - open = 'open', - 'in-progress' = 'in-progress', - closed = 'closed', -} - -export const CaseStatusRt = rt.union([ - rt.literal(CaseStatuses.open), - rt.literal(CaseStatuses['in-progress']), - rt.literal(CaseStatuses.closed), -]); - -export const caseStatuses = Object.values(CaseStatuses); - export enum CaseType { parent = 'parent', individual = 'individual', diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 1ca38d4d61d8c..4b2bc88889896 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -35,7 +35,7 @@ export const CommentAttributesBasicRt = rt.type({ export enum CommentType { user = 'user', alert = 'alert', - alertGroup = 'alert_group', + generatedAlert = 'generated_alert', } export const ContextTypeUserRt = rt.type({ @@ -44,7 +44,7 @@ export const ContextTypeUserRt = rt.type({ }); export const ContextTypeAlertRt = rt.type({ - type: rt.literal(CommentType.alert), + type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, }); @@ -53,33 +53,21 @@ const AlertIDRt = rt.type({ _id: rt.string, }); -export const ContextTypeAlertGroupRt = rt.type({ - type: rt.literal(CommentType.alertGroup), +export const ContextTypeGeneratedAlertRt = rt.type({ + type: rt.literal(CommentType.generatedAlert), alerts: rt.union([rt.array(AlertIDRt), AlertIDRt]), index: rt.string, - ruleId: rt.string, -}); - -export const ContextTypeAlertGroupAttributesRt = rt.type({ - type: rt.literal(CommentType.alertGroup), - alertId: rt.union([rt.array(rt.string), rt.string]), - index: rt.string, - ruleId: rt.string, }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); -const AttributesTypeAlertGroupRt = rt.intersection([ - ContextTypeAlertGroupAttributesRt, - CommentAttributesBasicRt, -]); -const CommentAttributesRt = rt.union([ - AttributesTypeUserRt, - AttributesTypeAlertsRt, - AttributesTypeAlertGroupRt, -]); +const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); -const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt, ContextTypeAlertGroupRt]); +const ContextBasicRt = rt.union([ + ContextTypeUserRt, + ContextTypeAlertRt, + ContextTypeGeneratedAlertRt, +]); export const CommentRequestRt = ContextBasicRt; @@ -109,11 +97,7 @@ export const CommentPatchRequestRt = rt.intersection([ * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. */ export const CommentPatchAttributesRt = rt.intersection([ - rt.union([ - rt.partial(CommentAttributesBasicRt.props), - rt.partial(ContextTypeAlertRt.props), - rt.partial(ContextTypeAlertGroupAttributesRt.props), - ]), + rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(ContextTypeAlertRt.props)]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -136,5 +120,9 @@ export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; export type CommentRequestAlertType = rt.TypeOf; -export type CommentRequestAlertGroupType = rt.TypeOf; -export type CommentAlertGroupAttributesType = rt.TypeOf; +/** + * This type represents a generated alert (or alerts) from a rule. The difference between this and a user alert + * is that this format expects the included alerts to have the structure { _id: string }. When it is saved the outer + * object will be stripped off and the _id will be stored in the alertId field. + */ +export type CommentRequestGeneratedAlertType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/commentable_case.ts b/x-pack/plugins/case/common/api/cases/commentable_case.ts new file mode 100644 index 0000000000000..b5dbec1c135bf --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/commentable_case.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * 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 97c428ddf8947..ca71004151a22 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -10,3 +10,4 @@ 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/cases/status.ts b/x-pack/plugins/case/common/api/cases/status.ts index b812126dc1eab..ef179c4bdd5dc 100644 --- a/x-pack/plugins/case/common/api/cases/status.ts +++ b/x-pack/plugins/case/common/api/cases/status.ts @@ -6,6 +6,20 @@ import * as rt from 'io-ts'; +export enum CaseStatuses { + open = 'open', + 'in-progress' = 'in-progress', + closed = 'closed', +} + +export const CaseStatusRt = rt.union([ + rt.literal(CaseStatuses.open), + rt.literal(CaseStatuses['in-progress']), + rt.literal(CaseStatuses.closed), +]); + +export const caseStatuses = Object.values(CaseStatuses); + export const CasesStatusResponseRt = rt.type({ count_open_cases: rt.number, count_in_progress_cases: rt.number, diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts index a5685579efe79..241259c8aa239 100644 --- a/x-pack/plugins/case/common/api/cases/sub_case.ts +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -10,10 +10,9 @@ import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; -import { CaseAttributesRt, CaseStatusRt } from './case'; +import { CaseStatusRt } from './status'; // TODO: comments - const SubCaseBasicRt = rt.type({ status: CaseStatusRt, }); @@ -29,11 +28,6 @@ export const SubCaseAttributesRt = rt.intersection([ }), ]); -export const CollectionSubCaseAttributesRt = rt.type({ - subCase: rt.union([SubCaseAttributesRt, rt.null]), - caseCollection: CaseAttributesRt, -}); - export const SubCasesFindRequestRt = rt.partial({ status: CaseStatusRt, defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), @@ -46,19 +40,6 @@ export const SubCasesFindRequestRt = rt.partial({ sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), }); -export const CollectWithSubCaseResponseRt = rt.intersection([ - CollectionSubCaseAttributesRt, - rt.type({ - id: rt.string, - totalComment: rt.number, - version: rt.string, - }), - rt.partial({ - totalAlerts: rt.number, - comments: rt.array(CommentResponseRt), - }), -]); - export const SubCaseResponseRt = rt.intersection([ SubCaseAttributesRt, rt.type({ @@ -91,8 +72,6 @@ export const SubCasesPatchRequestRt = rt.type({ cases: rt.array(SubCasePatchRequ export const SubCasesResponseRt = rt.array(SubCaseResponseRt); // TODO: extract these to their own file and rename the types -export type CollectionWithSubCaseAttributes = rt.TypeOf; -export type CollectionWithSubCaseResponse = rt.TypeOf; export type SubCaseAttributes = rt.TypeOf; export type SubCaseResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap b/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap index 22f11034c072f..9e8a1e1296ebb 100644 --- a/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap +++ b/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap @@ -1,6 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`create happy path Allow user to create case without authentication 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "converted_by": null, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; + exports[`create happy path it creates the case correctly 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "Jira", + "type": ".jira", + }, + "converted_by": null, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; + +exports[`create happy path it creates the case correctly 2`] = ` Array [ Object { "attributes": Object { @@ -32,3 +114,42 @@ Array [ }, ] `; + +exports[`create happy path it creates the case without connector in the configuration 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "converted_by": null, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", +} +`; diff --git a/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap b/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap index af88c8893e144..808e54e542605 100644 --- a/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap +++ b/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap @@ -16,6 +16,7 @@ Array [ "name": "My connector", "type": ".jira", }, + "converted_by": null, "created_at": "2019-11-25T22:32:17.947Z", "created_by": Object { "email": "testemail@elastic.co", @@ -29,10 +30,12 @@ Array [ "syncAlerts": true, }, "status": "in-progress", + "subCases": undefined, "tags": Array [ "LOLBins", ], "title": "Another bad one", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", @@ -62,6 +65,7 @@ Array [ "name": "none", "type": ".none", }, + "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": "testemail@elastic.co", @@ -75,10 +79,12 @@ Array [ "syncAlerts": true, }, "status": "closed", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", @@ -104,6 +110,7 @@ Array [ "name": "none", "type": ".none", }, + "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": "testemail@elastic.co", @@ -117,10 +124,12 @@ Array [ "syncAlerts": true, }, "status": "open", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", @@ -163,10 +172,12 @@ Array [ "syncAlerts": true, }, "status": "closed", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 0, "updated_at": "2019-11-25T21:54:48.952Z", "updated_by": Object { @@ -195,6 +206,7 @@ Array [ "name": "My connector 2", "type": ".jira", }, + "converted_by": null, "created_at": "2019-11-25T22:32:17.947Z", "created_by": Object { "email": "testemail@elastic.co", @@ -208,10 +220,12 @@ Array [ "syncAlerts": true, }, "status": "open", + "subCases": undefined, "tags": Array [ "LOLBins", ], "title": "Another bad one", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", 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 00ab1620059f8..3cb9503efc5e1 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -45,35 +45,9 @@ describe('create', () => { caseConfigureSavedObject: mockCaseConfigure, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - type: CaseType.individual, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchSnapshot(); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions @@ -102,30 +76,9 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - type: CaseType.individual, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchSnapshot(); }); test('Allow user to create case without authentication', async () => { @@ -152,34 +105,9 @@ describe('create', () => { savedObjectsClient, badAuth: true, }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - type: CaseType.individual, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchSnapshot(); }); }); @@ -338,7 +266,7 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); @@ -366,7 +294,7 @@ describe('create', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); 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 80c429aa993b2..854175dd32b4c 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -39,10 +39,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + const res = await caseClient.client.update(patchCases); expect(res).toMatchSnapshot(); @@ -95,10 +92,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + const res = await caseClient.client.update(patchCases); expect(res).toMatchSnapshot(); }); @@ -119,10 +113,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + const res = await caseClient.client.update(patchCases); expect(res).toMatchSnapshot(); }); @@ -143,10 +134,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + const res = await caseClient.client.update(patchCases); expect(res).toMatchSnapshot(); }); @@ -172,10 +160,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + const res = await caseClient.client.update(patchCases); expect(res).toMatchSnapshot(); }); @@ -199,10 +184,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], @@ -235,10 +217,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -267,10 +246,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], @@ -298,10 +274,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -338,10 +311,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(1, { ids: ['test-id', 'test-id-2'], @@ -374,10 +344,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -463,7 +430,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(406); @@ -493,7 +460,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(404); @@ -520,7 +487,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(409); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index fa76c536652d2..a5fccdbf97293 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -16,12 +16,10 @@ import { throwErrors, excess, CasesResponseRt, - CasesPatchRequestRt, ESCasePatchRequest, CasePatchRequest, CasesResponse, CaseStatuses, - CasesPatchRequest, CasesUpdateRequest, CasesUpdateRequestRt, CommentType, @@ -32,7 +30,6 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientUpdate, CaseClientFactoryArguments } from '../types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { CaseClientImpl } from '..'; @@ -180,7 +177,7 @@ export const update = async ({ id: theCase.id, options: { fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alertGroup}`, + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, page: 1, perPage: 1, }, @@ -191,7 +188,7 @@ export const update = async ({ id: theCase.id, options: { fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alertGroup}`, + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, page: 1, perPage: totalComments.total, }, diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts new file mode 100644 index 0000000000000..5941b6d7dfafe --- /dev/null +++ b/x-pack/plugins/case/server/client/client.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { + CaseClientFactoryArguments, + CaseClient, + ConfigureFields, + MappingsClient, + CaseClientUpdateAlertsStatus, + CaseClientAddComment, + CaseClientPluginContract, +} from './types'; +import { create } from './cases/create'; +import { update } from './cases/update'; +import { addComment, addGeneratedAlerts } from './comments/add'; +import { getFields } from './configure/get_fields'; +import { getMappings } from './configure/get_mappings'; +import { updateAlertsStatus } from './alerts/update_status'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + ConnectorMappingsServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '../services'; +import { + CaseClientPostRequest, + CaseConvertRequest, + CaseConvertRequestRt, + CasesPatchRequest, + CasesPatchRequestRt, + CaseType, + CommentRequestGeneratedAlertType, + excess, + throwErrors, +} from '../../common/api'; + +// TODO: rename +export class CaseClientImpl implements CaseClient { + private readonly _caseConfigureService: CaseConfigureServiceSetup; + private readonly _caseService: CaseServiceSetup; + private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; + private readonly request: KibanaRequest; + private readonly _savedObjectsClient: SavedObjectsClientContract; + private readonly _userActionService: CaseUserActionServiceSetup; + private readonly _alertsService: AlertServiceContract; + + // TODO: refactor so these are created in the constructor instead of passed in + constructor(clientArgs: CaseClientFactoryArguments) { + this._caseConfigureService = clientArgs.caseConfigureService; + this._caseService = clientArgs.caseService; + this._connectorMappingsService = clientArgs.connectorMappingsService; + // TODO: extract this out so we just pass in the user information + this.request = clientArgs.request; + this._savedObjectsClient = clientArgs.savedObjectsClient; + this._userActionService = clientArgs.userActionService; + this._alertsService = clientArgs.alertsService; + } + + public get caseService(): CaseServiceSetup { + return this._caseService; + } + + public get caseConfigureService(): CaseConfigureServiceSetup { + return this._caseConfigureService; + } + + public get connectorMappingsService(): ConnectorMappingsServiceSetup { + return this._connectorMappingsService; + } + + public get userActionService(): CaseUserActionServiceSetup { + return this._userActionService; + } + + public get alertsService(): AlertServiceContract { + return this._alertsService; + } + + public async create(caseInfo: CaseClientPostRequest) { + return create({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + caseConfigureService: this._caseConfigureService, + userActionService: this._userActionService, + request: this.request, + theCase: caseInfo, + }); + } + + /** + * This enforces the restriction of not changing the case type field + * @param args requested cases to be updated + */ + public async update(args: CasesPatchRequest) { + const validatedCases = pipe( + excess(CasesPatchRequestRt).decode(args), + fold(throwErrors(Boom.badRequest), identity) + ); + + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + request: this.request, + cases: validatedCases, + caseClient: this, + }); + } + + public async convertCaseToCollection(caseInfo: CaseConvertRequest) { + const validatedRequest = pipe( + excess(CaseConvertRequestRt).decode(caseInfo), + fold(throwErrors(Boom.badRequest), identity) + ); + + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + request: this.request, + cases: { + cases: [{ ...validatedRequest, type: CaseType.parent }], + }, + caseClient: this, + }); + } + + public async addGeneratedAlerts(caseId: string, comment: CommentRequestGeneratedAlertType) { + return addGeneratedAlerts({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + }); + } + + public async addComment({ caseId, comment }: CaseClientAddComment) { + return addComment({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + request: this.request, + }); + } + + public async getFields(fields: ConfigureFields) { + return getFields(fields); + } + + public async getMappings(args: MappingsClient) { + return getMappings({ + ...args, + savedObjectsClient: this._savedObjectsClient, + connectorMappingsService: this._connectorMappingsService, + caseClient: this, + }); + } + + public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { + return updateAlertsStatus({ + ...args, + alertsService: this._alertsService, + request: this.request, + }); + } +} 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 03053ac498b7c..c030f16582837 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -31,7 +31,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -52,7 +51,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -74,7 +72,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -98,7 +95,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -149,7 +145,6 @@ describe('addComment', () => { badAuth: true, }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -175,7 +170,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -210,7 +204,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -290,7 +283,6 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -352,7 +344,6 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -379,7 +370,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'not-exists', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -403,7 +393,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Throw an error', @@ -428,7 +417,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-4', comment: { type: CommentType.alert, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index a354ef1fe5d0a..b5fdf4ccca1ab 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -12,9 +12,8 @@ import { identity } from 'fp-ts/lib/function'; import { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server'; import { decodeComment, - flattenCommentableCaseSavedObject, getAlertIds, - isAlertGroupContext, + isGeneratedAlertContext, transformNewComment, } from '../../routes/api/utils'; @@ -27,16 +26,15 @@ import { CaseType, SubCaseAttributes, CommentRequest, - CollectWithSubCaseResponseRt, CollectionWithSubCaseResponse, - ContextTypeAlertGroupRt, - CommentRequestAlertGroupType, + ContextTypeGeneratedAlertRt, + CommentRequestGeneratedAlertType, } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CommentableCase, countAlerts } from '../../common'; +import { CommentableCase } from '../../common'; import { CaseClientImpl } from '..'; async function getSubCase({ @@ -62,13 +60,13 @@ async function getSubCase({ interface AddCommentFromRuleArgs { caseClient: CaseClientImpl; caseId: string; - comment: CommentRequestAlertGroupType; + comment: CommentRequestGeneratedAlertType; savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; } -export const addAlertGroup = async ({ +export const addGeneratedAlerts = async ({ savedObjectsClient, caseService, userActionService, @@ -77,7 +75,7 @@ export const addAlertGroup = async ({ comment, }: AddCommentFromRuleArgs): Promise => { const query = pipe( - ContextTypeAlertGroupRt.decode(comment), + ContextTypeGeneratedAlertRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); @@ -89,7 +87,7 @@ export const addAlertGroup = async ({ id: caseId, }); - if (query.type === CommentType.alertGroup && myCase.attributes.type !== CaseType.parent) { + if (query.type === CommentType.generatedAlert && myCase.attributes.type !== CaseType.parent) { throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); } @@ -155,7 +153,7 @@ export const addAlertGroup = async ({ if ( (newComment.attributes.type === CommentType.alert || - newComment.attributes.type === CommentType.alertGroup) && + newComment.attributes.type === CommentType.generatedAlert) && myCase.attributes.settings.syncAlerts ) { const ids = getAlertIds(query); @@ -166,67 +164,42 @@ export const addAlertGroup = async ({ }); } - const totalCommentsFindBySubCase = await caseService.getAllCaseComments({ + await userActionService.postUserActions({ client: savedObjectsClient, - id: subCase.id, - options: { - fields: [], - page: 1, - perPage: 1, - }, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { ...userDetails }, + caseId: subCase.id, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client: savedObjectsClient, - id: subCase.id, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindBySubCase.total, + return new CommentableCase({ + collection: { + ...myCase, + ...updatedCase, + attributes: { + ...myCase.attributes, + ...updatedCase.attributes, }, - }), - userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { ...userDetails }, - caseId: subCase.id, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], - }), - ]); - - return CollectWithSubCaseResponseRt.encode( - flattenCommentableCaseSavedObject({ - totalAlerts: countAlerts(comments), - comments: comments.saved_objects, - combinedCase: new CommentableCase( - { - ...myCase, - ...updatedCase, - attributes: { - ...myCase.attributes, - ...updatedCase.attributes, - }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - { - ...subCase, - ...updatedSubCase, - attributes: { ...subCase.attributes, ...updatedSubCase.attributes }, - version: updatedSubCase.version ?? subCase.version, - references: subCase.references, - } - ), - }) - ); + version: updatedCase.version ?? myCase.version, + references: myCase.references, + }, + subCase: { + ...subCase, + ...updatedSubCase, + attributes: { ...subCase.attributes, ...updatedSubCase.attributes }, + version: updatedSubCase.version ?? subCase.version, + references: subCase.references, + }, + service: caseService, + soClient: savedObjectsClient, + }).encode(); }; async function getCombinedCase( @@ -251,16 +224,21 @@ async function getCombinedCase( client, id: subCasePromise.value.references[0].id, }); - return new CommentableCase(caseValue, subCasePromise.value); + return new CommentableCase({ + collection: caseValue, + subCase: subCasePromise.value, + service, + soClient: client, + }); } else { - throw Error('Sub case found without reference to collection'); + throw Boom.badRequest('Sub case found without reference to collection'); } } if (casePromise.status === 'rejected') { throw casePromise.reason; } else { - return new CommentableCase(casePromise.value); + return new CommentableCase({ collection: casePromise.value, service, soClient: client }); } } @@ -288,8 +266,8 @@ export const addComment = async ({ fold(throwErrors(Boom.badRequest), identity) ); - if (isAlertGroupContext(comment)) { - return caseClient.addAlertGroup(caseId, comment); + if (isGeneratedAlertContext(comment)) { + return caseClient.addGeneratedAlerts(caseId, comment); } decodeComment(comment); @@ -321,8 +299,6 @@ export const addComment = async ({ // This will return a full new CombinedCase object that has the updated and base fields // merged together so let's use the return value from now on combinedCase.update({ - service: caseService, - soClient: savedObjectsClient, date: createdDate, user: { username, full_name, email }, }), @@ -339,47 +315,20 @@ export const addComment = async ({ }); } - const totalCommentsFindByCases = await caseService.getAllCaseComments({ + await userActionService.postUserActions({ client: savedObjectsClient, - id: caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: updatedCase.id, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client: savedObjectsClient, - id: caseId, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, - }), - userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: updatedCase.id, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], - }), - ]); - - return CollectWithSubCaseResponseRt.encode( - flattenCommentableCaseSavedObject({ - combinedCase: updatedCase, - comments: comments.saved_objects, - totalAlerts: countAlerts(comments), - }) - ); + return updatedCase.encode(); }; diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index a7f093f42357c..0027d2b0bd095 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -6,7 +6,6 @@ import { KibanaRequest } from 'kibana/server'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { createCaseClient } from '.'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -15,16 +14,9 @@ import { createAlertServiceMock, } from '../services/mocks'; -import { create } from './cases/create'; -import { update } from './cases/update'; -import { addComment } from './comments/add'; -import { updateAlertsStatus } from './alerts/update_status'; -import type { CasesRequestHandlerContext } from '../types'; - -jest.mock('./cases/create'); -jest.mock('./cases/update'); -jest.mock('./comments/add'); -jest.mock('./alerts/update_status'); +jest.mock('./client'); +import { CaseClientImpl } from './client'; +import { createExternalCaseClient } from './index'; const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); @@ -33,65 +25,18 @@ const connectorMappingsService = connectorMappingsServiceMock(); const request = {} as KibanaRequest; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); -const context = {} as CasesRequestHandlerContext; - -const createMock = create as jest.Mock; -const updateMock = update as jest.Mock; -const addCommentMock = addComment as jest.Mock; -const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; -describe('createCaseClient()', () => { +describe('createExternalCaseClient()', () => { test('it creates the client correctly', async () => { - createCaseClient({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }); - - expect(createMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(updateMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(addCommentMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(updateAlertsStatusMock).toHaveBeenCalledWith({ + createExternalCaseClient({ alertsService, caseConfigureService, caseService, connectorMappingsService, - context, request, savedObjectsClient, userActionService, }); + expect(CaseClientImpl).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 3a77bba752973..46c23d58d2af0 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -4,197 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; -import { - CaseClientFactoryArguments, - CaseClient, - ConfigureFields, - MappingsClient, - CaseClientUpdateAlertsStatus, -} from './types'; -import { create } from './cases/create'; -import { update } from './cases/update'; -import { addComment, addAlertGroup } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { - CaseConfigureServiceSetup, - CaseServiceSetup, - ConnectorMappingsServiceSetup, - CaseUserActionServiceSetup, - AlertServiceContract, -} from '../services'; -import { - CaseClientPostRequest, - CaseConvertRequest, - CaseConvertRequestRt, - CaseResponse, - CasesPatchRequest, - CasesPatchRequestRt, - CasesResponse, - CaseType, - CommentRequest, - CommentRequestAlertGroupType, - excess, - throwErrors, -} from '../../common/api'; +import { CaseClientFactoryArguments, CaseClientPluginContract } from './types'; +import { CaseClientImpl } from './client'; +export { CaseClientImpl } from './client'; export { CaseClient } from './types'; -/* export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { +// TODO: this screws up the mocking because it won't mock out CaseClientImpl's methods +/* export const createExternalCaseClient = ( + clientArgs: CaseClientFactoryArguments +): CaseClientPluginContract => { + const client = new CaseClientImpl(clientArgs); return { - create: create(clientArgs), - update: update(clientArgs), - addComment: addComment(clientArgs), - addCommentFromRule: addCommentFromRule(clientArgs), - getFields: getFields(), - getMappings: getMappings(clientArgs), - updateAlertsStatus: updateAlertsStatus(clientArgs), + create: async (args: CaseClientPostRequest) => client.create(args), + addComment: async (args: CaseClientAddComment) => client.addComment(args), + getFields: async (args: ConfigureFields) => client.getFields(args), + getMappings: async (args: MappingsClient) => client.getMappings(args), + update: async (args: CasesPatchRequest) => client.update(args), + updateAlertsStatus: async (args: CaseClientUpdateAlertsStatus) => + client.updateAlertsStatus(args), }; };*/ -export const createCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { - return new CaseClientImpl(clientArgs); +export const createExternalCaseClient = ( + clientArgs: CaseClientFactoryArguments +): CaseClientPluginContract => { + const client = new CaseClientImpl(clientArgs); + return client; }; - -// TODO: rename -export class CaseClientImpl implements CaseClient { - private readonly _caseConfigureService: CaseConfigureServiceSetup; - private readonly _caseService: CaseServiceSetup; - private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; - private readonly request: KibanaRequest; - private readonly _savedObjectsClient: SavedObjectsClientContract; - private readonly _userActionService: CaseUserActionServiceSetup; - private readonly _alertsService: AlertServiceContract; - - // TODO: refactor so these are created in the constructor instead of passed in - constructor(clientArgs: CaseClientFactoryArguments) { - this._caseConfigureService = clientArgs.caseConfigureService; - this._caseService = clientArgs.caseService; - this._connectorMappingsService = clientArgs.connectorMappingsService; - // TODO: extract this out so we just pass in the user information - this.request = clientArgs.request; - this._savedObjectsClient = clientArgs.savedObjectsClient; - this._userActionService = clientArgs.userActionService; - this._alertsService = clientArgs.alertsService; - } - - public get caseService(): CaseServiceSetup { - return this._caseService; - } - - public get caseConfigureService(): CaseConfigureServiceSetup { - return this._caseConfigureService; - } - - public get connectorMappingsService(): ConnectorMappingsServiceSetup { - return this._connectorMappingsService; - } - - public get userActionService(): CaseUserActionServiceSetup { - return this._userActionService; - } - - public get alertsService(): AlertServiceContract { - return this._alertsService; - } - - public async create(caseInfo: CaseClientPostRequest) { - return create({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - caseConfigureService: this._caseConfigureService, - userActionService: this._userActionService, - request: this.request, - theCase: caseInfo, - }); - } - - /** - * This enforces the restriction of not changing the case type field - * @param cases requested cases to be updated - */ - public async update(cases: CasesPatchRequest) { - const validatedCases = pipe( - excess(CasesPatchRequestRt).decode(cases), - fold(throwErrors(Boom.badRequest), identity) - ); - - return update({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - request: this.request, - cases: validatedCases, - caseClient: this, - }); - } - - public async convertCaseToCollection(caseInfo: CaseConvertRequest) { - const validatedRequest = pipe( - excess(CaseConvertRequestRt).decode(caseInfo), - fold(throwErrors(Boom.badRequest), identity) - ); - - return update({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - request: this.request, - cases: { - cases: [{ ...validatedRequest, type: CaseType.parent }], - }, - caseClient: this, - }); - } - - public async addAlertGroup(caseId: string, comment: CommentRequestAlertGroupType) { - return addAlertGroup({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - caseClient: this, - caseId, - comment, - }); - } - - public async addComment(caseId: string, comment: CommentRequest) { - return addComment({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - caseClient: this, - caseId, - comment, - request: this.request, - }); - } - - public async getFields(fields: ConfigureFields) { - return getFields(fields); - } - - public async getMappings(args: MappingsClient) { - return getMappings({ - ...args, - savedObjectsClient: this._savedObjectsClient, - connectorMappingsService: this._connectorMappingsService, - caseClient: this, - }); - } - - public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { - return updateAlertsStatus({ - ...args, - alertsService: this._alertsService, - request: this.request, - }); - } -} diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 5fcc08ee2b0a7..9955eb4e493e2 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { omit } from 'lodash/fp'; import { KibanaRequest } from 'kibana/server'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsClientMock } from '../../../actions/server/mocks'; @@ -15,14 +14,13 @@ import { CaseUserActionServiceSetup, ConnectorMappingsService, } from '../services'; -import { CaseClient } from './types'; +import { CaseClientPluginContract } from './types'; import { authenticationMock } from '../routes/api/__fixtures__'; -import { createCaseClient } from '.'; +import { createExternalCaseClient } from '.'; import { getActions } from '../routes/api/__mocks__/request_responses'; -import type { CasesRequestHandlerContext } from '../types'; -export type CaseClientMock = jest.Mocked; -export const createCaseClientMock = (): CaseClientMock => ({ +export type CaseClientPluginContractMock = jest.Mocked; +export const createExternalCaseClientMock = (): CaseClientPluginContractMock => ({ addComment: jest.fn(), create: jest.fn(), getFields: jest.fn(), @@ -40,7 +38,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ badAuth?: boolean; omitFromContext?: string[]; }): Promise<{ - client: CaseClient; + client: CaseClientPluginContract; services: { userActionService: jest.Mocked; alertsService: jest.Mocked; @@ -51,13 +49,11 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const log = loggingSystemMock.create().get('case'); const request = {} as KibanaRequest; - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); @@ -68,7 +64,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const alertsService = { initialize: jest.fn(), updateAlertsStatus: jest.fn() }; - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient, request, caseService, @@ -76,7 +72,6 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ connectorMappingsService, userActionService, alertsService, - context: (omit(omitFromContext, context) as unknown) as CasesRequestHandlerContext, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index b4a961e868892..56a026f215375 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -13,9 +13,10 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, + CasesUpdateRequest, CollectionWithSubCaseResponse, CommentRequest, - CommentRequestAlertGroupType, + CommentRequestGeneratedAlertType, ConnectorMappingsAttributes, GetFieldsResponse, SubCaseResponse, @@ -40,7 +41,6 @@ export interface CaseClientUpdate { } export interface CaseClientAddComment { - caseClient: CaseClient; caseId: string; comment: CommentRequest; } @@ -64,7 +64,6 @@ export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; - context?: Omit; } export interface ConfigureFields { @@ -72,20 +71,26 @@ export interface ConfigureFields { connectorId: string; connectorType: string; } -export interface CaseClient { - addComment: (caseId: string, comment: CommentRequest) => Promise; - addAlertGroup: ( - caseId: string, - comment: CommentRequestAlertGroupType - ) => Promise; + +/** + * This represents the interface that other plugins can access. + */ +export interface CaseClientPluginContract { + addComment(args: CaseClientAddComment): Promise; create(theCase: CaseClientPostRequest): Promise; - convertCaseToCollection(caseInfo: CaseConvertRequest): Promise; getFields(args: ConfigureFields): Promise; getMappings(args: MappingsClient): Promise; - update(cases: CasesPatchRequest): Promise; + update(args: CasesPatchRequest): Promise; updateAlertsStatus(args: CaseClientUpdateAlertsStatus): Promise; } +export interface CaseClient extends CaseClientPluginContract { + addGeneratedAlerts( + caseId: string, + comment: CommentRequestGeneratedAlertType + ): Promise; + convertCaseToCollection(caseInfo: CaseConvertRequest): Promise; +} export interface MappingsClient { actionsClient: ActionsClient; caseClient: CaseClient; 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 2446eef29b7d1..3e3b4f5dba5cb 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -9,12 +9,15 @@ import { CaseSettings, CaseStatuses, CollectionWithSubCaseAttributes, + CollectWithSubCaseResponseRt, ESCaseAttributes, SubCaseAttributes, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; +import { flattenCommentSavedObjects, flattenSubCaseSavedObject } from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; +import { countAlerts } from '../index'; interface UserInfo { username: string | null | undefined; @@ -22,15 +25,28 @@ interface UserInfo { email: string | null | undefined; } +interface CommentableCaseParams { + collection: SavedObject; + subCase?: SavedObject; + soClient: SavedObjectsClientContract; + service: CaseServiceSetup; +} + /** * This class represents a case that can have a comment attached to it. This includes * a Sub Case, Case, and Collection. */ export class CommentableCase { - constructor( - private collection: SavedObject, - private subCase?: SavedObject - ) {} + private readonly collection: SavedObject; + private readonly subCase?: SavedObject; + private readonly soClient: SavedObjectsClientContract; + private readonly service: CaseServiceSetup; + constructor({ collection, subCase, soClient, service }: CommentableCaseParams) { + this.collection = collection; + this.subCase = subCase; + this.soClient = soClient; + this.service = service; + } public get status(): CaseStatuses { return this.subCase?.attributes.status ?? this.collection.attributes.status; @@ -50,8 +66,8 @@ export class CommentableCase { public get attributes(): CollectionWithSubCaseAttributes { return { - subCase: this.subCase?.attributes ?? null, - caseCollection: { + subCase: this.subCase?.attributes, + case: { ...this.collection.attributes, connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), }, @@ -70,20 +86,64 @@ export class CommentableCase { ]; } - public async update({ - service, - soClient, - date, - user, - }: { - service: CaseServiceSetup; - soClient: SavedObjectsClientContract; - date: string; - user: UserInfo; - }): Promise { + private formatCollectionForEncoding(totalComment: number) { + return { + id: this.collection.id, + version: this.collection.version ?? '0', + totalComment, + ...this.collection.attributes, + connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), + }; + } + + public async encode() { + const collectionCommentStats = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }); + if (this.subCase) { - const updated = await service.patchSubCase({ - client: soClient, + const subCaseComments = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + + return CollectWithSubCaseResponseRt.encode({ + subCase: flattenSubCaseSavedObject({ + savedObject: this.subCase, + comments: subCaseComments.saved_objects, + totalAlerts: countAlerts(subCaseComments), + }), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } + + const collectionComments = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: collectionCommentStats.total, + }, + }); + + return CollectWithSubCaseResponseRt.encode({ + comments: flattenCommentSavedObjects(collectionComments.saved_objects), + totalAlerts: countAlerts(collectionComments), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } + + public async update({ date, user }: { date: string; user: UserInfo }): Promise { + if (this.subCase) { + const updated = await this.service.patchSubCase({ + client: this.soClient, subCaseId: this.subCase.id, updatedAttributes: { updated_at: date, @@ -94,18 +154,23 @@ export class CommentableCase { version: this.subCase.version, }); - return new CommentableCase(this.collection, { - ...this.subCase, - attributes: { - ...this.subCase.attributes, - ...updated.attributes, + return new CommentableCase({ + soClient: this.soClient, + service: this.service, + collection: this.collection, + subCase: { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updated.attributes, + }, + version: updated.version ?? this.subCase.version, }, - version: updated.version ?? this.subCase.version, }); } - const updated = await service.patchCase({ - client: soClient, + const updated = await this.service.patchCase({ + client: this.soClient, caseId: this.collection.id, updatedAttributes: { updated_at: date, @@ -114,8 +179,8 @@ export class CommentableCase { version: this.collection.version, }); - return new CommentableCase( - { + return new CommentableCase({ + collection: { ...this.collection, attributes: { ...this.collection.attributes, @@ -123,7 +188,9 @@ export class CommentableCase { }, version: updated.version ?? this.collection.version, }, - this.subCase - ); + subCase: this.subCase, + soClient: this.soClient, + service: this.service, + }); } } diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index bd75599a4bcdb..0f59181737d8d 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -12,7 +12,7 @@ export const countAlerts = (comments: SavedObjectsFindResponse ({ - createCaseClient: () => mockCaseClient, + createExternalCaseClient: () => mockCaseClient, })); const services = actionsMock.createServices(); @@ -822,12 +825,14 @@ describe('case connector', () => { describe('create', () => { it('executes correctly', async () => { - const createReturn = { + const createReturn: CaseResponse = { id: 'mock-it', comments: [], totalComment: 0, + totalAlerts: 0, closed_at: null, closed_by: null, + converted_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, created_at: '2019-11-25T21:54:48.952Z', created_by: { @@ -906,7 +911,7 @@ describe('case connector', () => { describe('update', () => { it('executes correctly', async () => { - const updateReturn = [ + const updateReturn: CasesResponse = [ { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { @@ -927,6 +932,7 @@ describe('case connector', () => { full_name: 'elastic', username: 'elastic', }, + converted_by: null, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', external_service: null, @@ -934,6 +940,7 @@ describe('case connector', () => { tags: ['defacement'], title: 'Update title', totalComment: 0, + totalAlerts: 0, type: CaseType.parent, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { @@ -994,14 +1001,21 @@ describe('case connector', () => { describe('addComment', () => { it('executes correctly', async () => { - const commentReturn = { + const commentReturn: CollectionWithSubCaseResponse = { id: 'mock-it', totalComment: 0, + version: 'WzksMV0=', + closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, + created_by: { + full_name: 'Awesome D00d', + email: 'd00d@awesome.com', + username: 'awesome', + }, + converted_by: null, description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', @@ -1010,7 +1024,6 @@ describe('case connector', () => { type: CaseType.parent, updated_at: null, updated_by: null, - version: 'WzksMV0=', comments: [ { associationType: AssociationType.case, diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 046221bfe2164..5ddde7009afd1 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -9,7 +9,7 @@ import { curry } from 'lodash'; import { KibanaRequest } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest, CaseType } from '../../../common/api'; -import { createCaseClient } from '../../client'; +import { CaseClientImpl } from '../../client'; import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; import { CaseExecutorResponse, @@ -18,7 +18,6 @@ import { CaseActionTypeExecutorOptions, } from './types'; import * as i18n from './translations'; -import type { CasesRequestHandlerContext } from '../../types'; import { GetActionTypeParams } from '..'; @@ -69,7 +68,7 @@ async function executor( let data: CaseExecutorResponse | null = null; const { savedObjectsClient } = services; - const caseClient = createCaseClient({ + const caseClient = new CaseClientImpl({ savedObjectsClient, // TODO: refactor this request: {} as KibanaRequest, @@ -78,8 +77,6 @@ async function executor( connectorMappingsService, userActionService, alertsService, - // TODO: When case connector is enabled we should figure out how to pass the context. - context: {} as CasesRequestHandlerContext, }); if (!supportedSubActions.includes(subAction)) { @@ -110,7 +107,7 @@ async function executor( if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment(caseId, comment); + data = await caseClient.addComment({ caseId, comment }); } return { status: 'ok', data: data ?? {}, actionId }; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index d6633c27cf620..030a1bf7da5ef 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -39,7 +39,7 @@ const AlertIDSchema = schema.object( ); const ContextTypeAlertGroupSchema = schema.object({ - type: schema.literal(CommentType.alertGroup), + type: schema.literal(CommentType.generatedAlert), alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), index: schema.string(), ruleId: schema.string(), diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 4e82552b8c341..dfce7cf6f4560 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -35,7 +35,7 @@ import { AlertService, AlertServiceContract, } from './services'; -import { createCaseClient } from './client'; +import { CaseClientImpl, createExternalCaseClient } from './client'; import { registerConnectors } from './connectors'; import type { CasesRequestHandlerContext } from './types'; @@ -129,7 +129,7 @@ export class CasePlugin { context: CasesRequestHandlerContext, request: KibanaRequest ) => { - return createCaseClient({ + return createExternalCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, caseService: this.caseService!, @@ -169,7 +169,7 @@ export class CasePlugin { const [{ savedObjects }] = await core.getStartServices(); return { getCaseClient: () => { - return createCaseClient({ + return new CaseClientImpl({ savedObjectsClient: savedObjects.getScopedClient(request), caseService, caseConfigureService, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 9010d1bcbe878..c401c2d49a0d0 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -86,13 +86,15 @@ export const createMockSavedObjectsRepository = ({ throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } return result[0]; - } - - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { + } else if (type === CASE_SAVED_OBJECT) { + const result = caseSavedObject.filter((s) => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + } else { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return result[0]; }), find: jest.fn((findArgs) => { if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { 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 7c28ebd24c668..66fe0c5e9a424 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 @@ -18,14 +18,10 @@ export const createRoute = async ( const router = httpService.createRouter(); const log = loggingSystemMock.create().get('case'); - - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index faa41e21fc5e0..8c644ee215040 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -38,6 +38,7 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + converted_by: null, description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', @@ -64,6 +65,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + converted_by: null, connector: { id: 'none', name: 'none', @@ -102,6 +104,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + converted_by: null, connector: { id: '123', name: 'My connector', @@ -148,6 +151,7 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + converted_by: null, connector: { id: '123', name: 'My connector', 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 42c7ae0dfdd28..2ba29a7231f0c 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 @@ -7,7 +7,7 @@ import { KibanaRequest } from 'src/core/server'; import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; import { actionsClientMock } from '../../../../../actions/server/mocks'; -import { createCaseClient } from '../../../client'; +import { createExternalCaseClient } from '../../../client'; import { AlertService, CaseService, @@ -54,7 +54,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { } as unknown) as CasesRequestHandlerContext; const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient: client, request: {} as KibanaRequest, caseService, diff --git a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap index b1f104886aa71..d0f35e0ac98ea 100644 --- a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap +++ b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap @@ -84,6 +84,191 @@ Array [ ] `; +exports[`Utils transformCases transforms correctly 1`] = ` +Object { + "cases": Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "converted_by": null, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "converted_by": null, + "created_at": "2019-11-25T22:32:00.900Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie destroying data!", + "external_service": null, + "id": "mock-id-2", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "Data Destruction", + ], + "title": "Damaging Data Destruction Detected", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:00.900Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzQsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "converted_by": null, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + Object { + "closed_at": "2019-11-25T22:32:17.947Z", + "closed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "converted_by": null, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 4, +} +`; + exports[`Utils transformNewCase transform correctly 1`] = ` Object { "closed_at": null, 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 33aded8fee7f4..ed6ca188c8ace 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 @@ -33,7 +33,7 @@ export function initPostCommentApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.addComment({ caseClient, caseId, comment }), + body: await caseClient.addComment({ caseId, comment }), }); } catch (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 7756102a85014..626deeb513947 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 @@ -127,7 +127,7 @@ async function getCaseCommentStats({ client, id, options: { - filter: `(${type}.attributes.type: ${CommentType.alert} OR ${type}.attributes.type: ${CommentType.alertGroup}) AND ${type}.attributes.associationType: ${associationType}`, + filter: `(${type}.attributes.type: ${CommentType.alert} OR ${type}.attributes.type: ${CommentType.generatedAlert}) AND ${type}.attributes.associationType: ${associationType}`, }, }) ) diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 91371fcc35aaa..a5fffc03549d9 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -30,6 +30,7 @@ import { CaseStatuses, AssociationType, CaseType, + CaseResponse, } from '../../../common/api'; describe('Utils', () => { @@ -181,40 +182,25 @@ describe('Utils', () => { describe('transformCases', () => { it('transforms correctly', () => { - const extraCaseData = [ - { caseId: mockCases[0].id, totalComments: 2 }, - { caseId: mockCases[1].id, totalComments: 2 }, - { caseId: mockCases[2].id, totalComments: 2 }, - { caseId: mockCases[3].id, totalComments: 2 }, - ]; - - const res = transformCases( - { - saved_objects: mockCases.map((obj) => ({ ...obj, score: 1 })), - total: mockCases.length, - per_page: 10, - page: 1, - }, - 2, - 2, - 2, - extraCaseData + const casesMap = new Map( + mockCases.map((obj) => { + return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; + }) ); - expect(res).toEqual({ + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, page: 1, - per_page: 10, - total: mockCases.length, - cases: flattenCaseSavedObjects( - mockCases.map((obj) => ({ ...obj, score: 1 })), - extraCaseData - ), - count_open_cases: 2, - count_closed_cases: 2, - count_in_progress_cases: 2, + perPage: 10, + total: casesMap.size, }); + expect(res).toMatchSnapshot(); }); }); + // TODO: remove these describe('flattenCaseSavedObjects', () => { it('flattens correctly', () => { const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 9e2ca116f2833..85ea5f071aea9 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -37,9 +37,8 @@ import { AssociationType, SubCaseAttributes, SubCaseResponse, - CommentRequestAlertGroupType, - ContextTypeAlertGroupRt, - CommentAlertGroupAttributesType, + CommentRequestGeneratedAlertType, + ContextTypeGeneratedAlertRt, CollectionWithSubCaseResponse, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -105,7 +104,7 @@ type NewCommentArgs = CommentRequest & { * @param comment the comment from the add comment request */ export const getAlertIds = (comment: CommentRequest): string[] => { - if (isAlertGroupContext(comment)) { + if (isGeneratedAlertContext(comment)) { const ids: string[] = []; if (Array.isArray(comment.alerts)) { ids.push( @@ -133,14 +132,13 @@ export const transformNewComment = ({ username, ...comment }: NewCommentArgs): CommentAttributes => { - if (isAlertGroupContext(comment)) { + if (isGeneratedAlertContext(comment)) { const ids = getAlertIds(comment); return { associationType, alertId: ids, index: comment.index, - ruleId: comment.ruleId, type: comment.type, created_at: createdDate, created_by: { email, full_name, username }, @@ -199,6 +197,7 @@ export const transformCases = ({ count_closed_cases: countClosedCases, }); +// TODO: remove because it is no longer used export const flattenCaseSavedObjects = ( savedObjects: Array>, totalCommentByCase: TotalCommentByCase[] @@ -237,23 +236,6 @@ export const flattenCaseSavedObject = ({ subCases, }); -export const flattenCommentableCaseSavedObject = ({ - combinedCase, - comments = [], - totalComment = comments.length, -}: { - combinedCase: CommentableCase; - comments?: Array>; - totalComment?: number; - totalAlerts?: number; -}): CollectionWithSubCaseResponse => ({ - id: combinedCase.id, - version: combinedCase.version ?? '0', - comments: flattenCommentSavedObjects(comments), - totalComment, - ...combinedCase.attributes, -}); - export const flattenSubCaseSavedObject = ({ savedObject, comments = [], @@ -326,10 +308,17 @@ const isAlertContext = ( return context.type === CommentType.alert; }; -export const isAlertGroupContext = ( +/** + * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. + * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is + * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store + * both a generated and user attached alert in the same structure but this function is useful to determine which + * structure the new alert in the request has. + */ +export const isGeneratedAlertContext = ( context: CommentRequest -): context is CommentRequestAlertGroupType => { - return context.type === CommentType.alertGroup; +): context is CommentRequestGeneratedAlertType => { + return context.type === CommentType.generatedAlert; }; export const decodeComment = (comment: CommentRequest) => { @@ -337,28 +326,25 @@ export const decodeComment = (comment: CommentRequest) => { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); } else if (isAlertContext(comment)) { pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isAlertGroupContext(comment)) { - pipe(excess(ContextTypeAlertGroupRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isGeneratedAlertContext(comment)) { + pipe( + excess(ContextTypeGeneratedAlertRt).decode(comment), + fold(throwErrors(badRequest), identity) + ); } }; export const getCommentContextFromAttributes = ( attributes: CommentAttributes -): CommentRequestUserType | CommentRequestAlertType | CommentAlertGroupAttributesType => +): CommentRequestUserType | CommentRequestAlertType => isUserContext(attributes) ? { type: CommentType.user, comment: attributes.comment, } - : isAlertContext(attributes) - ? { - type: CommentType.alert, - alertId: attributes.alertId, - index: attributes.index, - } : { - type: CommentType.alertGroup, + // this can be either alert or generated_alert so just grab it from the attributes + type: attributes.type, alertId: attributes.alertId, index: attributes.index, - ruleId: attributes.ruleId, }; diff --git a/x-pack/plugins/case/server/saved_object_types/sub_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts index f3daad5913c07..3f4d46de63f37 100644 --- a/x-pack/plugins/case/server/saved_object_types/sub_case.ts +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -5,7 +5,6 @@ */ import { SavedObjectsType } from 'src/core/server'; -import { caseMigrations } from './migrations'; export const SUB_CASE_SAVED_OBJECT = 'sub_case'; @@ -55,5 +54,4 @@ export const subCaseSavedObjectType: SavedObjectsType = { }, }, }, - // TODO migration }; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts index 1d8354de74d82..ba11dab965ac4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts @@ -9,7 +9,8 @@ import { Comment } from '../../containers/types'; export const getRuleIdsFromComments = (comments: Comment[]) => comments.reduce((ruleIds, comment: Comment) => { if (comment.type === CommentType.alert) { - return [...ruleIds, comment.alertId]; + const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + return [...ruleIds, ...ids]; } return ruleIds; 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 5c2590825d1b2..e5626d4f0e7e3 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 @@ -333,8 +333,15 @@ export const UserActionTree = React.memo( ), }, ]; + // TODO: need to handle CommentType.generatedAlert here to } else if (comment != null && comment.type === CommentType.alert) { - const alert = alerts[comment.alertId]; + // TODO: clean this up + const alertId = Array.isArray(comment.alertId) + ? comment.alertId.length > 0 + ? comment.alertId[0] + : '' + : comment.alertId; + const alert = alerts[alertId]; return [...comments, getAlertComment({ action, alert, onShowAlertDetails })]; } } diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx index b46840cae60e2..3c45c3cac6e47 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx @@ -188,7 +188,14 @@ const getCommentContent = ( if (comment.type === CommentType.user) { return comment.comment; } else if (comment.type === CommentType.alert) { - const alert = alerts[comment.alertId]; + // TODO: handle generated alerts here to + // TODO: clean this up + const alertId = Array.isArray(comment.alertId) + ? comment.alertId.length > 0 + ? comment.alertId[0] + : '' + : comment.alertId; + const alert = alerts[alertId]; const ruleDetailsLink = formatUrl(getRuleDetailsUrl(alert.rule.id), { absolute: true, skipSearch: true, From bc62907ed073f415ca976d79a7f1bacf4868f30c Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 27 Jan 2021 19:07:47 -0500 Subject: [PATCH 12/47] Updating snapshots --- .../server/routes/api/__snapshots__/utils.test.ts.snap | 9 +++++++++ .../api/cases/__snapshots__/patch_cases.test.ts.snap | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap index d0f35e0ac98ea..8aa5fda311a25 100644 --- a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap +++ b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap @@ -12,6 +12,7 @@ Array [ "name": "none", "type": ".none", }, + "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": "testemail@elastic.co", @@ -25,10 +26,12 @@ Array [ "syncAlerts": true, }, "status": "open", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 2, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", @@ -54,6 +57,7 @@ Array [ "name": "none", "type": ".none", }, + "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": "testemail@elastic.co", @@ -67,10 +71,12 @@ Array [ "syncAlerts": true, }, "status": "open", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", @@ -292,6 +298,7 @@ Object { "name": "My connector", "type": ".jira", }, + "converted_by": null, "created_at": "2020-04-09T09:43:51.778Z", "created_by": Object { "email": "elastic@elastic.co", @@ -338,6 +345,7 @@ Object { "name": "My connector", "type": ".jira", }, + "converted_by": null, "created_at": "2020-04-09T09:43:51.778Z", "created_by": Object { "email": null, @@ -384,6 +392,7 @@ Object { "name": "My connector", "type": ".jira", }, + "converted_by": null, "created_at": "2020-04-09T09:43:51.778Z", "created_by": Object { "email": undefined, diff --git a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap index a24997abcbaaf..ed52e92350f79 100644 --- a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap +++ b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap @@ -12,6 +12,7 @@ Array [ "name": "none", "type": ".none", }, + "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": "testemail@elastic.co", @@ -25,10 +26,12 @@ Array [ "syncAlerts": true, }, "status": "in-progress", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", @@ -58,6 +61,7 @@ Array [ "name": "none", "type": ".none", }, + "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": "testemail@elastic.co", @@ -71,10 +75,12 @@ Array [ "syncAlerts": true, }, "status": "closed", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", @@ -104,6 +110,7 @@ Array [ "name": "My connector", "type": ".jira", }, + "converted_by": null, "created_at": "2019-11-25T22:32:17.947Z", "created_by": Object { "email": "testemail@elastic.co", @@ -117,10 +124,12 @@ Array [ "syncAlerts": true, }, "status": "open", + "subCases": undefined, "tags": Array [ "LOLBins", ], "title": "Another bad one", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", From 250721399fe68fb4110f87f59805959803bb68e0 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 27 Jan 2021 23:41:01 -0500 Subject: [PATCH 13/47] Cleaning up comment references --- x-pack/plugins/case/server/client/client.ts | 15 +------------ .../case/server/client/comments/add.ts | 22 +++++++++++++------ x-pack/plugins/case/server/client/types.ts | 8 ------- .../server/routes/api/cases/find_cases.ts | 1 - 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index 5941b6d7dfafe..2c9298ed85245 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -17,11 +17,10 @@ import { MappingsClient, CaseClientUpdateAlertsStatus, CaseClientAddComment, - CaseClientPluginContract, } from './types'; import { create } from './cases/create'; import { update } from './cases/update'; -import { addComment, addGeneratedAlerts } from './comments/add'; +import { addComment } from './comments/add'; import { getFields } from './configure/get_fields'; import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; @@ -39,7 +38,6 @@ import { CasesPatchRequest, CasesPatchRequestRt, CaseType, - CommentRequestGeneratedAlertType, excess, throwErrors, } from '../../common/api'; @@ -135,17 +133,6 @@ export class CaseClientImpl implements CaseClient { }); } - public async addGeneratedAlerts(caseId: string, comment: CommentRequestGeneratedAlertType) { - return addGeneratedAlerts({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - caseClient: this, - caseId, - comment, - }); - } - public async addComment({ caseId, comment }: CaseClientAddComment) { return addComment({ savedObjectsClient: this._savedObjectsClient, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index b5fdf4ccca1ab..1f0a8a84e2d8b 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -66,7 +66,7 @@ interface AddCommentFromRuleArgs { userActionService: CaseUserActionServiceSetup; } -export const addGeneratedAlerts = async ({ +const addGeneratedAlerts = async ({ savedObjectsClient, caseService, userActionService, @@ -115,11 +115,12 @@ export const addGeneratedAlerts = async ({ ...userDetails, }), references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: myCase.id, - }, + // TODO: I don't think we need this? + // { + // type: CASE_SAVED_OBJECT, + // name: `associated-${CASE_SAVED_OBJECT}`, + // id: myCase.id, + // }, { type: SUB_CASE_SAVED_OBJECT, name: `associated-${SUB_CASE_SAVED_OBJECT}`, @@ -267,7 +268,14 @@ export const addComment = async ({ ); if (isGeneratedAlertContext(comment)) { - return caseClient.addGeneratedAlerts(caseId, comment); + return addGeneratedAlerts({ + caseId, + comment, + caseClient, + savedObjectsClient, + userActionService, + caseService, + }); } decodeComment(comment); diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 56a026f215375..65a75e7718cfb 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -13,13 +13,10 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, - CasesUpdateRequest, CollectionWithSubCaseResponse, CommentRequest, - CommentRequestGeneratedAlertType, ConnectorMappingsAttributes, GetFieldsResponse, - SubCaseResponse, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -30,7 +27,6 @@ import { import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; // TODO: Remove unused types -import type { CasesRequestHandlerContext } from '../types'; export interface CaseClientCreate { theCase: CaseClientPostRequest; @@ -85,10 +81,6 @@ export interface CaseClientPluginContract { } export interface CaseClient extends CaseClientPluginContract { - addGeneratedAlerts( - caseId: string, - comment: CommentRequestGeneratedAlertType - ): Promise; convertCaseToCollection(caseInfo: CaseConvertRequest): Promise; } export interface MappingsClient { 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 626deeb513947..03e3efff0a1d2 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 @@ -25,7 +25,6 @@ import { ESCaseAttributes, SubCaseAttributes, CommentType, - CommentAttributes, CaseType, SavedObjectFindOptions, CaseResponse, From d48648341166f5398702a27895b54eaf70c151aa Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 28 Jan 2021 12:58:46 -0500 Subject: [PATCH 14/47] Working unit tests --- x-pack/plugins/case/common/api/cases/case.ts | 1 - .../case/server/connectors/case/index.test.ts | 40 ++- .../case/server/connectors/case/index.ts | 6 +- .../__fixtures__/create_mock_so_repository.ts | 51 +++- .../api/__snapshots__/utils.test.ts.snap | 247 ++++++++++++++++++ .../__snapshots__/post_case.test.ts.snap | 3 + .../api/cases/comments/patch_comment.test.ts | 16 +- .../routes/api/cases/delete_cases.test.ts | 47 ++-- .../server/routes/api/cases/delete_cases.ts | 2 - .../server/routes/api/cases/find_cases.ts | 50 +++- .../case/server/routes/api/cases/post_case.ts | 2 +- .../case/server/routes/api/utils.test.ts | 108 +------- 12 files changed, 398 insertions(+), 175 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index d07bb9fb9e97d..5bc5b03d855c0 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -29,7 +29,6 @@ const CaseBasicNoTypeRt = rt.type({ status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, - type: CaseTypeRt, connector: CaseConnectorRt, settings: SettingsRt, }); 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 2ac45cd5c7e3d..63af12279bd32 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -891,20 +891,18 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: createReturn }); expect(mockCaseClient.create).toHaveBeenCalledWith({ - theCase: { - ...params.subActionParams, - connector: { - id: 'jira', - name: 'Jira', - type: '.jira', - fields: { - issueType: '10006', - priority: 'High', - parent: null, - }, + ...params.subActionParams, + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, }, - type: CaseType.parent, }, + type: CaseType.parent, }); }); }); @@ -984,17 +982,14 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); expect(mockCaseClient.update).toHaveBeenCalledWith({ - caseClient: mockCaseClient, // Null values have been striped out. - cases: { - cases: [ - { - id: 'case-id', - version: '123', - title: 'Update title', - }, - ], - }, + cases: [ + { + id: 'case-id', + version: '123', + title: 'Update title', + }, + ], }); }); }); @@ -1074,7 +1069,6 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ - caseClient: mockCaseClient, caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 5ddde7009afd1..3603b3571a060 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -9,7 +9,7 @@ import { curry } from 'lodash'; import { KibanaRequest } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest, CaseType } from '../../../common/api'; -import { CaseClientImpl } from '../../client'; +import { CaseClientImpl, createExternalCaseClient } from '../../client'; import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; import { CaseExecutorResponse, @@ -68,7 +68,9 @@ async function executor( let data: CaseExecutorResponse | null = null; const { savedObjectsClient } = services; - const caseClient = new CaseClientImpl({ + // TODO: ??? calling a constructor in a curry generates this error, TypeError: _client.CaseClientImpl is not a constructor + // const caseClient = new CaseClientImpl({ + const caseClient = createExternalCaseClient({ savedObjectsClient, // TODO: refactor this request: {} as KibanaRequest, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index c401c2d49a0d0..f80d9c37e2a8e 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -9,6 +9,7 @@ import { SavedObjectsErrorHelpers, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsFindOptions, } from 'src/core/server'; import { @@ -16,6 +17,7 @@ import { CASE_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, } from '../../../saved_object_types'; export const createMockSavedObjectsRepository = ({ @@ -96,8 +98,19 @@ export const createMockSavedObjectsRepository = ({ throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } }), - find: jest.fn((findArgs) => { - if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + find: jest.fn((findArgs: SavedObjectsFindOptions) => { + // References can be an array so we need to loop through it looking for the bad-guy + const hasReferenceIncludeBadGuy = (args: SavedObjectsFindOptions) => { + const references = args.hasReference; + if (references) { + return Array.isArray(references) + ? references.some((ref) => ref.id === 'bad-guy') + : references.id === 'bad-guy'; + } else { + return false; + } + }; + if (hasReferenceIncludeBadGuy(findArgs)) { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } @@ -137,6 +150,17 @@ export const createMockSavedObjectsRepository = ({ saved_objects: caseCommentSavedObject, }; } + + // Currently not supporting sub cases in this mock library + if (findArgs.type === SUB_CASE_SAVED_OBJECT) { + return { + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + }; + } + return { page: 1, per_page: 5, @@ -193,19 +217,22 @@ export const createMockSavedObjectsRepository = ({ }), update: jest.fn((type, id, attributes) => { if (type === CASE_COMMENT_SAVED_OBJECT) { - if (!caseCommentSavedObject.find((s) => s.id === id)) { + const foundComment = caseCommentSavedObject.findIndex((s: { id: string }) => s.id === id); + if (foundComment === -1) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - caseCommentSavedObject = [ - ...caseCommentSavedObject, - { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, + const comment = caseCommentSavedObject[foundComment]; + caseCommentSavedObject.splice(foundComment, 1, { + ...comment, + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes: { + ...comment.attributes, + ...attributes, }, - ]; + }); } else if (type === CASE_SAVED_OBJECT) { if (!caseSavedObject.find((s) => s.id === id)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); diff --git a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap index 8aa5fda311a25..0f8d644dd74c9 100644 --- a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap +++ b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap @@ -1,5 +1,209 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Utils flattenCaseSavedObject flattens correctly 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "converted_by": null, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", +} +`; + +exports[`Utils flattenCaseSavedObject flattens correctly with comments 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [ + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:55:00.177Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "id": "mock-comment-1", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": "2019-11-25T21:55:00.177Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzEsMV0=", + }, + ], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "converted_by": null, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", +} +`; + +exports[`Utils flattenCaseSavedObject flattens correctly without version 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "converted_by": null, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "0", +} +`; + +exports[`Utils flattenCaseSavedObject inserts missing connector 1`] = ` +Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", +} +`; + exports[`Utils flattenCaseSavedObjects flattens correctly 1`] = ` Array [ Object { @@ -45,6 +249,49 @@ Array [ ] `; +exports[`Utils flattenCaseSavedObjects inserts missing connector 1`] = ` +Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, +] +`; + exports[`Utils flattenCaseSavedObjects it handles total comments correctly when caseId is not in extraCaseData 1`] = ` Array [ Object { diff --git a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap index b03eae2e9a481..f6cb714d19387 100644 --- a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap +++ b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap @@ -11,6 +11,7 @@ Object { "name": "none", "type": ".none", }, + "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": null, @@ -24,10 +25,12 @@ Object { "syncAlerts": true, }, "status": "open", + "subCases": undefined, "tags": Array [ "defacement", ], "title": "Super Bad Security Issue", + "totalAlerts": 0, "totalComment": 0, "type": "individual", "updated_at": null, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 5cb411f17a744..d5d85ed9c949c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -26,6 +26,7 @@ describe('PATCH comment', () => { }); it(`Patch a comment`, async () => { + const commentID = 'mock-comment-1'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -35,7 +36,7 @@ describe('PATCH comment', () => { body: { type: CommentType.user, comment: 'Update my comment', - id: 'mock-comment-1', + id: commentID, version: 'WzEsMV0=', }, }); @@ -49,12 +50,14 @@ describe('PATCH comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].comment).toEqual( - 'Update my comment' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.comment).toEqual('Update my comment'); }); it(`Patch an alert`, async () => { + const commentID = 'mock-comment-4'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -65,7 +68,7 @@ describe('PATCH comment', () => { type: CommentType.alert, alertId: 'new-id', index: 'test-index', - id: 'mock-comment-4', + id: commentID, version: 'WzYsMV0=', }, }); @@ -79,9 +82,10 @@ describe('PATCH comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual( - 'new-id' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.alertId).toEqual('new-id'); }); it(`it throws when missing attributes: type user`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index 3970534140cd8..ccf264823ef20 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -51,12 +51,15 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const theContext = await createRouteContext(mockSO); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(404); @@ -70,12 +73,16 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCaseComments, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const theContext = await createRouteContext(mockSO); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -89,12 +96,16 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCasesErrorTriggerData, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const theContext = await createRouteContext(mockSO); const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(400); 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 191adf4c7d498..fc56b3306fa90 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 @@ -97,7 +97,6 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R body: `Case IDs: [${unremovable.join(' ,')}] are not removable`, }); } - await Promise.all( request.query.ids.map((id) => caseService.deleteCase({ @@ -131,7 +130,6 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R } await deleteSubCases({ caseService, client, caseIds: request.query.ids }); - // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request, response }); const deleteDate = new Date().toISOString(); 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 03e3efff0a1d2..2d15ff33f934a 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 @@ -162,6 +162,29 @@ async function getCaseCommentStats({ /** * Constructs the filters used for finding cases and sub cases. + * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases + * and sub cases. + * + * Scenario 1: + * Type == Individual + * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that + * specific filter when call the saved objects find api. This will filter out any collection cases. + * + * Scenario 2: + * Type == collection + * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add + * the filter for collections AND we need to ignore any status filter for the case find call. This is because a + * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection + * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. + * + * Scenario 3: + * No Type is specified + * If the API request does not want to filter on type but instead get both collections and regular individual cases then + * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: + * + * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) + * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other + * filter criteria. When we search for sub cases we will use that status filter in that find call as well. */ function constructQueries({ tags, @@ -220,9 +243,18 @@ function constructQueries({ }; } default: { - // The cases filter will result in this structure "(status == open or type == parent) and (tags == blah) and (reporter == yo)" - // The sub case filter will use the query.status if it exists - const statusFilter = addStatusFilter({ status }); + /** + * In this scenario no type filter was sent, so we want to honor the status filter if one exists. + * To construct the filter and honor the status portion we need to find all individual cases that + * have that particular status. We also need to find cases that have sub cases but we want to ignore the + * case collection's status because it is not relevant. We only care about the status of the sub cases if the + * case is a collection. + * + * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" + * The sub case filter will use the query.status if it exists + */ + const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; const statusAndType = combineFilters([statusFilter, typeFilter], 'OR'); const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); @@ -365,14 +397,17 @@ async function findCases({ ids: cases.saved_objects.map((caseInfo) => caseInfo.id), includeCommentsStats, }); - const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { const subCasesForCase = subCases.get(caseInfo.id); - // if we don't have the sub cases for the case and the case is a collection then ignore it - // unless we're forcing retrieval of empty collections + /** + * If we don't have the sub cases for the case and the case is a collection then ignore it + * unless we're forcing retrieval of empty collections. Otherwise if the case is an individual case + * then include it. + */ if ( (subCasesForCase && caseInfo.attributes.type === CaseType.parent) || - includeEmptyCollections + includeEmptyCollections || + caseInfo.attributes.type === CaseType.individual ) { accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); } @@ -449,6 +484,7 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: includeCommentsStats: true, }), ...caseStatuses.map((status) => { + // TODO: maybe clean this up const statusQuery = constructQueries({ ...queryArgs, status }); return findCases({ client, 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 b71410f603acc..431f6b4b84da3 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 @@ -27,7 +27,7 @@ export function initPostCaseApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.create({ theCase: { ...theCase, type: CaseType.individual } }), + body: await caseClient.create({ ...theCase, type: CaseType.individual }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index a5fffc03549d9..aaea1366b7fa4 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -228,42 +228,7 @@ describe('Utils', () => { // @ts-ignore this is to update old case saved objects to include connector const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); - expect(res).toEqual([ - { - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchSnapshot(); }); }); @@ -275,17 +240,7 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchSnapshot(); }); it('flattens correctly without version', () => { @@ -296,17 +251,7 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: '0', - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchSnapshot(); }); it('flattens correctly with comments', () => { @@ -318,17 +263,7 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: flattenCommentSavedObjects(comments), - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchSnapshot(); }); it('inserts missing connector', () => { @@ -342,40 +277,7 @@ describe('Utils', () => { ...extraCaseData, }); - expect(res).toEqual({ - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchSnapshot(); }); }); From 2ee61b0a56a758a3013b6154482c00f6a3298030 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 28 Jan 2021 18:39:23 -0500 Subject: [PATCH 15/47] Fixing integration tests and got ES to work --- .../client/alerts/update_status.test.ts | 1 - .../server/client/alerts/update_status.ts | 9 +- x-pack/plugins/case/server/client/client.ts | 11 ++- .../case/server/client/comments/add.ts | 15 ++- .../client/configure/get_mappings.test.ts | 2 - .../plugins/case/server/client/index.test.ts | 5 + x-pack/plugins/case/server/client/types.ts | 9 +- .../server/common/models/commentable_case.ts | 1 + .../case/server/connectors/case/index.ts | 14 +-- .../case/server/connectors/case/schema.ts | 1 - x-pack/plugins/case/server/plugin.ts | 4 +- .../api/cases/comments/get_all_comment.ts | 6 ++ .../routes/api/cases/comments/post_comment.ts | 1 - .../api/cases/configure/get_configure.ts | 1 - .../api/cases/configure/patch_configure.ts | 1 - .../api/cases/configure/post_configure.ts | 1 - .../cases/configure/post_push_to_service.ts | 1 - .../server/routes/api/cases/delete_cases.ts | 9 +- .../server/routes/api/cases/find_cases.ts | 2 + .../routes/api/cases/sub_case/get_sub_case.ts | 1 + .../server/saved_object_types/comments.ts | 3 + .../server/saved_object_types/migrations.ts | 18 +++- .../case/server/services/alerts/index.test.ts | 32 +++--- .../case/server/services/alerts/index.ts | 25 +---- x-pack/plugins/case/server/services/index.ts | 26 +++-- x-pack/plugins/case/server/services/mocks.ts | 7 ++ .../tests/cases/comments/delete_comment.ts | 1 - .../user_actions/get_all_user_actions.ts | 9 +- .../basic/tests/connectors/case.ts | 98 ++++++++++++------- .../case_api_integration/common/lib/mock.ts | 13 +++ 30 files changed, 201 insertions(+), 126 deletions(-) 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 16a8735efae27..44dd563d29de6 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,7 +22,6 @@ describe('updateAlertsStatus', () => { expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['alert-id-1'], indices: new Set(['.siem-signals']), - request: {}, 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 73536e56f41b2..08eec606cfa99 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -4,25 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'src/core/server'; +import { ILegacyScopedClusterClient, KibanaRequest } from 'src/core/server'; import { CaseStatuses } from '../../../common/api'; import { AlertServiceContract } from '../../services'; interface UpdateAlertsStatusArgs { alertsService: AlertServiceContract; - request: KibanaRequest; ids: string[]; status: CaseStatuses; indices: Set; + // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think + callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; } // TODO: remove this file export const updateAlertsStatus = async ({ alertsService, - request, ids, status, indices, + callCluster, }: UpdateAlertsStatusArgs): Promise => { - await alertsService.updateAlertsStatus({ ids, status, indices, request }); + await alertsService.updateAlertsStatus({ ids, status, indices, callCluster }); }; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index 2c9298ed85245..d62b19f41755d 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -9,7 +9,11 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { + ILegacyScopedClusterClient, + KibanaRequest, + SavedObjectsClientContract, +} from 'src/core/server'; import { CaseClientFactoryArguments, CaseClient, @@ -44,6 +48,8 @@ import { // TODO: rename export class CaseClientImpl implements CaseClient { + // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think + private readonly _callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; private readonly _caseConfigureService: CaseConfigureServiceSetup; private readonly _caseService: CaseServiceSetup; private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; @@ -54,6 +60,7 @@ export class CaseClientImpl implements CaseClient { // TODO: refactor so these are created in the constructor instead of passed in constructor(clientArgs: CaseClientFactoryArguments) { + this._callCluster = clientArgs.callCluster; this._caseConfigureService = clientArgs.caseConfigureService; this._caseService = clientArgs.caseService; this._connectorMappingsService = clientArgs.connectorMappingsService; @@ -162,7 +169,7 @@ export class CaseClientImpl implements CaseClient { return updateAlertsStatus({ ...args, alertsService: this._alertsService, - request: this.request, + callCluster: this._callCluster, }); } } diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 1f0a8a84e2d8b..726c6f7bb49da 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -115,12 +115,11 @@ const addGeneratedAlerts = async ({ ...userDetails, }), references: [ - // TODO: I don't think we need this? - // { - // type: CASE_SAVED_OBJECT, - // name: `associated-${CASE_SAVED_OBJECT}`, - // id: myCase.id, - // }, + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: myCase.id, + }, { type: SUB_CASE_SAVED_OBJECT, name: `associated-${SUB_CASE_SAVED_OBJECT}`, @@ -158,7 +157,7 @@ const addGeneratedAlerts = async ({ myCase.attributes.settings.syncAlerts ) { const ids = getAlertIds(query); - caseClient.updateAlertsStatus({ + await caseClient.updateAlertsStatus({ ids, status: myCase.attributes.status, indices: new Set([newComment.attributes.index]), @@ -316,7 +315,7 @@ export const addComment = async ({ const ids = Array.isArray(newComment.attributes.alertId) ? newComment.attributes.alertId : [newComment.attributes.alertId]; - caseClient.updateAlertsStatus({ + await caseClient.updateAlertsStatus({ ids, status: updatedCase.status, indices: new Set([newComment.attributes.index]), diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts index 06f24190e2563..b2b72f8326cc0 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts @@ -30,7 +30,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); @@ -44,7 +43,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 0027d2b0bd095..5bd1813ab5f62 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,6 +5,9 @@ */ import { KibanaRequest } from 'kibana/server'; +// TODO: fix this +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { connectorMappingsServiceMock, @@ -18,6 +21,7 @@ jest.mock('./client'); import { CaseClientImpl } from './client'; import { createExternalCaseClient } from './index'; +const esLegacyCluster = legacyClientMock.createScopedClusterClient(); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); @@ -29,6 +33,7 @@ const userActionService = createUserActionServiceMock(); describe('createExternalCaseClient()', () => { test('it creates the client correctly', async () => { createExternalCaseClient({ + callCluster: esLegacyCluster.callAsCurrentUser, alertsService, caseConfigureService, caseService, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 65a75e7718cfb..de54706b31870 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { + ILegacyScopedClusterClient, + KibanaRequest, + SavedObjectsClientContract, +} from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CaseClientPostRequest, @@ -53,6 +57,8 @@ export interface CaseClientUpdateAlertsStatus { } export interface CaseClientFactoryArguments { + // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think + callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; @@ -85,7 +91,6 @@ export interface CaseClient extends CaseClientPluginContract { } export interface MappingsClient { actionsClient: ActionsClient; - caseClient: CaseClient; connectorId: string; connectorType: string; } 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 3e3b4f5dba5cb..1227eafa54f1e 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -111,6 +111,7 @@ export class CommentableCase { const subCaseComments = await this.service.getAllCaseComments({ client: this.soClient, id: this.subCase.id, + subCaseID: this.subCase.id, }); return CollectWithSubCaseResponseRt.encode({ diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 3603b3571a060..575abd7217f7f 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -9,7 +9,7 @@ import { curry } from 'lodash'; import { KibanaRequest } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest, CaseType } from '../../../common/api'; -import { CaseClientImpl, createExternalCaseClient } from '../../client'; +import { createExternalCaseClient } from '../../client'; import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; import { CaseExecutorResponse, @@ -67,11 +67,12 @@ async function executor( const { subAction, subActionParams } = params; let data: CaseExecutorResponse | null = null; - const { savedObjectsClient } = services; + const { savedObjectsClient, callCluster } = services; // TODO: ??? calling a constructor in a curry generates this error, TypeError: _client.CaseClientImpl is not a constructor // const caseClient = new CaseClientImpl({ const caseClient = createExternalCaseClient({ savedObjectsClient, + callCluster, // TODO: refactor this request: {} as KibanaRequest, caseService, @@ -88,11 +89,10 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create( - // TODO: is it possible for the action framework to create an individual case that is not associated with sub cases? - // TODO: I think this should be an individual case... - { ...(subActionParams as CasePostRequest), type: CaseType.parent } - ); + data = await caseClient.create({ + ...(subActionParams as CasePostRequest), + type: CaseType.individual, + }); } if (subAction === 'update') { diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 030a1bf7da5ef..654c05cb43f90 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -42,7 +42,6 @@ const ContextTypeAlertGroupSchema = schema.object({ type: schema.literal(CommentType.generatedAlert), alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), index: schema.string(), - ruleId: schema.string(), }); const ContextTypeAlertSchema = schema.object({ diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index dfce7cf6f4560..f24e08d6178ce 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -8,7 +8,6 @@ import { first, map } from 'rxjs/operators'; import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; -import Boom from '@hapi/boom'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server'; import { APP_ID } from '../common/constants'; @@ -123,13 +122,13 @@ export class CasePlugin { public async start(core: CoreStart) { this.log.debug(`Starting Case Workflow`); - this.alertsService!.initialize(core.elasticsearch.client); const getCaseClientWithRequestAndContext = async ( context: CasesRequestHandlerContext, request: KibanaRequest ) => { return createExternalCaseClient({ + callCluster: context.core.elasticsearch.legacy.client.callAsCurrentUser, savedObjectsClient: core.savedObjects.getScopedClient(request), request, caseService: this.caseService!, @@ -170,6 +169,7 @@ export class CasePlugin { return { getCaseClient: () => { return new CaseClientImpl({ + callCluster: context.core.elasticsearch.legacy.client.callAsCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), caseService, caseConfigureService, 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 a29824570e418..ea4ec3639ed8b 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 @@ -19,6 +19,11 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + sub_case_id: schema.string(), + }) + ), }, }, async (context, request, response) => { @@ -27,6 +32,7 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { const comments = await caseService.getAllCaseComments({ client, id: request.params.case_id, + subCaseID: request.query?.sub_case_id, }); return response.ok({ body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), 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 ed6ca188c8ace..08d442bccf2cb 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 @@ -28,7 +28,6 @@ export function initPostCommentApi({ router }: RouteDeps) { const caseClient = context.case.getCaseClient(); const caseId = request.params.case_id; - // TODO: is it bad if this allows alert groups? Currently it will not allow it const comment = request.body as CommentRequest; try { 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 6ee8b5d7e4fc2..105b889310246 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 @@ -39,7 +39,6 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); 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 d2f3ea2bec5b9..e992b1402d746 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 @@ -72,7 +72,6 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); 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 b90bdd448d4da..ae4cf5332de79 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 @@ -64,7 +64,6 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: query.connector.id, connectorType: query.connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts index fb7a91d046313..659ebb87f6856 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts @@ -49,7 +49,6 @@ export function initPostPushToService({ router }: RouteDeps) { const myConnectorMappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: params.connector_id, connectorType: body.connector_type, }); 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 fc56b3306fa90..c9e2e0e3d21f5 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 @@ -34,7 +34,12 @@ async function unremovableCases({ const cases = await caseService.getCases({ caseIds: ids, client }); const parentCases = cases.saved_objects.filter( - (caseObj) => caseObj.attributes.type === CaseType.parent + /** + * getCases will return an array of saved_objects and some can be successful cases where as others + * might have failed to find the ID. If it fails to find it, it will set the error field but not + * the attributes so check that we didn't receive an error. + */ + (caseObj) => !caseObj.error && caseObj.attributes.type === CaseType.parent ); return parentCases.map((parentCase) => parentCase.id); @@ -55,7 +60,7 @@ async function deleteSubCases({ ); const commentsForSubCases = await Promise.all( - caseIds.map((id) => caseService.getAllCaseComments({ client, id })) + caseIds.map((id) => caseService.getAllCaseComments({ client, id, subCaseID: id })) ); await Promise.all( 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 2d15ff33f934a..fb149851341c9 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 @@ -108,6 +108,7 @@ async function getCaseCommentStats({ caseService.getAllCaseComments({ client, id, + subCaseID: type === SUB_CASE_SAVED_OBJECT ? id : undefined, options: { fields: [], page: 1, @@ -125,6 +126,7 @@ async function getCaseCommentStats({ caseService.getAllCaseComments({ client, id, + subCaseID: type === SUB_CASE_SAVED_OBJECT ? id : undefined, options: { filter: `(${type}.attributes.type: ${CommentType.alert} OR ${type}.attributes.type: ${CommentType.generatedAlert}) AND ${type}.attributes.associationType: ${associationType}`, }, 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 2b1b1e37db1a5..8c0c77fc02f24 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 @@ -52,6 +52,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { sortField: 'created_at', sortOrder: 'asc', }, + subCaseID: request.params.sub_case_id, }); return response.ok({ diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 8f398c63e01bd..0a18c01977b1e 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -15,6 +15,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { namespaceType: 'single', mappings: { properties: { + associationType: { + type: 'keyword', + }, comment: { type: 'text', }, diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index 48b97a91758c7..0cb975c44e486 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; -import { ConnectorTypes, CommentType, CaseType } from '../../common/api'; +import { ConnectorTypes, CommentType, CaseType, AssociationType } from '../../common/api'; interface UnsanitizedCaseConnector { connector_id: string; @@ -178,6 +178,10 @@ interface SanitizedComment { type: CommentType; } +interface SanitizedCommentAssociationType { + associationType: AssociationType; +} + export const commentsMigrations = { '7.11.0': ( doc: SavedObjectUnsanitizedDoc @@ -191,4 +195,16 @@ export const commentsMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + associationType: AssociationType.case, + }, + references: doc.references || [], + }; + }, }; 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 e36f05dc8098c..db2515170f593 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -8,9 +8,12 @@ import { KibanaRequest } from 'kibana/server'; import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; +// TODO: need to fix this +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; describe('updateAlertsStatus', () => { - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esLegacyCluster = legacyClientMock.createScopedClusterClient(); describe('happy path', () => { let alertService: AlertServiceContract; @@ -19,6 +22,7 @@ describe('updateAlertsStatus', () => { indices: new Set(['.siem-signals']), request: {} as KibanaRequest, status: CaseStatuses.closed, + callCluster: esLegacyCluster.callAsCurrentUser, }; beforeEach(async () => { @@ -27,10 +31,10 @@ describe('updateAlertsStatus', () => { }); test('it update the status of the alert correctly', async () => { - alertService.initialize(esClientMock); + // alertService.initialize(esClientMock); await alertService.updateAlertsStatus(args); - expect(esClientMock.asScoped().asCurrentUser.updateByQuery).toHaveBeenCalledWith({ + expect(esLegacyCluster.callAsCurrentUser).toHaveBeenCalledWith('updateByQuery', { body: { query: { ids: { values: args.ids } }, script: { lang: 'painless', source: `ctx._source.signal.status = '${args.status}'` }, @@ -42,26 +46,24 @@ describe('updateAlertsStatus', () => { }); describe('unhappy path', () => { - test('it throws when service is already initialized', async () => { - alertService.initialize(esClientMock); - expect(() => { - alertService.initialize(esClientMock); - }).toThrow(); - }); + // test('it throws when service is already initialized', async () => { + // alertService.initialize(esClientMock); + // expect(() => { + // alertService.initialize(esClientMock); + // }).toThrow(); + // }); - test('it throws when service is not initialized and try to update the status', async () => { - await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); - }); + // test('it throws when service is not initialized and try to update the status', async () => { + // await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); + // }); it('throws an error if no valid indices are provided', async () => { - alertService.initialize(esClientMock); - expect(async () => { await alertService.updateAlertsStatus({ ids: ['alert-id-1'], status: CaseStatuses.closed, indices: new Set(['']), - request: {} as KibanaRequest, + callCluster: esLegacyCluster.callAsCurrentUser, }); }).rejects.toThrow(); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index a1a89ed5748d6..7f21309b677c3 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -6,38 +6,23 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { IClusterClient, KibanaRequest } from 'kibana/server'; +import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; export type AlertServiceContract = PublicMethodsOf; interface UpdateAlertsStatusArgs { - request: KibanaRequest; ids: string[]; status: CaseStatuses; indices: Set; + // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think + callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; } export class AlertService { - private isInitialized = false; - private esClient?: IClusterClient; - constructor() {} - public initialize(esClient: IClusterClient) { - if (this.isInitialized) { - throw new Error('AlertService already initialized'); - } - - this.isInitialized = true; - this.esClient = esClient; - } - - public async updateAlertsStatus({ request, ids, status, indices }: UpdateAlertsStatusArgs) { - if (!this.isInitialized) { - throw new Error('AlertService not initialized'); - } - + public async updateAlertsStatus({ ids, status, indices, callCluster }: UpdateAlertsStatusArgs) { /** * remove empty strings from the indices, I'm not sure how likely this is but in the case that * the document doesn't have _index set the security_solution code sets the value to an empty string @@ -49,7 +34,7 @@ export class AlertService { } // The above check makes sure that esClient is defined. - const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ + const result = await callCluster('updateByQuery', { index: sanitizedIndices, conflicts: 'abort', body: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 28e46f13ee61a..a9259c97fd062 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -15,6 +15,7 @@ import { SavedObjectReference, SavedObjectsBulkUpdateResponse, SavedObjectsBulkResponse, + SavedObjectsFindOptionsReference, } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; @@ -59,6 +60,7 @@ interface GetCasesArgs extends ClientArgs { interface FindCommentsArgs extends GetCaseArgs { options?: SavedObjectFindOptions; + subCaseID?: string; } interface FindCasesArgs extends ClientArgs { @@ -358,17 +360,19 @@ export class CaseService implements CaseServiceSetup { client, id, options, + subCaseID, }: FindCommentsArgs): Promise> { try { - this.log.debug(`Attempting to GET all comments for case ${id}`); + const ref = + subCaseID == null + ? { type: CASE_SAVED_OBJECT, id } + : { type: SUB_CASE_SAVED_OBJECT, id: subCaseID }; + this.log.debug(`Attempting to GET all comments for case caseID ${id} subCaseID ${subCaseID}`); if (options?.page !== undefined || options?.perPage !== undefined) { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, hasReferenceOperator: 'OR', - hasReference: [ - { type: CASE_SAVED_OBJECT, id }, - { type: SUB_CASE_SAVED_OBJECT, id }, - ], + hasReference: [ref], ...options, }); } @@ -376,10 +380,7 @@ export class CaseService implements CaseServiceSetup { const stats = await client.find({ type: CASE_COMMENT_SAVED_OBJECT, hasReferenceOperator: 'OR', - hasReference: [ - { type: CASE_SAVED_OBJECT, id }, - { type: SUB_CASE_SAVED_OBJECT, id }, - ], + hasReference: [ref], fields: [], page: 1, perPage: 1, @@ -390,16 +391,13 @@ export class CaseService implements CaseServiceSetup { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, hasReferenceOperator: 'OR', - hasReference: [ - { type: CASE_SAVED_OBJECT, id }, - { type: SUB_CASE_SAVED_OBJECT, id }, - ], + hasReference: [ref], page: 1, perPage: stats.total, ...options, }); } catch (error) { - this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + this.log.debug(`Error on GET all comments for case ${id} subCaseID: ${subCaseID}: ${error}`); throw error; } } diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 65f2c845bb400..588ba3db78437 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -19,13 +19,19 @@ export type CaseUserActionServiceMock = jest.Mocked; export type AlertServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => ({ + createSubCase: jest.fn(), deleteCase: jest.fn(), deleteComment: jest.fn(), + deleteSubCase: jest.fn(), findCases: jest.fn(), + findSubCases: jest.fn(), + findSubCasesByCaseId: jest.fn(), getAllCaseComments: jest.fn(), getCase: jest.fn(), getCases: jest.fn(), getComment: jest.fn(), + getMostRecentSubCase: jest.fn(), + getSubCase: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), getUser: jest.fn(), @@ -35,6 +41,7 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ patchCases: jest.fn(), patchComment: jest.fn(), patchComments: jest.fn(), + patchSubCase: jest.fn(), }); export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ 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 6ab29ffa09e13..58ff1e3d1b977 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 @@ -41,7 +41,6 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(204) .send(); - expect(comment).to.eql({}); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 4eb87d2c2d2ce..0ade064228d05 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -9,7 +9,12 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../plugins/case/common/constants'; import { CommentType } from '../../../../../../plugins/case/common/api'; -import { defaultUser, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + userActionPostResp, + defaultUser, + postCaseReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, @@ -72,7 +77,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); - expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); + expect(body[0].new_value).to.eql(JSON.stringify(userActionPostResp)); }); it(`on close case, user action: 'update' should be called with actionFields: ['status']`, async () => { diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index e0812d01d0fb8..26f2f10fd3965 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -16,10 +16,21 @@ import { removeServerGeneratedPropertiesFromCase, removeServerGeneratedPropertiesFromComments, } from '../../../common/lib/mock'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsByIds, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../../../detection_engine_api_integration/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('case_connector', () => { let createdActionId = ''; @@ -679,47 +690,60 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('should fail adding a comment of type alert', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A case connector', - actionTypeId: '.case', - config: {}, - }) - .expect(200); + describe('blah', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); - createdActionId = createdAction.id; + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); - const caseRes = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); + it('should fail adding a comment of type alert', async () => { + // TODO: don't do all this stuff + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + const alert = signals.hits.hits[0]; + + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); - const params = { - subAction: 'addComment', - subActionParams: { - caseId: caseRes.body.id, - comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, - }, - }; + createdActionId = createdAction.id; - const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ params }) - .expect(200); + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); - expect(caseConnector.body).to.eql({ - status: 'error', - actionId: createdActionId, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]', - retry: false, + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { alertId: alert._id, index: alert._index, type: CommentType.alert }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); }); }); @@ -788,7 +812,7 @@ export default ({ getService }: FtrProviderContext): void => { }, }; - for (const attribute of ['alertId', 'index']) { + for (const attribute of ['blah', 'bogus']) { const caseConnector = await supertest .post(`/api/actions/action/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') 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 d61b999a745a0..fdbaeb8590770 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -14,6 +14,8 @@ import { CommentRequestAlertType, CommentType, CaseStatuses, + CaseType, + CaseClientPostRequest, } from '../../../../plugins/case/common/api'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { @@ -31,6 +33,14 @@ export const postCaseReq: CasePostRequest = { }, }; +/** + * This is needed because the post api does not allow specifying the case type. But the response will include the type. + */ +export const userActionPostResp: CaseClientPostRequest = { + ...postCaseReq, + type: CaseType.individual, +}; + export const postCommentUserReq: CommentRequestUserType = { comment: 'This is a cool comment', type: CommentType.user, @@ -49,8 +59,11 @@ export const postCaseResp = ( ...req, id, comments: [], + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, closed_by: null, + converted_by: null, created_by: defaultUser, external_service: null, status: CaseStatuses.open, From 34fb160d4501abd095369a0c640736a4f7d4d936 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Fri, 29 Jan 2021 12:11:31 -0500 Subject: [PATCH 16/47] Unit tests and api integration test working --- .../case/server/connectors/case/index.test.ts | 2 +- .../routes/api/__fixtures__/route_contexts.ts | 8 +- .../server/routes/api/cases/find_cases.ts | 231 ++++++++++++------ .../case/server/services/alerts/index.test.ts | 13 - x-pack/plugins/case/server/services/mocks.ts | 1 - .../basic/tests/connectors/case.ts | 25 +- 6 files changed, 181 insertions(+), 99 deletions(-) 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 63af12279bd32..925ff36899a05 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -902,7 +902,7 @@ describe('case connector', () => { parent: null, }, }, - type: CaseType.parent, + type: CaseType.individual, }); }); }); 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 2ba29a7231f0c..11d767a485b88 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 @@ -5,7 +5,9 @@ */ import { KibanaRequest } from 'src/core/server'; -import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; import { actionsClientMock } from '../../../../../actions/server/mocks'; import { createExternalCaseClient } from '../../../client'; import { @@ -22,7 +24,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esLegacyCluster = legacyClientMock.createScopedClusterClient(); const caseService = new CaseService( log, @@ -33,7 +35,6 @@ export const createRouteContext = async (client: any, badAuth = false) => { const caseConfigureService = await caseConfigureServicePlugin.setup(); const alertsService = new AlertService(); - alertsService.initialize(esClientMock); const context = ({ core: { @@ -65,6 +66,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { getUserActions: jest.fn(), }, alertsService, + callCluster: esLegacyCluster.callAsCurrentUser, }); return context; 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 fb149851341c9..56b6a31af8ba8 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 @@ -39,13 +39,30 @@ import { flattenCaseSavedObject, } from '../utils'; import { RouteDeps } from '../types'; -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; import { CaseServiceSetup } from '../../../services'; import { countAlerts } from '../../../common'; -const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => - filters?.filter((i) => i !== '').join(` ${operator} `); +// TODO: write unit tests for these functions +const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { + const noEmptyStrings = filters?.filter((value) => value !== ''); + const joinedExp = noEmptyStrings?.join(` ${operator} `); + // if undefined or an empty string + if (!joinedExp) { + return ''; + } else if ((noEmptyStrings?.length ?? 0) > 1) { + // if there were multiple filters, wrap them in () + return `(${joinedExp})`; + } else { + // return a single value not wrapped in () + return joinedExp; + } +}; const addStatusFilter = ({ status, @@ -77,15 +94,19 @@ const buildFilter = ({ field: string; operator: 'OR' | 'AND'; type?: string; -}): string => - filters != null && filters.length > 0 - ? Array.isArray(filters) - ? // Be aware of the surrounding parenthesis (as string inside literal) around filters. - `(${filters - .map((filter) => `${type}.attributes.${field}: ${filter}`) - ?.join(` ${operator} `)})` - : `${type}.attributes.${field}: ${filters}` - : ''; +}): string => { + // if it is an empty string, empty array of strings, or undefined just return + if (!filters || filters.length <= 0) { + return ''; + } + + const arrayFilters = !Array.isArray(filters) ? [filters] : filters; + + return combineFilters( + arrayFilters.map((filter) => `${type}.attributes.${field}: ${filter}`), + operator + ); +}; interface SubCaseStats { commentTotals: Map; @@ -128,7 +149,7 @@ async function getCaseCommentStats({ id, subCaseID: type === SUB_CASE_SAVED_OBJECT ? id : undefined, options: { - filter: `(${type}.attributes.type: ${CommentType.alert} OR ${type}.attributes.type: ${CommentType.generatedAlert}) AND ${type}.attributes.associationType: ${associationType}`, + filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}) AND ${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${associationType}`, }, }) ) @@ -256,9 +277,10 @@ function constructQueries({ * The sub case filter will use the query.status if it exists */ const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; + const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); - const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; - const statusAndType = combineFilters([statusFilter, typeFilter], 'OR'); + const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); return { @@ -283,13 +305,11 @@ async function findSubCases({ subCaseOptions, caseService, ids, - includeCommentsStats, }: { client: SavedObjectsClientContract; subCaseOptions?: SavedObjectFindOptions; caseService: CaseServiceSetup; ids: string[]; - includeCommentsStats: boolean; }): Promise> { const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { return subCase.references.length > 0 ? subCase.references[0].id : undefined; @@ -312,21 +332,14 @@ async function findSubCases({ }, }); - let subCaseComments: SubCaseStats = { - commentTotals: new Map(), - alertTotals: new Map(), - }; - - if (includeCommentsStats) { - subCaseComments = await getCaseCommentStats({ - client, - caseService, - ids: subCases.saved_objects.map((subCase) => subCase.id), - type: SUB_CASE_SAVED_OBJECT, - }); - } + const subCaseComments = await getCaseCommentStats({ + client, + caseService, + ids: subCases.saved_objects.map((subCase) => subCase.id), + type: SUB_CASE_SAVED_OBJECT, + }); - return subCases.saved_objects.reduce((accMap, subCase) => { + const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { const id = getCaseID(subCase); if (id) { const subCaseFromMap = accMap.get(id); @@ -352,6 +365,8 @@ async function findSubCases({ } return accMap; }, new Map()); + + return subCasesMap; } interface Collection { @@ -366,11 +381,7 @@ interface CasesMapWithPageInfo { } /** - * Returns a map of all cases combined with their sub cases if they are collections and - * optionally includes the statistics for the cases' comments. - * - * @param includeEmptyCollections is a flag for whether to include collections that don't - * have any sub cases + * Returns a map of all cases combined with their sub cases if they are collections. */ async function findCases({ client, @@ -378,29 +389,26 @@ async function findCases({ subCaseOptions, caseService, includeEmptyCollections, - includeCommentsStats, }: { client: SavedObjectsClientContract; caseOptions: SavedObjectFindOptions; subCaseOptions?: SavedObjectFindOptions; caseService: CaseServiceSetup; includeEmptyCollections: boolean; - includeCommentsStats: boolean; }): Promise { const cases = await caseService.findCases({ client, options: caseOptions, }); - const subCases = await findSubCases({ + const subCasesResp = await findSubCases({ client, subCaseOptions, caseService, ids: cases.saved_objects.map((caseInfo) => caseInfo.id), - includeCommentsStats, }); const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { - const subCasesForCase = subCases.get(caseInfo.id); + const subCasesForCase = subCasesResp.get(caseInfo.id); /** * If we don't have the sub cases for the case and the case is a collection then ignore it * unless we're forcing retrieval of empty collections. Otherwise if the case is an individual case @@ -416,19 +424,12 @@ async function findCases({ return accMap; }, new Map()); - let totalCommentsForCases: SubCaseStats = { - commentTotals: new Map(), - alertTotals: new Map(), - }; - - if (includeCommentsStats) { - totalCommentsForCases = await getCaseCommentStats({ - client, - caseService, - ids: Array.from(casesMap.keys()), - type: CASE_SAVED_OBJECT, - }); - } + const totalCommentsForCases = await getCaseCommentStats({ + client, + caseService, + ids: Array.from(casesMap.keys()), + type: CASE_SAVED_OBJECT, + }); const casesWithComments = new Map(); for (const [id, caseInfo] of casesMap.entries()) { @@ -450,6 +451,90 @@ async function findCases({ }; } +// TODO: move to the service layer +/** + * + */ +async function findCaseStatusStats({ + client, + caseOptions, + caseService, + subCaseOptions, +}: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + caseService: CaseServiceSetup; +}): Promise { + // TODO: move the double calls to the service layer? + const casesStats = await caseService.findCases({ + client, + options: { + ...caseOptions, + fields: [], + page: 1, + perPage: 1, + }, + }); + + /** + * This could be made more performant. What we're doing here is retrieving all cases + * that match the API request's filters instead of just counts. This is because we need to grab + * the ids for the parent cases that match those filters. Then we use those IDS to count how many + * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. + * + * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single + * query for each type to calculate the totals using the filters. This has drawbacks though: + * + * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid + * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot + * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. + * + * Another option is to prevent the ability from update the parent case's details all together once it's created. A user + * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same + * parent would have different titles, tags, etc. + * + * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases + * don't have the same title and tags, we'd need to account for that as well. + */ + const cases = await caseService.findCases({ + client, + options: { + ...caseOptions, + // TODO: move this to a variable that the cases spec uses to define the field + fields: ['type'], + page: 1, + perPage: casesStats.total, + }, + }); + + const caseIds = cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.parent) + .map((caseInfo) => caseInfo.id); + + const subCases = await caseService.findSubCases({ + client, + options: { + ...subCaseOptions, + page: 1, + perPage: 1, + fields: [], + hasReference: caseIds.map((id) => { + return { + id, + type: SUB_CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.parent).length + + subCases.total; + + return total; +} + export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( { @@ -476,32 +561,22 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: const caseQueries = constructQueries(queryArgs); - const [cases, openCases, inProgressCases, closedCases] = await Promise.all([ - findCases({ - client, - caseOptions: { ...queryParams, ...caseQueries.case }, - subCaseOptions: caseQueries.subCase, - caseService, - includeEmptyCollections: queryParams.type === CaseType.parent || !queryParams.status, - includeCommentsStats: true, - }), + const cases = await findCases({ + client, + caseOptions: { ...queryParams, ...caseQueries.case }, + subCaseOptions: caseQueries.subCase, + caseService, + includeEmptyCollections: queryParams.type === CaseType.parent || !queryParams.status, + }); + + const [openCases, inProgressCases, closedCases] = await Promise.all([ ...caseStatuses.map((status) => { - // TODO: maybe clean this up const statusQuery = constructQueries({ ...queryArgs, status }); - return findCases({ + return findCaseStatusStats({ client, - caseOptions: { - ...statusQuery.case, - fields: ['attributes.type'], - page: 1, - perPage: 1, - }, + caseOptions: statusQuery.case, subCaseOptions: statusQuery.subCase, caseService, - includeEmptyCollections: false, - // we don't need the comment stats because we're just trying to get the total open, closed, and in-progress - // cases - includeCommentsStats: false, }); }), ]); @@ -510,9 +585,9 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: body: CasesFindResponseRt.encode( transformCases({ ...cases, - countOpenCases: openCases.casesMap.size, - countInProgressCases: inProgressCases.casesMap.size, - countClosedCases: closedCases.casesMap.size, + countOpenCases: openCases, + countInProgressCases: inProgressCases, + countClosedCases: closedCases, total: cases.casesMap.size, }) ), 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 db2515170f593..5b53770053624 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -5,7 +5,6 @@ */ import { KibanaRequest } from 'kibana/server'; -import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; // TODO: need to fix this @@ -31,7 +30,6 @@ describe('updateAlertsStatus', () => { }); test('it update the status of the alert correctly', async () => { - // alertService.initialize(esClientMock); await alertService.updateAlertsStatus(args); expect(esLegacyCluster.callAsCurrentUser).toHaveBeenCalledWith('updateByQuery', { @@ -46,17 +44,6 @@ describe('updateAlertsStatus', () => { }); describe('unhappy path', () => { - // test('it throws when service is already initialized', async () => { - // alertService.initialize(esClientMock); - // expect(() => { - // alertService.initialize(esClientMock); - // }).toThrow(); - // }); - - // test('it throws when service is not initialized and try to update the status', async () => { - // await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); - // }); - it('throws an error if no valid indices are provided', async () => { expect(async () => { await alertService.updateAlertsStatus({ diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 588ba3db78437..7b8f999877ea7 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -63,6 +63,5 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ }); export const createAlertServiceMock = (): AlertServiceMock => ({ - initialize: jest.fn(), updateAlertsStatus: jest.fn(), }); diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 26f2f10fd3965..c14f0d7ead6e4 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -690,7 +690,7 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - describe('blah', () => { + describe('adding alerts using a connector', () => { beforeEach(async () => { await esArchiver.load('auditbeat/hosts'); await createSignalsIndex(supertest); @@ -702,7 +702,7 @@ export default ({ getService }: FtrProviderContext): void => { await esArchiver.unload('auditbeat/hosts'); }); - it('should fail adding a comment of type alert', async () => { + it('should add a comment of type alert', async () => { // TODO: don't do all this stuff const rule = getRuleForSignalTesting(['auditbeat-*']); const { id } = await createRule(supertest, rule); @@ -744,6 +744,25 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); expect(caseConnector.body.status).to.eql('ok'); + + const { body } = await supertest + .get(`${CASES_URL}/${caseRes.body.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + const comments = removeServerGeneratedPropertiesFromComments(data.comments ?? []); + expect({ ...data, comments }).to.eql({ + ...postCaseResp(caseRes.body.id), + comments, + totalComment: 1, + updated_by: { + email: null, + full_name: null, + username: null, + }, + }); }); }); @@ -830,7 +849,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert]`, retry: false, }); } From 435b741ef097f95daa46b061dc08c7ab3f36fc67 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Fri, 29 Jan 2021 13:15:23 -0500 Subject: [PATCH 17/47] Refactoring find and get_status --- .../server/routes/api/cases/find_cases.ts | 276 +----------------- .../case/server/routes/api/cases/helpers.ts | 266 ++++++++++++++++- .../api/cases/status/get_status.test.ts | 11 +- .../routes/api/cases/status/get_status.ts | 31 +- 4 files changed, 303 insertions(+), 281 deletions(-) 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 56b6a31af8ba8..d914ecb1c3547 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 @@ -19,7 +19,6 @@ import { CasesFindResponseRt, CasesFindRequestRt, throwErrors, - CaseStatuses, caseStatuses, SubCaseResponse, ESCaseAttributes, @@ -32,7 +31,6 @@ import { } from '../../../../common/api'; import { transformCases, - sortToSnake, wrapError, escapeHatch, flattenSubCaseSavedObject, @@ -47,66 +45,7 @@ import { import { CASES_URL } from '../../../../common/constants'; import { CaseServiceSetup } from '../../../services'; import { countAlerts } from '../../../common'; - -// TODO: write unit tests for these functions -const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { - const noEmptyStrings = filters?.filter((value) => value !== ''); - const joinedExp = noEmptyStrings?.join(` ${operator} `); - // if undefined or an empty string - if (!joinedExp) { - return ''; - } else if ((noEmptyStrings?.length ?? 0) > 1) { - // if there were multiple filters, wrap them in () - return `(${joinedExp})`; - } else { - // return a single value not wrapped in () - return joinedExp; - } -}; - -const addStatusFilter = ({ - status, - appendFilter, - type = CASE_SAVED_OBJECT, -}: { - status: CaseStatuses | undefined; - appendFilter?: string; - type?: string; -}) => { - const filters: string[] = []; - if (status) { - filters.push(`${type}.attributes.status: ${status}`); - } - - if (appendFilter) { - filters.push(appendFilter); - } - return combineFilters(filters, 'AND'); -}; - -const buildFilter = ({ - filters, - field, - operator, - type = CASE_SAVED_OBJECT, -}: { - filters: string | string[] | undefined; - field: string; - operator: 'OR' | 'AND'; - type?: string; -}): string => { - // if it is an empty string, empty array of strings, or undefined just return - if (!filters || filters.length <= 0) { - return ''; - } - - const arrayFilters = !Array.isArray(filters) ? [filters] : filters; - - return combineFilters( - arrayFilters.map((filter) => `${type}.attributes.${field}: ${filter}`), - operator - ); -}; +import { constructQueries, findCaseStatusStats } from './helpers'; interface SubCaseStats { commentTotals: Map; @@ -183,120 +122,6 @@ async function getCaseCommentStats({ return { commentTotals: groupedComments, alertTotals: groupedAlerts }; } -/** - * Constructs the filters used for finding cases and sub cases. - * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases - * and sub cases. - * - * Scenario 1: - * Type == Individual - * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that - * specific filter when call the saved objects find api. This will filter out any collection cases. - * - * Scenario 2: - * Type == collection - * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add - * the filter for collections AND we need to ignore any status filter for the case find call. This is because a - * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection - * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. - * - * Scenario 3: - * No Type is specified - * If the API request does not want to filter on type but instead get both collections and regular individual cases then - * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: - * - * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) - * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other - * filter criteria. When we search for sub cases we will use that status filter in that find call as well. - */ -function constructQueries({ - tags, - reporters, - status, - sortByField, - caseType, -}: { - tags?: string | string[]; - reporters?: string | string[]; - status?: CaseStatuses; - sortByField?: string; - caseType?: CaseType; -}): { case: SavedObjectFindOptions; subCase?: SavedObjectFindOptions } { - const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'OR' }); - const reportersFilter = buildFilter({ - filters: reporters, - field: 'created_by.username', - operator: 'OR', - }); - const sortField = sortToSnake(sortByField); - - switch (caseType) { - case CaseType.individual: { - // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" - // The subCase filter will be undefined because we don't need to find sub cases if type === individual - - // We do not want to support multiple type's being used, so force it to be a single filter value - const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; - const caseFilters = addStatusFilter({ - status, - appendFilter: combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'), - }); - return { - case: { - filter: caseFilters, - sortField, - }, - }; - } - case CaseType.parent: { - // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" - // The sub case filter will use the query.status if it exists - const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; - const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); - - return { - case: { - filter: caseFilters, - sortField, - }, - subCase: { - filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), - sortField, - }, - }; - } - default: { - /** - * In this scenario no type filter was sent, so we want to honor the status filter if one exists. - * To construct the filter and honor the status portion we need to find all individual cases that - * have that particular status. We also need to find cases that have sub cases but we want to ignore the - * case collection's status because it is not relevant. We only care about the status of the sub cases if the - * case is a collection. - * - * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" - * The sub case filter will use the query.status if it exists - */ - const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; - const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; - - const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); - const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); - const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); - - return { - case: { - filter: caseFilters, - sortField, - }, - subCase: { - filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), - sortField, - }, - }; - } - } -} - /** * Returns all the sub cases for a set of case IDs. Optionally includes the comment statistics as well. */ @@ -405,7 +230,9 @@ async function findCases({ client, subCaseOptions, caseService, - ids: cases.saved_objects.map((caseInfo) => caseInfo.id), + ids: cases.saved_objects + .filter((caseInfo) => caseInfo.type === CaseType.parent) + .map((caseInfo) => caseInfo.id), }); const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { const subCasesForCase = subCasesResp.get(caseInfo.id); @@ -424,6 +251,17 @@ async function findCases({ return accMap; }, new Map()); + /** + * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases + * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case + * and the parent. The associationType field allows us to determine which type of case the comment is attached to. + * + * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. + * Once we have it we can build the maps. + * + * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) + * in another request (the one below this comment). + */ const totalCommentsForCases = await getCaseCommentStats({ client, caseService, @@ -451,90 +289,6 @@ async function findCases({ }; } -// TODO: move to the service layer -/** - * - */ -async function findCaseStatusStats({ - client, - caseOptions, - caseService, - subCaseOptions, -}: { - client: SavedObjectsClientContract; - caseOptions: SavedObjectFindOptions; - subCaseOptions?: SavedObjectFindOptions; - caseService: CaseServiceSetup; -}): Promise { - // TODO: move the double calls to the service layer? - const casesStats = await caseService.findCases({ - client, - options: { - ...caseOptions, - fields: [], - page: 1, - perPage: 1, - }, - }); - - /** - * This could be made more performant. What we're doing here is retrieving all cases - * that match the API request's filters instead of just counts. This is because we need to grab - * the ids for the parent cases that match those filters. Then we use those IDS to count how many - * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. - * - * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single - * query for each type to calculate the totals using the filters. This has drawbacks though: - * - * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid - * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot - * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. - * - * Another option is to prevent the ability from update the parent case's details all together once it's created. A user - * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same - * parent would have different titles, tags, etc. - * - * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases - * don't have the same title and tags, we'd need to account for that as well. - */ - const cases = await caseService.findCases({ - client, - options: { - ...caseOptions, - // TODO: move this to a variable that the cases spec uses to define the field - fields: ['type'], - page: 1, - perPage: casesStats.total, - }, - }); - - const caseIds = cases.saved_objects - .filter((caseInfo) => caseInfo.attributes.type === CaseType.parent) - .map((caseInfo) => caseInfo.id); - - const subCases = await caseService.findSubCases({ - client, - options: { - ...subCaseOptions, - page: 1, - perPage: 1, - fields: [], - hasReference: caseIds.map((id) => { - return { - id, - type: SUB_CASE_SAVED_OBJECT, - }; - }), - }, - }); - - const total = - cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.parent).length + - subCases.total; - - return total; -} - export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( { diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index b71dc013a1e5b..4d5b361811889 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -7,7 +7,7 @@ import { get, isPlainObject } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { SavedObjectsFindResponse } from 'kibana/server'; +import { SavedObjectsClientContract, SavedObjectsFindResponse } from 'kibana/server'; import { CaseConnector, ESCaseConnector, @@ -15,8 +15,272 @@ import { ESCasePatchRequest, ESCasesConfigureAttributes, ConnectorTypes, + CaseStatuses, + CaseType, + SavedObjectFindOptions, } from '../../../../common/api'; import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { sortToSnake } from '../utils'; +import { CaseServiceSetup } from '../../../services'; + +// TODO: write unit tests for these functions +export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { + const noEmptyStrings = filters?.filter((value) => value !== ''); + const joinedExp = noEmptyStrings?.join(` ${operator} `); + // if undefined or an empty string + if (!joinedExp) { + return ''; + } else if ((noEmptyStrings?.length ?? 0) > 1) { + // if there were multiple filters, wrap them in () + return `(${joinedExp})`; + } else { + // return a single value not wrapped in () + return joinedExp; + } +}; + +export const addStatusFilter = ({ + status, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + status: CaseStatuses | undefined; + appendFilter?: string; + type?: string; +}) => { + const filters: string[] = []; + if (status) { + filters.push(`${type}.attributes.status: ${status}`); + } + + if (appendFilter) { + filters.push(appendFilter); + } + return combineFilters(filters, 'AND'); +}; + +export const buildFilter = ({ + filters, + field, + operator, + type = CASE_SAVED_OBJECT, +}: { + filters: string | string[] | undefined; + field: string; + operator: 'OR' | 'AND'; + type?: string; +}): string => { + // if it is an empty string, empty array of strings, or undefined just return + if (!filters || filters.length <= 0) { + return ''; + } + + const arrayFilters = !Array.isArray(filters) ? [filters] : filters; + + return combineFilters( + arrayFilters.map((filter) => `${type}.attributes.${field}: ${filter}`), + operator + ); +}; + +// TODO: move to the service layer +/** + * Retrieves the number of cases that exist with a given status (open, closed, etc). + * This also counts sub cases. Parent cases are excluded from the statistics. + */ +export const findCaseStatusStats = async ({ + client, + caseOptions, + caseService, + subCaseOptions, +}: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + caseService: CaseServiceSetup; +}): Promise => { + const casesStats = await caseService.findCases({ + client, + options: { + ...caseOptions, + fields: [], + page: 1, + perPage: 1, + }, + }); + + /** + * This could be made more performant. What we're doing here is retrieving all cases + * that match the API request's filters instead of just counts. This is because we need to grab + * the ids for the parent cases that match those filters. Then we use those IDS to count how many + * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. + * + * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single + * query for each type to calculate the totals using the filters. This has drawbacks though: + * + * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid + * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot + * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. + * + * Another option is to prevent the ability from update the parent case's details all together once it's created. A user + * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same + * parent would have different titles, tags, etc. + * + * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases + * don't have the same title and tags, we'd need to account for that as well. + */ + const cases = await caseService.findCases({ + client, + options: { + ...caseOptions, + // TODO: move this to a variable that the cases spec uses to define the field + fields: ['type'], + page: 1, + perPage: casesStats.total, + }, + }); + + const caseIds = cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.parent) + .map((caseInfo) => caseInfo.id); + + const subCases = await caseService.findSubCases({ + client, + options: { + ...subCaseOptions, + page: 1, + perPage: 1, + fields: [], + hasReference: caseIds.map((id) => { + return { + id, + type: SUB_CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.parent).length + + subCases.total; + + return total; +}; + +/** + * Constructs the filters used for finding cases and sub cases. + * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases + * and sub cases. + * + * Scenario 1: + * Type == Individual + * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that + * specific filter when call the saved objects find api. This will filter out any collection cases. + * + * Scenario 2: + * Type == collection + * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add + * the filter for collections AND we need to ignore any status filter for the case find call. This is because a + * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection + * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. + * + * Scenario 3: + * No Type is specified + * If the API request does not want to filter on type but instead get both collections and regular individual cases then + * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: + * + * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) + * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other + * filter criteria. When we search for sub cases we will use that status filter in that find call as well. + */ +export const constructQueries = ({ + tags, + reporters, + status, + sortByField, + caseType, +}: { + tags?: string | string[]; + reporters?: string | string[]; + status?: CaseStatuses; + sortByField?: string; + caseType?: CaseType; +}): { case: SavedObjectFindOptions; subCase?: SavedObjectFindOptions } => { + const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'OR' }); + const reportersFilter = buildFilter({ + filters: reporters, + field: 'created_by.username', + operator: 'OR', + }); + const sortField = sortToSnake(sortByField); + + switch (caseType) { + case CaseType.individual: { + // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" + // The subCase filter will be undefined because we don't need to find sub cases if type === individual + + // We do not want to support multiple type's being used, so force it to be a single filter value + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const caseFilters = addStatusFilter({ + status, + appendFilter: combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'), + }); + return { + case: { + filter: caseFilters, + sortField, + }, + }; + } + case CaseType.parent: { + // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" + // The sub case filter will use the query.status if it exists + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; + const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + default: { + /** + * In this scenario no type filter was sent, so we want to honor the status filter if one exists. + * To construct the filter and honor the status portion we need to find all individual cases that + * have that particular status. We also need to find cases that have sub cases but we want to ignore the + * case collection's status because it is not relevant. We only care about the status of the sub cases if the + * case is a collection. + * + * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" + * The sub case filter will use the query.status if it exists + */ + const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; + + const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); + const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); + const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + } +}; interface CompareArrays { addedItems: string[]; diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts index a5fe5bb3695a9..35b088cac56ab 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -22,6 +22,7 @@ describe('GET status', () => { page: 1, perPage: 1, type: 'cases', + sortField: 'created_at', }; beforeAll(async () => { @@ -41,19 +42,23 @@ describe('GET status', () => { ); const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { ...findArgs, - filter: 'cases.attributes.status: open', + filter: + '((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: parent)', }); expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { ...findArgs, - filter: 'cases.attributes.status: in-progress', + filter: + '((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: parent)', }); expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { ...findArgs, - filter: 'cases.attributes.status: closed', + filter: + '((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: parent)', }); expect(response.payload).toEqual({ 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 4379a6b56367c..c305e71f1520c 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 @@ -8,8 +8,8 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { constructQueries, findCaseStatusStats } from '../helpers'; export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { router.get( @@ -20,25 +20,24 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const args = caseStatuses.map((status) => ({ - client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: `${CASE_SAVED_OBJECT}.attributes.status: ${status}`, - }, - })); - const [openCases, inProgressCases, closesCases] = await Promise.all( - args.map((arg) => caseService.findCases(arg)) - ); + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueries({ status }); + return findCaseStatusStats({ + client, + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + caseService, + }); + }), + ]); return response.ok({ body: CasesStatusResponseRt.encode({ - count_open_cases: openCases.total, - count_in_progress_cases: inProgressCases.total, - count_closed_cases: closesCases.total, + count_open_cases: openCases, + count_in_progress_cases: inProgressCases, + count_closed_cases: closedCases, }), }); } catch (error) { From a4f411417b4b8cba2f05f65db7dd4942e0960ec1 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Fri, 29 Jan 2021 21:33:38 -0500 Subject: [PATCH 18/47] Starting patch, and update --- x-pack/plugins/case/common/api/cases/case.ts | 2 +- .../plugins/case/common/api/cases/comment.ts | 1 + .../plugins/case/common/api/cases/sub_case.ts | 6 +- x-pack/plugins/case/common/constants.ts | 1 + .../case/server/client/cases/update.ts | 3 + .../server/common/models/commentable_case.ts | 6 +- x-pack/plugins/case/server/common/utils.ts | 29 +- .../api/cases/comments/find_comments.ts | 8 + .../server/routes/api/cases/delete_cases.ts | 30 +- .../server/routes/api/cases/find_cases.ts | 182 +-------- .../case/server/routes/api/cases/helpers.ts | 231 ++++++++++- .../api/cases/sub_case/find_sub_cases.ts | 83 ++++ .../api/cases/sub_case/patch_sub_cases.ts | 361 ++++++++++++++++++ .../plugins/case/server/routes/api/index.ts | 5 +- .../plugins/case/server/routes/api/utils.ts | 38 ++ x-pack/plugins/case/server/services/index.ts | 116 ++++-- x-pack/plugins/case/server/services/mocks.ts | 1 + .../public/cases/containers/mock.ts | 9 + .../public/cases/containers/types.ts | 6 + 19 files changed, 874 insertions(+), 244 deletions(-) create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 5bc5b03d855c0..ebf6f0595af01 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -80,8 +80,8 @@ export const CasePostRequestRt = rt.type({ }); export const CaseClientPostRequestRt = rt.type({ - type: CaseTypeRt, ...CasePostRequestRt.props, + type: CaseTypeRt, }); export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 4b2bc88889896..8d888fe07ae09 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -110,6 +110,7 @@ export const CommentsResponseRt = rt.type({ export const AllCommentsResponseRt = rt.array(CommentResponseRt); +export type AttributesTypeAlerts = rt.TypeOf; export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts index 241259c8aa239..cde54da4a787b 100644 --- a/x-pack/plugins/case/common/api/cases/sub_case.ts +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -55,7 +55,7 @@ export const SubCaseResponseRt = rt.intersection([ export const SubCasesFindResponseRt = rt.intersection([ rt.type({ - cases: rt.array(SubCaseResponseRt), + subCases: rt.array(SubCaseResponseRt), page: rt.number, per_page: rt.number, total: rt.number, @@ -68,11 +68,9 @@ export const SubCasePatchRequestRt = rt.intersection([ rt.type({ id: rt.string, version: rt.string }), ]); -export const SubCasesPatchRequestRt = rt.type({ cases: rt.array(SubCasePatchRequestRt) }); +export const SubCasesPatchRequestRt = rt.type({ subCases: rt.array(SubCasePatchRequestRt) }); export const SubCasesResponseRt = rt.array(SubCaseResponseRt); -// TODO: extract these to their own file and rename the types - export type SubCaseAttributes = rt.TypeOf; export type SubCaseResponse = rt.TypeOf; export type SubCasesResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index ab5be099e13f3..b1686fcb59304 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -18,6 +18,7 @@ export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`; export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`; +export const SUB_CASES_PATCH_URL = `${CASES_URL}/sub_cases`; export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index a5fccdbf97293..73dac8b51c69b 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -183,10 +183,13 @@ export const update = async ({ }, }); + // TODO: double check that this logic will get all sub case comments and include them in the updates const caseComments = (await caseService.getAllCaseComments({ client: savedObjectsClient, id: theCase.id, options: { + // TODO: is this a bug? I think this will return no fields for attributes, I have no idea why when + // we attempt to access the attributes that it doesn't crash?? fields: [], filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, page: 1, 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 1227eafa54f1e..8fd76c01e5d4c 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -17,7 +17,7 @@ import { transformESConnectorToCaseConnector } from '../../routes/api/cases/help import { flattenCommentSavedObjects, flattenSubCaseSavedObject } from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; -import { countAlerts } from '../index'; +import { countAlerts, countAlertsFindResponse } from '../index'; interface UserInfo { username: string | null | undefined; @@ -118,7 +118,7 @@ export class CommentableCase { subCase: flattenSubCaseSavedObject({ savedObject: this.subCase, comments: subCaseComments.saved_objects, - totalAlerts: countAlerts(subCaseComments), + totalAlerts: countAlertsFindResponse(subCaseComments), }), ...this.formatCollectionForEncoding(collectionCommentStats.total), }); @@ -136,7 +136,7 @@ export class CommentableCase { return CollectWithSubCaseResponseRt.encode({ comments: flattenCommentSavedObjects(collectionComments.saved_objects), - totalAlerts: countAlerts(collectionComments), + totalAlerts: countAlertsFindResponse(collectionComments), ...this.formatCollectionForEncoding(collectionCommentStats.total), }); } diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index 0f59181737d8d..1412e3d5afd10 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -4,22 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsFindResponse } from 'kibana/server'; +import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; import { CommentAttributes, CommentType } from '../../common/api'; -export const countAlerts = (comments: SavedObjectsFindResponse) => { +export const countAlerts = (comment: SavedObjectsFindResult) => { let totalAlerts = 0; - for (const comment of comments.saved_objects) { - if ( - comment.attributes.type === CommentType.alert || - comment.attributes.type === CommentType.generatedAlert - ) { - if (Array.isArray(comment.attributes.alertId)) { - totalAlerts += comment.attributes.alertId.length; - } else { - totalAlerts++; - } + if ( + comment.attributes.type === CommentType.alert || + comment.attributes.type === CommentType.generatedAlert + ) { + if (Array.isArray(comment.attributes.alertId)) { + totalAlerts += comment.attributes.alertId.length; + } else { + totalAlerts++; } } return totalAlerts; }; + +export const countAlertsFindResponse = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.reduce((total, comment) => { + total += countAlerts(comment); + return total; + }, 0); +}; 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 d6f594f4855fd..0fe3a1f45fb24 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 @@ -20,6 +20,9 @@ import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +const defaultPage = 1; +const defaultPerPage = 20; + export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { router.get( { @@ -44,6 +47,11 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { client, id: request.params.case_id, options: { + // We need this because the default behavior of getAllCaseComments is to return all the comments + // unless the page and/or perPage is specified. Since we're spreading the query after the request can + // still override this behavior. + page: defaultPage, + perPage: defaultPerPage, ...query, sortField: 'created_at', }, 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 c9e2e0e3d21f5..ba0a6fe83a90a 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 @@ -55,24 +55,30 @@ async function deleteSubCases({ client: SavedObjectsClientContract; caseIds: string[]; }) { - const subCasesForCaseIds = await Promise.all( - caseIds.map((id) => caseService.findSubCasesByCaseId(client, id)) - ); + const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ client, ids: caseIds }); - const commentsForSubCases = await Promise.all( - caseIds.map((id) => caseService.getAllCaseComments({ client, id, subCaseID: id })) - ); + const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); + const commentsForSubCases = await caseService.getAllCaseComments({ + client, + id: subCaseIDs, + subCaseID: subCaseIDs, + options: { + fields: [], + }, + }); + // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted + // per case ID await Promise.all( - commentsForSubCases - .flatMap((comment) => comment.saved_objects) - .map((commentSO) => caseService.deleteComment({ client, commentId: commentSO.id })) + commentsForSubCases.saved_objects.map((commentSO) => + caseService.deleteComment({ client, commentId: commentSO.id }) + ) ); await Promise.all( - subCasesForCaseIds - .flatMap((subCase) => subCase.saved_objects) - .map((subCaseSO) => caseService.deleteSubCase(client, subCaseSO.id)) + subCasesForCaseIds.saved_objects.map((subCaseSO) => + caseService.deleteSubCase(client, subCaseSO.id) + ) ); } 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 d914ecb1c3547..a2df3f434e0cc 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 @@ -10,11 +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, - SavedObjectsFindResponse, - SavedObjectsFindResult, -} from 'kibana/server'; +import { SavedObjectsClientContract, SavedObjectsFindResult } from 'kibana/server'; import { CasesFindResponseRt, CasesFindRequestRt, @@ -22,177 +18,21 @@ import { caseStatuses, SubCaseResponse, ESCaseAttributes, - SubCaseAttributes, - CommentType, CaseType, SavedObjectFindOptions, CaseResponse, - AssociationType, } from '../../../../common/api'; -import { - transformCases, - wrapError, - escapeHatch, - flattenSubCaseSavedObject, - flattenCaseSavedObject, -} from '../utils'; +import { transformCases, wrapError, escapeHatch, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; -import { - CASE_COMMENT_SAVED_OBJECT, - CASE_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, -} from '../../../saved_object_types'; +import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; import { CaseServiceSetup } from '../../../services'; -import { countAlerts } from '../../../common'; -import { constructQueries, findCaseStatusStats } from './helpers'; - -interface SubCaseStats { - commentTotals: Map; - alertTotals: Map; -} - -async function getCaseCommentStats({ - client, - caseService, - ids, - type, -}: { - client: SavedObjectsClientContract; - caseService: CaseServiceSetup; - ids: string[]; - type: typeof SUB_CASE_SAVED_OBJECT | typeof CASE_SAVED_OBJECT; -}): Promise { - const allComments = await Promise.all( - ids.map((id) => - caseService.getAllCaseComments({ - client, - id, - subCaseID: type === SUB_CASE_SAVED_OBJECT ? id : undefined, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }) - ) - ); - - const associationType = - type === SUB_CASE_SAVED_OBJECT ? AssociationType.subCase : AssociationType.case; - - const alerts = await Promise.all( - ids.map((id) => - caseService.getAllCaseComments({ - client, - id, - subCaseID: type === SUB_CASE_SAVED_OBJECT ? id : undefined, - options: { - filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}) AND ${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${associationType}`, - }, - }) - ) - ); - - const getID = (comments: SavedObjectsFindResponse) => { - return comments.saved_objects.length > 0 - ? comments.saved_objects[0].references.find((ref) => ref.type === type)?.id - : undefined; - }; - - const groupedComments = allComments.reduce((acc, comments) => { - const id = getID(comments); - if (id) { - acc.set(id, comments.total); - } - return acc; - }, new Map()); - - const groupedAlerts = alerts.reduce((acc, alertsInfo) => { - const id = getID(alertsInfo); - if (id) { - const totalAlerts = acc.get(id); - if (totalAlerts !== undefined) { - acc.set(id, totalAlerts + countAlerts(alertsInfo)); - } - acc.set(id, alertsInfo.total); - } - return acc; - }, new Map()); - return { commentTotals: groupedComments, alertTotals: groupedAlerts }; -} - -/** - * Returns all the sub cases for a set of case IDs. Optionally includes the comment statistics as well. - */ -async function findSubCases({ - client, - subCaseOptions, - caseService, - ids, -}: { - client: SavedObjectsClientContract; - subCaseOptions?: SavedObjectFindOptions; - caseService: CaseServiceSetup; - ids: string[]; -}): Promise> { - const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { - return subCase.references.length > 0 ? subCase.references[0].id : undefined; - }; - - if (!subCaseOptions) { - return new Map(); - } - - const subCases = await caseService.findSubCases({ - client, - options: { - ...subCaseOptions, - hasReference: ids.map((id) => { - return { - id, - type: SUB_CASE_SAVED_OBJECT, - }; - }), - }, - }); - - const subCaseComments = await getCaseCommentStats({ - client, - caseService, - ids: subCases.saved_objects.map((subCase) => subCase.id), - type: SUB_CASE_SAVED_OBJECT, - }); - - const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { - const id = getCaseID(subCase); - if (id) { - const subCaseFromMap = accMap.get(id); - - if (subCaseFromMap === undefined) { - const subCasesForID = [ - flattenSubCaseSavedObject({ - savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, - }), - ]; - accMap.set(id, subCasesForID); - } else { - subCaseFromMap.push( - flattenSubCaseSavedObject({ - savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, - }) - ); - } - } - return accMap; - }, new Map()); - - return subCasesMap; -} +import { + constructQueries, + findCaseStatusStats, + findSubCases, + getCaseCommentStats, +} from './helpers'; interface Collection { case: SavedObjectsFindResult; @@ -228,14 +68,14 @@ async function findCases({ const subCasesResp = await findSubCases({ client, - subCaseOptions, + options: subCaseOptions, caseService, ids: cases.saved_objects .filter((caseInfo) => caseInfo.type === CaseType.parent) .map((caseInfo) => caseInfo.id), }); const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { - const subCasesForCase = subCasesResp.get(caseInfo.id); + const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); /** * If we don't have the sub cases for the case and the case is a collection then ignore it * unless we're forcing retrieval of empty collections. Otherwise if the case is an individual case diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 4d5b361811889..2100ef105623c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -7,7 +7,11 @@ import { get, isPlainObject } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { SavedObjectsClientContract, SavedObjectsFindResponse } from 'kibana/server'; +import { + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsFindResult, +} from 'kibana/server'; import { CaseConnector, ESCaseConnector, @@ -18,11 +22,20 @@ import { CaseStatuses, CaseType, SavedObjectFindOptions, + AssociationType, + CommentType, + SubCaseResponse, + SubCaseAttributes, } from '../../../../common/api'; import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; -import { sortToSnake } from '../utils'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../../saved_object_types'; +import { flattenSubCaseSavedObject, sortToSnake } from '../utils'; import { CaseServiceSetup } from '../../../services'; +import { countAlerts } from '../../../common'; // TODO: write unit tests for these functions export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { @@ -84,6 +97,39 @@ export const buildFilter = ({ ); }; +/** + * Calculates the number of sub cases for a given set of options for a set of case IDs. + */ +export const findSubCaseStatusStats = async ({ + client, + options, + caseService, + ids, +}: { + client: SavedObjectsClientContract; + options: SavedObjectFindOptions; + caseService: CaseServiceSetup; + ids: string[]; +}): Promise => { + const subCases = await caseService.findSubCases({ + client, + options: { + ...options, + page: 1, + perPage: 1, + fields: [], + hasReference: ids.map((id) => { + return { + id, + type: SUB_CASE_SAVED_OBJECT, + }; + }), + }, + }); + + return subCases.total; +}; + // TODO: move to the service layer /** * Retrieves the number of cases that exist with a given status (open, closed, etc). @@ -145,14 +191,133 @@ export const findCaseStatusStats = async ({ .filter((caseInfo) => caseInfo.attributes.type === CaseType.parent) .map((caseInfo) => caseInfo.id); + let subCasesTotal = 0; + + if (subCaseOptions) { + subCasesTotal = await findSubCaseStatusStats({ + client, + options: subCaseOptions, + caseService, + ids: caseIds, + }); + } + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.parent).length + + subCasesTotal; + + return total; +}; + +interface SubCaseStats { + commentTotals: Map; + alertTotals: Map; +} + +export const getCaseCommentStats = async ({ + client, + caseService, + ids, + type, +}: { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + ids: string[]; + type: typeof SUB_CASE_SAVED_OBJECT | typeof CASE_SAVED_OBJECT; +}): Promise => { + const allComments = await Promise.all( + ids.map((id) => + caseService.getAllCaseComments({ + client, + id, + subCaseID: type === SUB_CASE_SAVED_OBJECT ? id : undefined, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }) + ) + ); + + const associationType = + type === SUB_CASE_SAVED_OBJECT ? AssociationType.subCase : AssociationType.case; + + const alerts = await caseService.getAllCaseComments({ + client, + id: ids, + subCaseID: type === SUB_CASE_SAVED_OBJECT ? ids : undefined, + options: { + filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}) AND ${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${associationType}`, + }, + }); + + const getID = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.length > 0 + ? comments.saved_objects[0].references.find((ref) => ref.type === type)?.id + : undefined; + }; + + const groupedComments = allComments.reduce((acc, comments) => { + const id = getID(comments); + if (id) { + acc.set(id, comments.total); + } + return acc; + }, new Map()); + + const getFindResultID = (comment: SavedObjectsFindResult) => { + const refs = comment.references; + return refs.length > 0 ? refs.find((ref) => ref.type === type)?.id : undefined; + }; + + const groupedAlerts = alerts.saved_objects.reduce((acc, alertsInfo) => { + const id = getFindResultID(alertsInfo); + if (id) { + const totalAlerts = acc.get(id); + if (totalAlerts !== undefined) { + acc.set(id, totalAlerts + countAlerts(alertsInfo)); + } + acc.set(id, countAlerts(alertsInfo)); + } + return acc; + }, new Map()); + return { commentTotals: groupedComments, alertTotals: groupedAlerts }; +}; + +interface SubCasesMapWithPageInfo { + subCasesMap: Map; + page: number; + perPage: number; +} + +/** + * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. + */ +export const findSubCases = async ({ + client, + options, + caseService, + ids, +}: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + caseService: CaseServiceSetup; + ids: string[]; +}): Promise => { + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; + + if (!options) { + return { subCasesMap: new Map(), page: 0, perPage: 0 }; + } + const subCases = await caseService.findSubCases({ client, options: { - ...subCaseOptions, - page: 1, - perPage: 1, - fields: [], - hasReference: caseIds.map((id) => { + ...options, + hasReference: ids.map((id) => { return { id, type: SUB_CASE_SAVED_OBJECT, @@ -161,11 +326,41 @@ export const findCaseStatusStats = async ({ }, }); - const total = - cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.parent).length + - subCases.total; + const subCaseComments = await getCaseCommentStats({ + client, + caseService, + ids: subCases.saved_objects.map((subCase) => subCase.id), + type: SUB_CASE_SAVED_OBJECT, + }); - return total; + const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { + const id = getCaseID(subCase); + if (id) { + const subCaseFromMap = accMap.get(id); + + if (subCaseFromMap === undefined) { + const subCasesForID = [ + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, + }), + ]; + accMap.set(id, subCasesForID); + } else { + subCaseFromMap.push( + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, + }) + ); + } + } + return accMap; + }, new Map()); + + return { subCasesMap, page: subCases.page, perPage: subCases.per_page }; }; /** @@ -329,10 +524,14 @@ export const isTwoArraysDifference = ( return null; }; -export const getCaseToUpdate = ( - currentCase: ESCaseAttributes, - queryCase: ESCasePatchRequest -): ESCasePatchRequest => +// TODO: rename +interface Versioned { + id: string; + version: string; + [key: string]: unknown; +} + +export const getCaseToUpdate = (currentCase: unknown, queryCase: Versioned): Versioned => Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); 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 new file mode 100644 index 0000000000000..526e81e8e72d0 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + caseStatuses, + SubCasesFindRequestRt, + SubCasesFindResponseRt, + throwErrors, +} from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { escapeHatch, transformSubCases, wrapError } from '../../utils'; +import { SUB_CASES_URL } from '../../../../../common/constants'; +import { constructQueries, findSubCases, findSubCaseStatusStats } from '../helpers'; + +export function initFindSubCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: `${SUB_CASES_URL}/_find`, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const queryParams = pipe( + SubCasesFindRequestRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const ids = [request.params.case_id]; + const subCases = await findSubCases({ + client, + ids, + caseService, + options: { + sortField: 'created_at', + ...queryParams, + }, + }); + + const [open, inProgress, closed] = await Promise.all([ + ...caseStatuses.map((status) => { + const { subCase } = constructQueries({ status }); + return findSubCaseStatusStats({ + client, + options: subCase ?? {}, + caseService, + ids, + }); + }), + ]); + + return response.ok({ + body: SubCasesFindResponseRt.encode( + transformSubCases({ + ...subCases, + open, + inProgress, + closed, + total: subCases.subCasesMap.size, + }) + ), + }); + } catch (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 new file mode 100644 index 0000000000000..12804e60f0712 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -0,0 +1,361 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { + SavedObjectsClientContract, + KibanaRequest, + SavedObjectsFindResponse, + SavedObject, +} from 'kibana/server'; + +import { CaseClient, CaseClientImpl } from '../../../../client'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; +import { buildCaseUserActions } from '../../../../services/user_actions/helpers'; +import { + CasePatchRequest, + CasesPatchRequest, + CasesResponse, + CasesResponseRt, + CaseStatuses, + SubCasesPatchRequest, + SubCasesPatchRequestRt, + CommentType, + ESCasePatchRequest, + excess, + throwErrors, + SubCasesResponse, + SubCasePatchRequest, + SubCaseAttributes, + ESCaseAttributes, + SubCaseResponse, + SubCasesResponseRt, +} from '../../../../../common/api'; +import { CASES_URL, SUB_CASES_PATCH_URL } from '../../../../../common/constants'; +import { RouteDeps } from '../../types'; +import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; +import { getCaseToUpdate } from '../helpers'; + +interface UpdateArgs { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + request: KibanaRequest; + caseClient: CaseClient; + subCases: SubCasesPatchRequest; +} + +function checkNonExistingOrConflict( + toUpdate: SubCasePatchRequest[], + fromStorage: Map> +) { + const nonExistingSubCases: SubCasePatchRequest[] = []; + const conflictedSubCases: SubCasePatchRequest[] = []; + for (const subCaseToUpdate of toUpdate) { + const bulkEntry = fromStorage.get(subCaseToUpdate.id); + + if (bulkEntry && bulkEntry.error) { + nonExistingSubCases.push(subCaseToUpdate); + } + + if (!bulkEntry || bulkEntry.version !== subCaseToUpdate.version) { + conflictedSubCases.push(subCaseToUpdate); + } + } + + if (nonExistingSubCases.length > 0) { + throw Boom.notFound( + `These sub cases ${nonExistingSubCases + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + if (conflictedSubCases.length > 0) { + throw Boom.conflict( + `These cases ${conflictedSubCases + .map((c) => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } +} + +interface GetParentIDsResult { + ids: string[]; + parentIDToSubID: Map; +} + +function getParentIDs({ + subCasesMap, + subCaseIDs, +}: { + subCasesMap: Map>; + subCaseIDs: string[]; +}): GetParentIDsResult { + return subCaseIDs.reduce( + (acc, id) => { + const subCase = subCasesMap.get(id); + if (subCase && subCase.references.length > 0) { + const parentID = subCase.references[0].id; + acc.ids.push(parentID); + let subIDs = acc.parentIDToSubID.get(parentID); + if (subIDs === undefined) { + subIDs = []; + } + subIDs.push(id); + acc.parentIDToSubID.set(parentID, subIDs); + } + return acc; + }, + { ids: [], parentIDToSubID: new Map() } + ); +} + +async function getParentCases({ + caseService, + client, + subCaseIDs, + subCasesMap, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + subCaseIDs: string[]; + subCasesMap: Map>; +}): Promise>> { + const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); + + const parentCases = await caseService.getCases({ + client, + caseIds: parentIDInfo.ids, + }); + + const parentCaseErrors = parentCases.saved_objects.find((so) => so.error !== undefined); + + if (parentCaseErrors) { + throw Boom.badRequest( + `Unable to find parent cases for sub cases, original error: ${parentCaseErrors.error?.message}` + ); + } + + return parentCases.saved_objects.reduce((acc, so) => { + const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); + subCaseIDsWithParent?.forEach((subCaseID) => { + acc.set(subCaseID, so); + }); + return acc; + }, new Map>()); +} + +async function update({ + client, + caseService, + userActionService, + request, + caseClient, + subCases, +}: 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), + }); + + const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + checkNonExistingOrConflict(query.subCases, subCasesMap); + + // TODO: extract to new function + const validatedSubCaseAttributes: SubCasePatchRequest[] = query.subCases.map((updateCase) => { + const currentCase = subCasesMap.get(updateCase.id); + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...updateCase, + }) + : { id: updateCase.id, version: updateCase.version }; + }); + + const nonEmptySubCaseRequests = validatedSubCaseAttributes.filter( + (updateCase: SubCasePatchRequest) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + } + ); + + 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, + }); + + // TODO: extract to new function + // 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; + // TODO: type this + let closedInfo = {}; + 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, + }; + }), + }); + + 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 + ); + }); + + // TODO: extra to new function + for (const subCaseToSync of subCasesToSyncAlertsFor) { + const currentSubCase = subCasesMap.get(subCaseToSync.id); + const alertComments = await caseService.getAllCaseComments({ + client, + id: subCaseToSync.id, + subCaseID: subCaseToSync.id, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); + + // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) + const idsAndIndices = alertComments.saved_objects.reduce( + (acc: { ids: string[]; indices: Set }, comment) => { + if (isAlertCommentSO(comment)) { + const alertId = comment.attributes.alertId; + const ids = Array.isArray(alertId) ? alertId : [alertId]; + acc.ids.push(...ids); + acc.indices.add(comment.attributes.index); + } + return acc; + }, + { ids: [], indices: new Set() } + ); + + if (idsAndIndices.ids.length > 0) { + caseClient.updateAlertsStatus({ + ids: idsAndIndices.ids, + // We shouldn't really get in a case where the sub cases' status is undefined, but there wouldn't be anything to + // update in that case + status: subCaseToSync.status ?? currentSubCase?.attributes.status ?? CaseStatuses.open, + indices: idsAndIndices.indices, + }); + } + } + + 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; + }, + [] + ); + + // TODO: need to implement one for sub case + /* await userActionService.postUserActions({ + client, + actions: buildCaseUserActions({ + originalCases: bulkSubCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + });*/ + + return SubCasesResponseRt.encode(returnUpdatedSubCases); +} + +export function initPatchSubCasesApi({ router, caseService, userActionService }: RouteDeps) { + router.patch( + { + path: SUB_CASES_PATCH_URL, + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + const caseClient = context.case.getCaseClient(); + const subCases = request.body as SubCasesPatchRequest; + + try { + return response.ok({ + body: await update({ + request, + subCases, + caseClient, + client: context.core.savedObjects.client, + caseService, + userActionService, + }), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 3b866a572e7ba..7e144088b3730 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -32,6 +32,8 @@ import { initPostPushToService } from './cases/configure/post_push_to_service'; import { RouteDeps } from './types'; import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; +import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; +import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; export function initCaseApi(deps: RouteDeps) { // Cases @@ -45,7 +47,8 @@ export function initCaseApi(deps: RouteDeps) { initConvertCaseToCollectionApi(deps); // Sub cases initGetSubCaseApi(deps); - + initPatchSubCasesApi(deps); + initFindSubCasesApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 85ea5f071aea9..07ca6cfac134c 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -40,6 +40,8 @@ import { CommentRequestGeneratedAlertType, ContextTypeGeneratedAlertRt, CollectionWithSubCaseResponse, + SubCasesFindResponse, + AttributesTypeAlerts, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -197,6 +199,33 @@ export const transformCases = ({ count_closed_cases: countClosedCases, }); +export const transformSubCases = ({ + subCasesMap, + open, + inProgress, + closed, + page, + perPage, + total, +}: { + subCasesMap: Map; + open: number; + inProgress: number; + closed: number; + page: number; + perPage: number; + total: number; +}): SubCasesFindResponse => ({ + page, + per_page: perPage, + total, + // Squish all the entries in the map together as one array + subCases: Array.from(subCasesMap.values()).flat(), + count_open_cases: open, + count_in_progress_cases: inProgress, + count_closed_cases: closed, +}); + // TODO: remove because it is no longer used export const flattenCaseSavedObjects = ( savedObjects: Array>, @@ -321,6 +350,15 @@ export const isGeneratedAlertContext = ( return context.type === CommentType.generatedAlert; }; +export const isAlertCommentSO = ( + comment: SavedObject +): comment is SavedObject => { + return ( + comment.attributes.type === CommentType.generatedAlert || + comment.attributes.type === CommentType.alert + ); +}; + export const decodeComment = (comment: CommentRequest) => { if (isUserContext(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index a9259c97fd062..b1a8f69b57122 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -27,6 +27,7 @@ import { CommentPatchAttributes, SubCaseAttributes, } from '../../common/api'; +import { SUB_CASES_URL } from '../../common/constants'; import { transformNewSubCase } from '../routes/api/utils'; import { CASE_SAVED_OBJECT, @@ -58,14 +59,25 @@ interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface FindCommentsArgs extends GetCaseArgs { +interface GetSubCasesArgs extends ClientArgs { + ids: string[]; +} + +interface FindCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; options?: SavedObjectFindOptions; - subCaseID?: string; + subCaseID?: string | string[]; } interface FindCasesArgs extends ClientArgs { options?: SavedObjectFindOptions; } + +interface FindSubCasesByIDArgs extends FindCasesArgs { + ids: string[]; +} + interface GetCommentArgs extends ClientArgs { commentId: string; } @@ -109,6 +121,11 @@ interface PatchSubCase { version?: string; } +interface PatchSubCases { + client: SavedObjectsClientContract; + subCases: Array>; +} + interface GetUserArgs { request: KibanaRequest; response?: KibanaResponseFactory; @@ -122,12 +139,12 @@ export interface CaseServiceSetup { findCases(args: FindCasesArgs): Promise>; findSubCases(args: FindCasesArgs): Promise>; findSubCasesByCaseId( - client: SavedObjectsClientContract, - caseId: string + args: FindSubCasesByIDArgs ): Promise>; getAllCaseComments(args: FindCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; getSubCase(args: GetCaseArgs): Promise>; + getSubCases(args: GetSubCasesArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; @@ -149,6 +166,7 @@ export interface CaseServiceSetup { caseId: string ): Promise>; patchSubCase(args: PatchSubCase): Promise>; + patchSubCases(args: PatchSubCases): Promise>; } export class CaseService implements CaseServiceSetup { @@ -249,6 +267,20 @@ export class CaseService implements CaseServiceSetup { throw error; } } + + public async getSubCases({ + client, + ids, + }: GetSubCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); + return await client.bulkGet(ids.map((id) => ({ type: CASE_SAVED_OBJECT, id }))); + } catch (error) { + this.log.debug(`Error on GET cases ${ids.join(', ')}: ${error}`); + throw error; + } + } + public async getCases({ client, caseIds, @@ -327,31 +359,43 @@ export class CaseService implements CaseServiceSetup { * Find sub cases using a collection's ID. This would try to retrieve the maximum amount of sub cases * by default. * - * @param caseId the saved object ID of the parent collection to find sub cases for. + * @param id the saved object ID of the parent collection to find sub cases for. */ - public async findSubCasesByCaseId( - client: SavedObjectsClientContract, - caseId: string - ): Promise> { + public async findSubCasesByCaseId({ + client, + ids, + options, + }: FindSubCasesByIDArgs): Promise> { try { - this.log.debug(`Attempting to GET sub cases for case collection id ${caseId}`); + this.log.debug(`Attempting to GET sub cases for case collection id ${ids.join(', ')}`); return this.findSubCases({ client, options: { - hasReference: [ - { - type: CASE_SAVED_OBJECT, - id: caseId, - }, - ], + ...options, + hasReference: ids.map((id) => ({ + type: CASE_SAVED_OBJECT, + id, + })), }, }); } catch (error) { - this.log.debug(`Error on GET all sub cases for case collection id ${caseId}: ${error}`); + this.log.debug( + `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` + ); throw error; } } + private asArray(id: string | string[] | undefined): string[] { + if (id === undefined) { + return []; + } else if (Array.isArray(id)) { + return id; + } else { + return [id]; + } + } + /** * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). * to override this pass in the either the page or perPage options. @@ -363,16 +407,19 @@ export class CaseService implements CaseServiceSetup { subCaseID, }: FindCommentsArgs): Promise> { try { - const ref = + const refs = subCaseID == null - ? { type: CASE_SAVED_OBJECT, id } - : { type: SUB_CASE_SAVED_OBJECT, id: subCaseID }; + ? this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })) + : this.asArray(subCaseID).map((idObj) => ({ + type: SUB_CASE_SAVED_OBJECT, + id: idObj, + })); this.log.debug(`Attempting to GET all comments for case caseID ${id} subCaseID ${subCaseID}`); if (options?.page !== undefined || options?.perPage !== undefined) { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, hasReferenceOperator: 'OR', - hasReference: [ref], + hasReference: refs, ...options, }); } @@ -380,7 +427,7 @@ export class CaseService implements CaseServiceSetup { const stats = await client.find({ type: CASE_COMMENT_SAVED_OBJECT, hasReferenceOperator: 'OR', - hasReference: [ref], + hasReference: refs, fields: [], page: 1, perPage: 1, @@ -391,7 +438,7 @@ export class CaseService implements CaseServiceSetup { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, hasReferenceOperator: 'OR', - hasReference: [ref], + hasReference: refs, page: 1, perPage: stats.total, ...options, @@ -526,7 +573,7 @@ export class CaseService implements CaseServiceSetup { try { this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); return await client.update( - CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, subCaseId, { ...updatedAttributes }, { version } @@ -536,4 +583,25 @@ export class CaseService implements CaseServiceSetup { throw error; } } + + public async patchSubCases({ client, subCases }: PatchSubCases) { + try { + this.log.debug( + `Attempting to UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}` + ); + return await client.bulkUpdate( + subCases.map((c) => ({ + type: SUB_CASE_SAVED_OBJECT, + id: c.subCaseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` + ); + throw error; + } + } } diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 7b8f999877ea7..09b2fe8d1df8c 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -32,6 +32,7 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ getComment: jest.fn(), getMostRecentSubCase: jest.fn(), getSubCase: jest.fn(), + getSubCases: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), getUser: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 3fb962df232bc..adbda2410c34a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -18,6 +18,8 @@ import { CasesResponse, CasesFindResponse, CommentType, + AssociationType, + CaseType, } from '../../../../case/common/api'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { ConnectorTypes } from '../../../../case/common/api/connectors'; @@ -42,6 +44,7 @@ export const serviceConnectorUser = { export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, @@ -56,6 +59,7 @@ export const basicComment: Comment = { export const alertComment: Comment = { alertId: 'alert-id-1', + associationType: AssociationType.case, index: 'alert-index-1', type: CommentType.alert, id: 'alert-comment-id', @@ -69,6 +73,7 @@ export const alertComment: Comment = { }; export const basicCase: Case = { + type: CaseType.individual, closedAt: null, closedBy: null, id: basicCaseId, @@ -81,12 +86,14 @@ export const basicCase: Case = { type: ConnectorTypes.none, fields: null, }, + convertedBy: null, description: 'Security banana Issue', externalService: null, status: CaseStatuses.open, tags, title: 'Another horrible breach!!', totalComment: 1, + totalAlerts: 0, updatedAt: basicUpdatedAt, updatedBy: elasticUser, version: 'WzQ3LDFd', @@ -215,6 +222,7 @@ export const elasticUserSnake = { email: 'leslie.knope@elastic.co', }; export const basicCommentSnake: CommentResponse = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, @@ -239,6 +247,7 @@ export const basicCaseSnake: CaseResponse = { type: ConnectorTypes.none, fields: null, }, + converted_by: null, created_at: basicCreatedAt, created_by: elasticUserSnake, external_service: null, diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index 4e9baed62c644..773259d321cd1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -13,11 +13,14 @@ import { CaseStatuses, CaseAttributes, CasePatchRequest, + CaseType, + AssociationType, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; export type Comment = CommentRequest & { + associationType: AssociationType; id: string; createdAt: string; createdBy: ElasticUser; @@ -54,6 +57,7 @@ export interface Case { closedBy: ElasticUser | null; comments: Comment[]; connector: CaseConnector; + convertedBy: ElasticUser | null; createdAt: string; createdBy: ElasticUser; description: string; @@ -61,7 +65,9 @@ export interface Case { status: CaseStatuses; tags: string[]; title: string; + totalAlerts: number; totalComment: number; + type: CaseType; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; From a96c765caf5b63a154c6667a2d9debb6316bf654 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Mon, 1 Feb 2021 16:36:06 -0500 Subject: [PATCH 19/47] script for sub cases --- x-pack/plugins/case/common/api/cases/case.ts | 32 ++- x-pack/plugins/case/common/constants.ts | 1 - x-pack/plugins/case/package.json | 10 + .../client/alerts/update_status.test.ts | 25 +- .../server/client/alerts/update_status.ts | 2 +- .../cases/__snapshots__/create.test.ts.snap | 45 +--- .../case/server/client/cases/create.test.ts | 43 +++- .../case/server/client/cases/create.ts | 10 +- x-pack/plugins/case/server/client/client.ts | 25 +- .../case/server/client/comments/add.ts | 8 +- x-pack/plugins/case/server/client/index.ts | 6 +- x-pack/plugins/case/server/client/mocks.ts | 11 +- x-pack/plugins/case/server/client/types.ts | 9 +- .../server/common/models/commentable_case.ts | 2 +- .../case/server/connectors/case/index.test.ts | 7 +- .../case/server/connectors/case/index.ts | 3 +- .../case/server/connectors/case/schema.ts | 16 -- .../server/routes/api/cases/convert_case.ts | 37 --- .../server/routes/api/cases/delete_cases.ts | 2 +- .../server/routes/api/cases/find_cases.ts | 19 +- .../case/server/routes/api/cases/helpers.ts | 16 +- .../case/server/routes/api/cases/post_case.ts | 4 +- .../api/cases/status/get_status.test.ts | 10 +- .../api/cases/sub_case/patch_sub_cases.ts | 17 +- .../plugins/case/server/routes/api/index.ts | 2 - .../case/server/routes/api/utils.test.ts | 1 - .../plugins/case/server/routes/api/utils.ts | 3 - .../server/scripts/mock/case/post_case.json | 11 +- .../scripts/mock/case/post_case_v2.json | 11 +- .../server/scripts/sub_cases/generator.js | 8 + .../case/server/scripts/sub_cases/index.ts | 221 ++++++++++++++++++ .../case/server/services/alerts/index.ts | 2 +- x-pack/plugins/case/server/services/index.ts | 2 - x-pack/plugins/case/server/services/mocks.ts | 1 + .../cases/components/all_cases/index.test.tsx | 6 +- 35 files changed, 387 insertions(+), 241 deletions(-) create mode 100644 x-pack/plugins/case/package.json delete mode 100644 x-pack/plugins/case/server/routes/api/cases/convert_case.ts create mode 100644 x-pack/plugins/case/server/scripts/sub_cases/generator.js create mode 100644 x-pack/plugins/case/server/scripts/sub_cases/index.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index ebf6f0595af01..22b685abda773 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -14,11 +14,11 @@ import { CaseConnectorRt, ESCaseConnector } from '../connectors'; import { SubCaseResponseRt } from './sub_case'; export enum CaseType { - parent = 'parent', + collection = 'collection', individual = 'individual', } -const CaseTypeRt = rt.union([rt.literal(CaseType.parent), rt.literal(CaseType.individual)]); +const CaseTypeRt = rt.union([rt.literal(CaseType.collection), rt.literal(CaseType.individual)]); const SettingsRt = rt.type({ syncAlerts: rt.boolean, @@ -71,7 +71,7 @@ export const CaseAttributesRt = rt.intersection([ }), ]); -export const CasePostRequestRt = rt.type({ +const CasePostRequestNoTypeRt = rt.type({ description: rt.string, tags: rt.array(rt.string), title: rt.string, @@ -79,11 +79,25 @@ export const CasePostRequestRt = rt.type({ settings: SettingsRt, }); +/** + * This type is used for validating a create case request. It requires that the type field be defined. + */ export const CaseClientPostRequestRt = rt.type({ - ...CasePostRequestRt.props, + ...CasePostRequestNoTypeRt.props, type: CaseTypeRt, }); +/** + * This type is not used for validation when decoding a request because intersection does not have props defined which + * required for the excess function. Instead we use this as the type used by the UI. This allows the type field to be + * optional and the server will handle setting it to a default value before validating that the request + * has all the necessary fields. CaseClientPostRequestRt is used for validation. + */ +export const CasePostRequestRt = rt.intersection([ + rt.partial({ type: CaseTypeRt }), + CasePostRequestNoTypeRt, +]); + export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; export const CasesFindRequestRt = rt.partial({ @@ -140,14 +154,16 @@ export const CaseUpdateRequestRt = rt.intersection([ rt.type({ id: rt.string, version: rt.string }), ]); -export const CaseConvertRequestRt = rt.type({ id: rt.string, version: rt.string }); - export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); export const CasesUpdateRequestRt = rt.type({ cases: rt.array(CaseUpdateRequestRt) }); export type CaseAttributes = rt.TypeOf; -// TODO: document how this is different from the CasePostRequest +/** + * This field differs from the CasePostRequest in that the post request's type field can be optional. This type requires + * that the type field be defined. The CasePostRequest should be used in most places (the UI etc). This type is really + * only necessary for validation. + */ export type CaseClientPostRequest = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -166,5 +182,3 @@ export type ESCaseAttributes = Omit & { connector: export type ESCasePatchRequest = Omit & { connector?: ESCaseConnector; }; - -export type CaseConvertRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index b1686fcb59304..6b6f99feed697 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -13,7 +13,6 @@ export const APP_ID = 'case'; export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; -export const CASE_COLLECTION_URL = `${CASES_URL}/collection`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`; export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`; diff --git a/x-pack/plugins/case/package.json b/x-pack/plugins/case/package.json new file mode 100644 index 0000000000000..5a25414296946 --- /dev/null +++ b/x-pack/plugins/case/package.json @@ -0,0 +1,10 @@ +{ + "author": "Elastic", + "name": "case", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "test:sub-cases": "node server/scripts/sub_cases/generator" + } +} 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 44dd563d29de6..970134db4e483 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 @@ -20,33 +20,10 @@ describe('updateAlertsStatus', () => { }); expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ + callCluster: expect.any(Function), ids: ['alert-id-1'], indices: new Set(['.siem-signals']), status: CaseStatuses.closed, }); }); - - // TODO: maybe test the plugin code instead? - /* describe('unhappy path', () => { - test('it throws when missing securitySolutionClient', async () => { - expect.assertions(3); - - const savedObjectsClient = createMockSavedObjectsRepository(); - - const caseClient = await createCaseClientWithMockSavedObjectsClient({ - savedObjectsClient, - omitFromContext: ['securitySolution'], - }); - caseClient.client - .updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); - }); - }); - });*/ }); 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 08eec606cfa99..acbc3ebd9777a 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyScopedClusterClient, KibanaRequest } from 'src/core/server'; +import { ILegacyScopedClusterClient } from 'src/core/server'; import { CaseStatuses } from '../../../common/api'; import { AlertServiceContract } from '../../services'; diff --git a/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap b/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap index 9e8a1e1296ebb..3f51bf8bc775e 100644 --- a/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap +++ b/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap @@ -39,49 +39,6 @@ Object { } `; -exports[`create happy path it creates the case correctly 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "Jira", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; - exports[`create happy path it creates the case correctly 2`] = ` Array [ Object { @@ -101,7 +58,7 @@ Array [ "connector", "settings", ], - "new_value": "{\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"type\\":\\"individual\\",\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", + "new_value": "{\\"type\\":\\"individual\\",\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", "old_value": null, }, "references": Array [ 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 3cb9503efc5e1..e13bd0e67aaa4 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -47,7 +47,48 @@ describe('create', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.create(postCase); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "Jira", + "type": ".jira", + }, + "converted_by": null, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index 7e72959d37822..225507c725b7c 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -18,7 +18,8 @@ import { CaseResponseRt, CaseResponse, CaseClientPostRequestRt, - CaseClientPostRequest, + CasePostRequest, + CaseType, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -38,7 +39,7 @@ interface CreateCaseArgs { request: KibanaRequest; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; - theCase: CaseClientPostRequest; + theCase: CasePostRequest; } export const create = async ({ @@ -49,8 +50,11 @@ export const create = async ({ request, theCase, }: CreateCaseArgs): Promise => { + // default to an individual case if the type is not defined. + const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; const query = pipe( - excess(CaseClientPostRequestRt).decode(theCase), + // decode with the defaulted type field + excess(CaseClientPostRequestRt).decode({ type, ...nonTypeCaseFields }), fold(throwErrors(Boom.badRequest), identity) ); diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index d62b19f41755d..f32ce83c5ec43 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -36,12 +36,9 @@ import { AlertServiceContract, } from '../services'; import { - CaseClientPostRequest, - CaseConvertRequest, - CaseConvertRequestRt, CasesPatchRequest, CasesPatchRequestRt, - CaseType, + CasePostRequest, excess, throwErrors, } from '../../common/api'; @@ -91,7 +88,7 @@ export class CaseClientImpl implements CaseClient { return this._alertsService; } - public async create(caseInfo: CaseClientPostRequest) { + public async create(caseInfo: CasePostRequest) { return create({ savedObjectsClient: this._savedObjectsClient, caseService: this._caseService, @@ -122,24 +119,6 @@ export class CaseClientImpl implements CaseClient { }); } - public async convertCaseToCollection(caseInfo: CaseConvertRequest) { - const validatedRequest = pipe( - excess(CaseConvertRequestRt).decode(caseInfo), - fold(throwErrors(Boom.badRequest), identity) - ); - - return update({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - request: this.request, - cases: { - cases: [{ ...validatedRequest, type: CaseType.parent }], - }, - caseClient: this, - }); - } - public async addComment({ caseId, comment }: CaseClientAddComment) { return addComment({ savedObjectsClient: this._savedObjectsClient, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 726c6f7bb49da..3a66faaa18a53 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -87,7 +87,7 @@ const addGeneratedAlerts = async ({ id: caseId, }); - if (query.type === CommentType.generatedAlert && myCase.attributes.type !== CaseType.parent) { + if (query.type === CommentType.generatedAlert && myCase.attributes.type !== CaseType.collection) { throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); } @@ -99,9 +99,9 @@ const addGeneratedAlerts = async ({ }); const userDetails = { - username: myCase.attributes.converted_by?.username, - full_name: myCase.attributes.converted_by?.full_name, - email: myCase.attributes.converted_by?.email, + username: myCase.attributes.created_by?.username, + full_name: myCase.attributes.created_by?.full_name, + email: myCase.attributes.created_by?.email, }; const [newComment, updatedCase, updatedSubCase] = await Promise.all([ diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 46c23d58d2af0..b5f0e3b89e824 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaseClientFactoryArguments, CaseClientPluginContract } from './types'; +import { CaseClientFactoryArguments, CaseClient } from './types'; import { CaseClientImpl } from './client'; export { CaseClientImpl } from './client'; @@ -26,9 +26,7 @@ export { CaseClient } from './types'; }; };*/ -export const createExternalCaseClient = ( - clientArgs: CaseClientFactoryArguments -): CaseClientPluginContract => { +export const createExternalCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { const client = new CaseClientImpl(clientArgs); return client; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 9955eb4e493e2..7a2b31b7ab8fd 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -5,6 +5,9 @@ */ import { KibanaRequest } from 'kibana/server'; +// TODO: fix this +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsClientMock } from '../../../actions/server/mocks'; import { @@ -14,12 +17,12 @@ import { CaseUserActionServiceSetup, ConnectorMappingsService, } from '../services'; -import { CaseClientPluginContract } from './types'; +import { CaseClient } from './types'; import { authenticationMock } from '../routes/api/__fixtures__'; import { createExternalCaseClient } from '.'; import { getActions } from '../routes/api/__mocks__/request_responses'; -export type CaseClientPluginContractMock = jest.Mocked; +export type CaseClientPluginContractMock = jest.Mocked; export const createExternalCaseClientMock = (): CaseClientPluginContractMock => ({ addComment: jest.fn(), create: jest.fn(), @@ -38,12 +41,13 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ badAuth?: boolean; omitFromContext?: string[]; }): Promise<{ - client: CaseClientPluginContract; + client: CaseClient; services: { userActionService: jest.Mocked; alertsService: jest.Mocked; }; }> => { + const esLegacyCluster = legacyClientMock.createScopedClusterClient(); const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); @@ -72,6 +76,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ connectorMappingsService, userActionService, alertsService, + callCluster: esLegacyCluster.callAsCurrentUser, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index de54706b31870..5f73581be8db1 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -12,7 +12,7 @@ import { import { ActionsClient } from '../../../actions/server'; import { CaseClientPostRequest, - CaseConvertRequest, + CasePostRequest, CaseResponse, CasesPatchRequest, CasesResponse, @@ -77,18 +77,15 @@ export interface ConfigureFields { /** * This represents the interface that other plugins can access. */ -export interface CaseClientPluginContract { +export interface CaseClient { addComment(args: CaseClientAddComment): Promise; - create(theCase: CaseClientPostRequest): Promise; + create(theCase: CasePostRequest): Promise; getFields(args: ConfigureFields): Promise; getMappings(args: MappingsClient): Promise; update(args: CasesPatchRequest): Promise; updateAlertsStatus(args: CaseClientUpdateAlertsStatus): Promise; } -export interface CaseClient extends CaseClientPluginContract { - convertCaseToCollection(caseInfo: CaseConvertRequest): Promise; -} export interface MappingsClient { actionsClient: ActionsClient; connectorId: string; 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 8fd76c01e5d4c..c2cbd0b6c78e9 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -17,7 +17,7 @@ import { transformESConnectorToCaseConnector } from '../../routes/api/cases/help import { flattenCommentSavedObjects, flattenSubCaseSavedObject } from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; -import { countAlerts, countAlertsFindResponse } from '../index'; +import { countAlertsFindResponse } from '../index'; interface UserInfo { username: string | null | undefined; 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 925ff36899a05..ac8df2d7c3c42 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -842,7 +842,7 @@ describe('case connector', () => { }, title: 'Case from case connector!!', tags: ['case', 'connector'], - type: CaseType.parent, + type: CaseType.collection, description: 'Yo fields!!', external_service: null, status: CaseStatuses.open, @@ -902,7 +902,6 @@ describe('case connector', () => { parent: null, }, }, - type: CaseType.individual, }); }); }); @@ -939,7 +938,7 @@ describe('case connector', () => { title: 'Update title', totalComment: 0, totalAlerts: 0, - type: CaseType.parent, + type: CaseType.collection, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', @@ -1016,7 +1015,7 @@ describe('case connector', () => { title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], - type: CaseType.parent, + type: CaseType.collection, updated_at: null, updated_by: null, comments: [ diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 575abd7217f7f..16a8a8b87a83b 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -8,7 +8,7 @@ import { curry } from 'lodash'; import { KibanaRequest } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { CasePatchRequest, CasePostRequest, CaseType } from '../../../common/api'; +import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createExternalCaseClient } from '../../client'; import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; import { @@ -91,7 +91,6 @@ async function executor( if (subAction === 'create') { data = await caseClient.create({ ...(subActionParams as CasePostRequest), - type: CaseType.individual, }); } diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 654c05cb43f90..389ed1e58e1e7 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -15,22 +15,6 @@ const ContextTypeUserSchema = schema.object({ comment: schema.string(), }); -/** - * TODO: remove - * ContextTypeAlertSchema has been deleted. - * Comments of type alert need the siem signal index. - * Case connector is not being passed the context which contains the - * security solution app client which in turn provides the siem signal index. - * For that reason, we disable comments of type alert for the case connector until - * we figure out how to pass the security solution app client to the connector. - * See: x-pack/plugins/case/server/connectors/case/index.ts L76. - * - * The schema: - * - * - * Issue: https://github.com/elastic/kibana/issues/85750 - * */ - const AlertIDSchema = schema.object( { _id: schema.string(), diff --git a/x-pack/plugins/case/server/routes/api/cases/convert_case.ts b/x-pack/plugins/case/server/routes/api/cases/convert_case.ts deleted file mode 100644 index d361c7b5530e3..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/convert_case.ts +++ /dev/null @@ -1,37 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { wrapError, escapeHatch } from '../utils'; - -import { RouteDeps } from '../types'; -import { CASE_COLLECTION_URL } from '../../../../common/constants'; -import { CaseConvertRequest } from '../../../../common/api'; - -export function initConvertCaseToCollectionApi({ router }: RouteDeps) { - router.post( - { - path: CASE_COLLECTION_URL, - validate: { - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } - - const caseClient = context.case.getCaseClient(); - const body = request.body as CaseConvertRequest; - return response.ok({ - body: await caseClient.convertCaseToCollection({ id: body.id, version: body.version }), - }); - } catch (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 ba0a6fe83a90a..86ad2b3f9b0ed 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 @@ -39,7 +39,7 @@ async function unremovableCases({ * might have failed to find the ID. If it fails to find it, it will set the error field but not * the attributes so check that we didn't receive an error. */ - (caseObj) => !caseObj.error && caseObj.attributes.type === CaseType.parent + (caseObj) => !caseObj.error && caseObj.attributes.type === CaseType.collection ); return parentCases.map((parentCase) => parentCase.id); 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 a2df3f434e0cc..c068a4e0b20d7 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 @@ -53,13 +53,11 @@ async function findCases({ caseOptions, subCaseOptions, caseService, - includeEmptyCollections, }: { client: SavedObjectsClientContract; caseOptions: SavedObjectFindOptions; subCaseOptions?: SavedObjectFindOptions; caseService: CaseServiceSetup; - includeEmptyCollections: boolean; }): Promise { const cases = await caseService.findCases({ client, @@ -71,23 +69,17 @@ async function findCases({ options: subCaseOptions, caseService, ids: cases.saved_objects - .filter((caseInfo) => caseInfo.type === CaseType.parent) + .filter((caseInfo) => caseInfo.type === CaseType.collection) .map((caseInfo) => caseInfo.id), }); const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); + /** - * If we don't have the sub cases for the case and the case is a collection then ignore it - * unless we're forcing retrieval of empty collections. Otherwise if the case is an individual case - * then include it. + * This will include empty collections unless the query explicitly requested type === CaseType.individual, in which + * case we'd not have any collections anyway. */ - if ( - (subCasesForCase && caseInfo.attributes.type === CaseType.parent) || - includeEmptyCollections || - caseInfo.attributes.type === CaseType.individual - ) { - accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); - } + accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); return accMap; }, new Map()); @@ -160,7 +152,6 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: caseOptions: { ...queryParams, ...caseQueries.case }, subCaseOptions: caseQueries.subCase, caseService, - includeEmptyCollections: queryParams.type === CaseType.parent || !queryParams.status, }); const [openCases, inProgressCases, closedCases] = await Promise.all([ diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 2100ef105623c..1bd8321c03d13 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -15,8 +15,6 @@ import { import { CaseConnector, ESCaseConnector, - ESCaseAttributes, - ESCasePatchRequest, ESCasesConfigureAttributes, ConnectorTypes, CaseStatuses, @@ -188,7 +186,7 @@ export const findCaseStatusStats = async ({ }); const caseIds = cases.saved_objects - .filter((caseInfo) => caseInfo.attributes.type === CaseType.parent) + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) .map((caseInfo) => caseInfo.id); let subCasesTotal = 0; @@ -203,8 +201,8 @@ export const findCaseStatusStats = async ({ } const total = - cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.parent).length + - subCasesTotal; + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) + .length + subCasesTotal; return total; }; @@ -320,7 +318,7 @@ export const findSubCases = async ({ hasReference: ids.map((id) => { return { id, - type: SUB_CASE_SAVED_OBJECT, + type: CASE_SAVED_OBJECT, }; }), }, @@ -428,10 +426,10 @@ export const constructQueries = ({ }, }; } - case CaseType.parent: { + case CaseType.collection: { // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" // The sub case filter will use the query.status if it exists - const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); return { @@ -457,7 +455,7 @@ export const constructQueries = ({ * The sub case filter will use the query.status if it exists */ const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; - const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.parent}`; + const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); 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 431f6b4b84da3..b33eae9edbcfd 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 @@ -8,7 +8,7 @@ import { wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; -import { CasePostRequest, CaseType } from '../../../../common/api'; +import { CasePostRequest } from '../../../../common/api'; export function initPostCaseApi({ router }: RouteDeps) { router.post( @@ -27,7 +27,7 @@ export function initPostCaseApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.create({ ...theCase, type: CaseType.individual }), + body: await caseClient.create({ ...theCase }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts index 35b088cac56ab..138147a37f3f7 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -14,6 +14,7 @@ import { } from '../../__fixtures__'; import { initGetCasesStatusApi } from './get_status'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { CaseType } from '../../../../../common/api'; describe('GET status', () => { let routeHandler: RequestHandler; @@ -45,20 +46,17 @@ describe('GET status', () => { expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { ...findArgs, - filter: - '((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: parent)', + filter: `((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { ...findArgs, - filter: - '((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: parent)', + filter: `((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { ...findArgs, - filter: - '((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: parent)', + filter: `((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(response.payload).toEqual({ 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 12804e60f0712..c57761da177d2 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 @@ -8,27 +8,16 @@ import Boom from '@hapi/boom'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - SavedObjectsClientContract, - KibanaRequest, - SavedObjectsFindResponse, - SavedObject, -} from 'kibana/server'; +import { SavedObjectsClientContract, KibanaRequest, SavedObject } from 'kibana/server'; -import { CaseClient, CaseClientImpl } from '../../../../client'; +import { CaseClient } from '../../../../client'; import { CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; -import { buildCaseUserActions } from '../../../../services/user_actions/helpers'; import { - CasePatchRequest, - CasesPatchRequest, - CasesResponse, - CasesResponseRt, CaseStatuses, SubCasesPatchRequest, SubCasesPatchRequestRt, CommentType, - ESCasePatchRequest, excess, throwErrors, SubCasesResponse, @@ -38,7 +27,7 @@ import { SubCaseResponse, SubCasesResponseRt, } from '../../../../../common/api'; -import { CASES_URL, SUB_CASES_PATCH_URL } from '../../../../../common/constants'; +import { SUB_CASES_PATCH_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; import { getCaseToUpdate } from '../helpers'; diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 7e144088b3730..d0ea5d08cc659 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -5,7 +5,6 @@ */ import { initDeleteCasesApi } from './cases/delete_cases'; -import { initConvertCaseToCollectionApi } from './cases/convert_case'; import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; @@ -44,7 +43,6 @@ export function initCaseApi(deps: RouteDeps) { initPostCaseApi(deps); initPushCaseUserActionApi(deps); initGetAllUserActionsApi(deps); - initConvertCaseToCollectionApi(deps); // Sub cases initGetSubCaseApi(deps); initPatchSubCasesApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index aaea1366b7fa4..d902d9e58a682 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -27,7 +27,6 @@ import { ConnectorTypes, ESCaseConnector, CommentType, - CaseStatuses, AssociationType, CaseType, CaseResponse, diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 07ca6cfac134c..ce2c572b3a9a4 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -39,15 +39,12 @@ import { SubCaseResponse, CommentRequestGeneratedAlertType, ContextTypeGeneratedAlertRt, - CollectionWithSubCaseResponse, SubCasesFindResponse, AttributesTypeAlerts, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase, TotalCommentByCase } from './types'; -// TODO: figure out where the class should actually be stored -import { CommentableCase } from '../../common'; // TODO: refactor these functions to a common location, this is used by the caseClient too diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case.json b/x-pack/plugins/case/server/scripts/mock/case/post_case.json index 743fa396295ca..bed342dd69fe9 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case.json @@ -3,5 +3,14 @@ "title": "Bad meanie defacing data", "tags": [ "defacement" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json index 13efe436a640d..58fee92859bf9 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json @@ -3,5 +3,14 @@ "title": "Another bad dude", "tags": [ "phishing" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/sub_cases/generator.js b/x-pack/plugins/case/server/scripts/sub_cases/generator.js new file mode 100644 index 0000000000000..de7477628e3a8 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/generator.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +require('../../../../../../src/setup_node_env'); +require('./index'); diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts new file mode 100644 index 0000000000000..469d6e059556b --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable no-console */ +import yargs from 'yargs'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api'; +import { CommentType } from '../../../common/api/cases/comment'; +import { CASES_URL } from '../../../common/constants'; + +main(); + +// TODO: find actual type +interface ActionResp { + id: string; +} + +interface ExecuteResp { + status: string; +} + +function createClient(argv: any): KbnClient { + return new KbnClient({ + log: new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }), + url: argv.kibana, + }); +} + +async function handleFind(argv: any) { + const client = createClient(argv); + + try { + const res = await client.request({ + path: `${CASES_URL}/${argv.caseID}/sub_cases/_find`, + method: 'GET', + query: { + status: argv.status, + }, + }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleDelete(argv: any) { + const client = createClient(argv); + + try { + await client.request({ + path: `${CASES_URL}?ids=["${argv.id}"]`, + method: 'DELETE', + query: { + force: true, + }, + }); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleGenGroupAlerts(argv: any) { + const client = createClient(argv); + + try { + const createdAction = await client.request({ + path: '/api/actions/action', + method: 'POST', + body: { + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }, + }); + + let caseID: string | undefined = argv.caseID as string | undefined; + + if (!caseID) { + console.log('Creating new case'); + const newCase = await client.request({ + path: CASES_URL, + method: 'POST', + body: { + description: 'This is a brand new case from generator script', + type: CaseType.collection, + title: 'Super Bad Security Issue', + tags: ['defacement'], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }, + }); + caseID = newCase.data.id; + } + + console.log('Case id: ', caseID); + const executeResp = await client.request({ + path: `/api/actions/action/${createdAction.data.id}/_execute`, + method: 'POST', + body: { + params: { + subAction: 'addComment', + subActionParams: { + caseId: caseID, + comment: { + type: CommentType.generatedAlert, + alerts: argv.ids.map((id: string) => ({ _id: id })), + index: argv.signalsIndex, + }, + }, + }, + }, + }); + + if (executeResp.data.status !== 'ok') { + console.log( + 'Error received from actions api during execute: ', + JSON.stringify(executeResp.data, null, 2) + ); + process.exit(1); + } + + console.log('Execution response ', JSON.stringify(executeResp.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function main() { + // TODO: this isn't waiting for some reason + const argv = await yargs(process.argv.slice(2)) + .help() + .options({ + seed: { + alias: 's', + describe: 'random seed to use for document generator', + type: 'string', + }, + kibana: { + alias: 'k', + describe: 'kibana url', + default: 'http://elastic:changeme@localhost:5601', + type: 'string', + }, + }) + .command({ + command: ['generate', 'gen', 'genAlerts'], + aliases: ['$0'], + describe: 'generate a group of alerts', + builder: (args) => { + return args + .options({ + caseID: { + alias: 'c', + describe: 'case ID', + }, + ids: { + alias: 'a', + describe: 'alert ids', + type: 'array', + }, + signalsIndex: { + alias: 'i', + describe: 'siem signals index', + type: 'string', + default: '.siem-signals-default', + }, + }) + .demandOption(['ids']); + }, + handler: async (args) => { + return handleGenGroupAlerts(args); + }, + }) + .command({ + command: 'delete ', + describe: 'deletes a case', + builder: (args) => { + return args.positional('id', { + describe: 'case id', + type: 'string', + }); + }, + handler: async (args) => { + return handleDelete(args); + }, + }) + .command({ + command: 'find [status]', + describe: 'gets all sub cases', + builder: (args) => { + return args + .positional('caseID', { describe: 'case id', type: 'string' }) + .positional('status', { + describe: 'filter by status', + type: 'string', + }); + }, + handler: async (args) => { + return handleFind(args); + }, + }) + .demandCommand() + .parse(); + + console.log('completed'); +} diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 7f21309b677c3..20b2cb9e0c090 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -6,7 +6,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; export type AlertServiceContract = PublicMethodsOf; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index b1a8f69b57122..11b19a818e074 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -15,7 +15,6 @@ import { SavedObjectReference, SavedObjectsBulkUpdateResponse, SavedObjectsBulkResponse, - SavedObjectsFindOptionsReference, } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; @@ -27,7 +26,6 @@ import { CommentPatchAttributes, SubCaseAttributes, } from '../../common/api'; -import { SUB_CASES_URL } from '../../common/constants'; import { transformNewSubCase } from '../routes/api/utils'; import { CASE_SAVED_OBJECT, diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 09b2fe8d1df8c..97427654fb74a 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -43,6 +43,7 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ patchComment: jest.fn(), patchComments: jest.fn(), patchSubCase: jest.fn(), + patchSubCases: jest.fn(), }); export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 009053067064a..8c6f93b8b3d27 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -13,7 +13,7 @@ import { TestProviders } from '../../../common/mock'; import { casesStatus, useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import { useKibana } from '../../../common/lib/kibana'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -515,6 +515,8 @@ describe('AllCases', () => { await waitFor(() => { wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click'); expect(onRowClick).toHaveBeenCalledWith({ + // TODO: remove + converted_by: null, closedAt: null, closedBy: null, comments: [], @@ -543,7 +545,9 @@ describe('AllCases', () => { status: 'open', tags: ['coke', 'pepsi'], title: 'Another horrible breach!!', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: '2020-02-20T15:02:57.995Z', updatedBy: { email: 'leslie.knope@elastic.co', From bc3e861f233bcce5eb452987ba9c1cd64c06f887 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Mon, 1 Feb 2021 17:14:12 -0500 Subject: [PATCH 20/47] Removing converted_by and fixing type errors --- x-pack/plugins/case/common/api/cases/case.ts | 1 - .../cases/__snapshots__/create.test.ts.snap | 112 --- .../cases/__snapshots__/update.test.ts.snap | 240 ------ .../case/server/client/cases/create.test.ts | 110 ++- .../case/server/client/cases/update.test.ts | 235 +++++- .../comments/__snapshots__/add.test.ts.snap | 62 -- .../case/server/client/comments/add.test.ts | 61 +- .../api/__fixtures__/mock_saved_objects.ts | 4 - .../api/__snapshots__/utils.test.ts.snap | 718 ------------------ .../__snapshots__/patch_cases.test.ts.snap | 144 ---- .../__snapshots__/post_case.test.ts.snap | 40 - .../__snapshots__/post_comment.test.ts.snap | 21 - .../api/cases/comments/post_comment.test.ts | 20 +- .../routes/api/cases/patch_cases.test.ts | 140 +++- .../server/routes/api/cases/post_case.test.ts | 38 +- .../case/server/routes/api/utils.test.ts | 705 ++++++++++++++++- .../plugins/case/server/routes/api/utils.ts | 1 - .../case/server/saved_object_types/cases.ts | 13 - .../server/saved_object_types/migrations.ts | 10 +- .../case/server/scripts/sub_cases/index.ts | 9 +- .../cases/components/all_cases/index.test.tsx | 2 - .../components/case_view/helpers.test.tsx | 4 +- .../public/cases/containers/mock.ts | 2 - .../public/cases/containers/types.ts | 1 - .../public/cases/containers/use_get_case.tsx | 4 +- 25 files changed, 1288 insertions(+), 1409 deletions(-) delete mode 100644 x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap delete mode 100644 x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap delete mode 100644 x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap delete mode 100644 x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap delete mode 100644 x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap delete mode 100644 x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap delete mode 100644 x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 22b685abda773..30315a9839174 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -62,7 +62,6 @@ export const CaseAttributesRt = rt.intersection([ rt.type({ closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRT, rt.null]), - converted_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, external_service: CaseFullExternalServiceRt, diff --git a/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap b/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap deleted file mode 100644 index 3f51bf8bc775e..0000000000000 --- a/x-pack/plugins/case/server/client/cases/__snapshots__/create.test.ts.snap +++ /dev/null @@ -1,112 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`create happy path Allow user to create case without authentication 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; - -exports[`create happy path it creates the case correctly 2`] = ` -Array [ - Object { - "attributes": Object { - "action": "create", - "action_at": "2019-11-25T21:54:48.952Z", - "action_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "action_field": Array [ - "description", - "status", - "tags", - "title", - "connector", - "settings", - ], - "new_value": "{\\"type\\":\\"individual\\",\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", - "old_value": null, - }, - "references": Array [ - Object { - "id": "mock-it", - "name": "associated-cases", - "type": "cases", - }, - ], - }, -] -`; - -exports[`create happy path it creates the case without connector in the configuration 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; diff --git a/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap b/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap deleted file mode 100644 index 808e54e542605..0000000000000 --- a/x-pack/plugins/case/server/client/cases/__snapshots__/update.test.ts.snap +++ /dev/null @@ -1,240 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`update happy path it change the status of case to in-progress correctly 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-4", - "settings": Object { - "syncAlerts": true, - }, - "status": "in-progress", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; - -exports[`update happy path it closes the case correctly 1`] = ` -Array [ - Object { - "closed_at": "2019-11-25T21:54:48.952Z", - "closed_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; - -exports[`update happy path it opens the case correctly 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; - -exports[`update happy path it updates a case without a connector.id 1`] = ` -Array [ - Object { - "closed_at": "2019-11-25T21:54:48.952Z", - "closed_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-no-connector_id", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; - -exports[`update happy path it updates the connector correctly 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Bug", - "parent": null, - "priority": "Low", - }, - "id": "456", - "name": "My connector 2", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; 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 e13bd0e67aaa4..4049207d0a243 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -62,7 +62,6 @@ describe('create', () => { "name": "Jira", "type": ".jira", }, - "converted_by": null, "created_at": "2019-11-25T21:54:48.952Z", "created_by": Object { "email": "d00d@awesome.com", @@ -93,7 +92,38 @@ describe('create', () => { expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions // using a snapshot here so we don't have to update the text field manually each time it changes - ).toMatchSnapshot(); + ).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object { + "action": "create", + "action_at": "2019-11-25T21:54:48.952Z", + "action_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "action_field": Array [ + "description", + "status", + "tags", + "title", + "connector", + "settings", + ], + "new_value": "{\\"type\\":\\"individual\\",\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", + "old_value": null, + }, + "references": Array [ + Object { + "id": "mock-it", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + ] + `); }); test('it creates the case without connector in the configuration', async () => { @@ -119,7 +149,43 @@ describe('create', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.create(postCase); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('Allow user to create case without authentication', async () => { @@ -148,7 +214,43 @@ describe('create', () => { }); const res = await caseClient.client.create(postCase); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); 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 854175dd32b4c..4ba2d092be57b 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -41,7 +41,53 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update(patchCases); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions @@ -94,7 +140,49 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update(patchCases); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); }); test('it change the status of case to in-progress correctly', async () => { @@ -115,7 +203,53 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update(patchCases); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); }); test('it updates a case without a connector.id', async () => { @@ -136,7 +270,52 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update(patchCases); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); }); test('it updates the connector correctly', async () => { @@ -162,7 +341,53 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update(patchCases); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Bug", + "parent": null, + "priority": "Low", + }, + "id": "456", + "name": "My connector 2", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); }); test('it updates alert status when the status is updated and syncAlerts=true', async () => { diff --git a/x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap b/x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap deleted file mode 100644 index b39544f1b03e2..0000000000000 --- a/x-pack/plugins/case/server/client/comments/__snapshots__/add.test.ts.snap +++ /dev/null @@ -1,62 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`addComment happy path it adds a comment correctly 1`] = ` -Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2020-10-23T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "id": "mock-comment", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; - -exports[`addComment happy path it adds a comment of type alert correctly 1`] = ` -Object { - "alertId": "test-id", - "associationType": "case", - "created_at": "2020-10-23T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "id": "mock-comment", - "index": "test-index", - "pushed_at": null, - "pushed_by": null, - "type": "alert", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; - -exports[`addComment happy path it allow user to create comments without authentications 1`] = ` -Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2020-10-23T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "id": "mock-comment", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; 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 c030f16582837..c90119d9896c6 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -40,7 +40,25 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toMatchSnapshot(); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it adds a comment of type alert correctly', async () => { @@ -61,7 +79,26 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toMatchSnapshot(); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "alertId": "test-id", + "associationType": "case", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "index": "test-index", + "pushed_at": null, + "pushed_by": null, + "type": "alert", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it updates the case correctly after adding a comment', async () => { @@ -153,7 +190,25 @@ describe('addComment', () => { }); expect(res.id).toEqual('mock-id-1'); - expect(res.comments![res.comments!.length - 1]).toMatchSnapshot(); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it update the status of the alert if the case is synced with alerts', async () => { diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 8c644ee215040..faa41e21fc5e0 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -38,7 +38,6 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, - converted_by: null, description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', @@ -65,7 +64,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - converted_by: null, connector: { id: 'none', name: 'none', @@ -104,7 +102,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - converted_by: null, connector: { id: '123', name: 'My connector', @@ -151,7 +148,6 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, - converted_by: null, connector: { id: '123', name: 'My connector', diff --git a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap b/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap deleted file mode 100644 index 0f8d644dd74c9..0000000000000 --- a/x-pack/plugins/case/server/routes/api/__snapshots__/utils.test.ts.snap +++ /dev/null @@ -1,718 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Utils flattenCaseSavedObject flattens correctly 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", -} -`; - -exports[`Utils flattenCaseSavedObject flattens correctly with comments 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [ - Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2019-11-25T21:55:00.177Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "id": "mock-comment-1", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": "2019-11-25T21:55:00.177Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzEsMV0=", - }, - ], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", -} -`; - -exports[`Utils flattenCaseSavedObject flattens correctly without version 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "0", -} -`; - -exports[`Utils flattenCaseSavedObject inserts missing connector 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-no-connector_id", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", -} -`; - -exports[`Utils flattenCaseSavedObjects flattens correctly 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, -] -`; - -exports[`Utils flattenCaseSavedObjects inserts missing connector 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-no-connector_id", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, -] -`; - -exports[`Utils flattenCaseSavedObjects it handles total comments correctly when caseId is not in extraCaseData 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, -] -`; - -exports[`Utils transformCases transforms correctly 1`] = ` -Object { - "cases": Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:00.900Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie destroying data!", - "external_service": null, - "id": "mock-id-2", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "Data Destruction", - ], - "title": "Damaging Data Destruction Detected", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:00.900Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzQsMV0=", - }, - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - }, - Object { - "closed_at": "2019-11-25T22:32:17.947Z", - "closed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-4", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - }, - ], - "count_closed_cases": 2, - "count_in_progress_cases": 2, - "count_open_cases": 2, - "page": 1, - "per_page": 10, - "total": 4, -} -`; - -exports[`Utils transformNewCase transform correctly 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": "elastic@elastic.co", - "full_name": "Elastic", - "username": "elastic", - }, - "description": "A description", - "external_service": null, - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, -} -`; - -exports[`Utils transformNewCase transform correctly with optional fields as null 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "description": "A description", - "external_service": null, - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, -} -`; - -exports[`Utils transformNewCase transform correctly without optional fields 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": undefined, - "full_name": undefined, - "username": undefined, - }, - "description": "A description", - "external_service": null, - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, -} -`; - -exports[`Utils transformNewComment transform correctly with optional fields as null 1`] = ` -Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, -} -`; - -exports[`Utils transformNewComment transform correctly without optional fields 1`] = ` -Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": undefined, - "full_name": undefined, - "username": undefined, - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, -} -`; - -exports[`Utils transformNewComment transforms correctly 1`] = ` -Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": "elastic@elastic.co", - "full_name": "Elastic", - "username": "elastic", - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, -} -`; diff --git a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap deleted file mode 100644 index ed52e92350f79..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/patch_cases.test.ts.snap +++ /dev/null @@ -1,144 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PATCH cases Change case to in-progress 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "in-progress", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; - -exports[`PATCH cases Close a case 1`] = ` -Array [ - Object { - "closed_at": "2019-11-25T21:54:48.952Z", - "closed_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; - -exports[`PATCH cases Open a case 1`] = ` -Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "converted_by": null, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-4", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, -] -`; diff --git a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap deleted file mode 100644 index f6cb714d19387..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/__snapshots__/post_case.test.ts.snap +++ /dev/null @@ -1,40 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`POST cases Allow user to create case without authentication 1`] = ` -Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "converted_by": null, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap b/x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap deleted file mode 100644 index bd4f80a872670..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/comments/__snapshots__/post_comment.test.ts.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`POST comment Allow user to create comments without authentications 1`] = ` -Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "id": "mock-comment", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", -} -`; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 4b75d86efb146..b903da5c5edd9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -298,6 +298,24 @@ describe('POST comment', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1]).toMatchSnapshot(); + expect(response.payload.comments[response.payload.comments.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 6c538cab9f072..370db5d6d6c24 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -51,7 +51,53 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toMatchSnapshot(); + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); }); it(`Open a case`, async () => { @@ -78,7 +124,53 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toMatchSnapshot(); + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); }); it(`Change case to in-progress`, async () => { @@ -104,7 +196,49 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toMatchSnapshot(); + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", + }, + ] + `); }); it(`Patches a case without a connector.id`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index e5362710461c9..a33d1ca95b4f0 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -188,6 +188,42 @@ describe('POST cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toMatchSnapshot(); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index d902d9e58a682..9f8c0faf93c46 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -56,7 +56,51 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly without optional fields', () => { @@ -68,7 +112,51 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly with optional fields as null', () => { @@ -83,7 +171,51 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -100,7 +232,23 @@ describe('Utils', () => { }; const res = transformNewComment(comment); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly without optional fields', () => { @@ -113,7 +261,23 @@ describe('Utils', () => { const res = transformNewComment(comment); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly with optional fields as null', () => { @@ -129,7 +293,23 @@ describe('Utils', () => { const res = transformNewComment(comment); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -195,7 +375,186 @@ describe('Utils', () => { perPage: 10, total: casesMap.size, }); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T22:32:00.900Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie destroying data!", + "external_service": null, + "id": "mock-id-2", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "Data Destruction", + ], + "title": "Damaging Data Destruction Detected", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:00.900Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzQsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + Object { + "closed_at": "2019-11-25T22:32:17.947Z", + "closed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 4, + } + `); }); }); @@ -206,14 +565,98 @@ describe('Utils', () => { const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + ] + `); }); it('it handles total comments correctly when caseId is not in extraCaseData', () => { const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + ] + `); }); it('inserts missing connector', () => { @@ -227,7 +670,48 @@ describe('Utils', () => { // @ts-ignore this is to update old case saved objects to include connector const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + ] + `); }); }); @@ -239,7 +723,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); it('flattens correctly without version', () => { @@ -250,7 +778,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "0", + } + `); }); it('flattens correctly with comments', () => { @@ -262,7 +834,73 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [ + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:55:00.177Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "id": "mock-comment-1", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": "2019-11-25T21:55:00.177Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzEsMV0=", + }, + ], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); it('inserts missing connector', () => { @@ -276,7 +914,46 @@ describe('Utils', () => { ...extraCaseData, }); - expect(res).toMatchSnapshot(); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index ce2c572b3a9a4..7d7e01d5c1944 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -79,7 +79,6 @@ export const transformNewCase = ({ ...newCase, closed_at: null, closed_by: null, - converted_by: null, connector, created_at: createdDate, created_by: { email, full_name, username }, diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 99d1a16f9ba54..a9bc91b31f283 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -31,19 +31,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - converted_by: { - properties: { - username: { - type: 'keyword', - }, - full_name: { - type: 'keyword', - }, - email: { - type: 'keyword', - }, - }, - }, created_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index 0cb975c44e486..c7b95d645fed9 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -48,13 +48,8 @@ interface SanitizedCaseSettings { }; } -interface SanitizedConvertedByCaseType { +interface SanitizedCaseType { type: string; - converted_by: { - username: string; - full_name: string; - email: string; - } | null; } export const caseMigrations = { @@ -93,13 +88,12 @@ export const caseMigrations = { }, '7.12.0': ( doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { + ): SavedObjectSanitizedDoc => { return { ...doc, attributes: { ...doc.attributes, type: CaseType.individual, - converted_by: null, }, references: doc.references || [], }; 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 469d6e059556b..fe4d2b877a5ce 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -142,14 +142,9 @@ async function handleGenGroupAlerts(argv: any) { async function main() { // TODO: this isn't waiting for some reason - const argv = await yargs(process.argv.slice(2)) + await yargs(process.argv.slice(2)) .help() .options({ - seed: { - alias: 's', - describe: 'random seed to use for document generator', - type: 'string', - }, kibana: { alias: 'k', describe: 'kibana url', @@ -158,7 +153,7 @@ async function main() { }, }) .command({ - command: ['generate', 'gen', 'genAlerts'], + command: ['alerts', 'gen', 'genAlerts'], aliases: ['$0'], describe: 'generate a group of alerts', builder: (args) => { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 8c6f93b8b3d27..5e52bee208303 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -515,8 +515,6 @@ describe('AllCases', () => { await waitFor(() => { wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click'); expect(onRowClick).toHaveBeenCalledWith({ - // TODO: remove - converted_by: null, closedAt: null, closedBy: null, comments: [], diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx index cfcfa412c79cb..679910724181c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CommentType } from '../../../../../case/common/api'; +import { AssociationType, CommentType } from '../../../../../case/common/api'; import { Comment } from '../../containers/types'; import { getRuleIdsFromComments, buildAlertsQuery } from './helpers'; const comments: Comment[] = [ { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-1', index: 'alert-index-1', @@ -24,6 +25,7 @@ const comments: Comment[] = [ version: 'WzQ3LDFc', }, { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-2', index: 'alert-index-2', diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index adbda2410c34a..744abff7c0fc6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -86,7 +86,6 @@ export const basicCase: Case = { type: ConnectorTypes.none, fields: null, }, - convertedBy: null, description: 'Security banana Issue', externalService: null, status: CaseStatuses.open, @@ -247,7 +246,6 @@ export const basicCaseSnake: CaseResponse = { type: ConnectorTypes.none, fields: null, }, - converted_by: null, created_at: basicCreatedAt, created_by: elasticUserSnake, external_service: null, diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index 773259d321cd1..61c7fb9e10cd8 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -57,7 +57,6 @@ export interface Case { closedBy: ElasticUser | null; comments: Comment[]; connector: CaseConnector; - convertedBy: ElasticUser | null; createdAt: string; createdBy: ElasticUser; description: string; 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 060ed787c7f4e..6099d7a4c5b08 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 @@ -5,7 +5,7 @@ */ import { useEffect, useReducer, useCallback } from 'react'; -import { CaseStatuses } from '../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; @@ -70,7 +70,9 @@ export const initialData: Case = { status: CaseStatuses.open, tags: [], title: '', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: null, updatedBy: null, version: '', From f13d1d424ef0d46c59e8503d0cfebdb770f39ff7 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Mon, 1 Feb 2021 17:29:10 -0500 Subject: [PATCH 21/47] Adding docs for script --- .../case/server/scripts/sub_cases/README.md | 80 +++++++++++++++++++ .../case/server/scripts/sub_cases/index.ts | 4 +- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/case/server/scripts/sub_cases/README.md diff --git a/x-pack/plugins/case/server/scripts/sub_cases/README.md b/x-pack/plugins/case/server/scripts/sub_cases/README.md new file mode 100644 index 0000000000000..92873b8f037f3 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/README.md @@ -0,0 +1,80 @@ +# Sub Cases Helper Script + +This script makes interacting with sub cases easier (creating, deleting, retrieving, etc). + +To run the script, first `cd x-pack/plugins/case/server/scripts` + +## Showing the help + +```bash +yarn test:sub-cases help +``` + +Sub command help + +```bash +yarn test:sub-cases help +``` + +## Generating alerts + +This will generate a new case and sub case if one does not exist and then attach a group +of alerts to it. + +```bash +yarn test:sub-cases alerts --ids id1 id2 id3 +``` + +## Deleting a collection + +This will delete a case that has sub cases. + +```bash +yarn test:sub-cases delete +``` + +## Find sub cases + +This will find sub cases attached to a collection. + +```bash +yarn test:sub-cases find [status] +``` + +Example: + +```bash +yarn test:sub-cases find 6c9e0490-64dc-11eb-92be-09d246866276 +``` + +Response: + +```bash +{ + "page": 1, + "per_page": 1, + "total": 1, + "subCases": [ + { + "id": "6dd6d2b0-64dc-11eb-92be-09d246866276", + "version": "WzUzNDMsMV0=", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "closed_at": null, + "closed_by": null, + "created_at": "2021-02-01T22:25:46.323Z", + "status": "open", + "updated_at": "2021-02-01T22:25:46.323Z", + "updated_by": { + "full_name": null, + "email": null, + "username": "elastic" + } + } + ], + "count_open_cases": 0, + "count_in_progress_cases": 0, + "count_closed_cases": 0 +} +``` 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 fe4d2b877a5ce..c25f682bfe3a8 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -153,8 +153,8 @@ async function main() { }, }) .command({ - command: ['alerts', 'gen', 'genAlerts'], - aliases: ['$0'], + command: 'alerts', + aliases: ['gen', 'genAlerts'], describe: 'generate a group of alerts', builder: (args) => { return args From 1db10e5d577da62188ed1a94efbc676bcd3db97d Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 2 Feb 2021 08:32:16 -0500 Subject: [PATCH 22/47] Removing converted_by and fixing integration test --- x-pack/plugins/case/server/connectors/case/index.test.ts | 3 --- x-pack/plugins/case/server/routes/api/cases/helpers.ts | 2 +- x-pack/test/case_api_integration/common/lib/mock.ts | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) 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 ac8df2d7c3c42..b06b9748142ec 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -832,7 +832,6 @@ describe('case connector', () => { totalAlerts: 0, closed_at: null, closed_by: null, - converted_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, created_at: '2019-11-25T21:54:48.952Z', created_by: { @@ -929,7 +928,6 @@ describe('case connector', () => { full_name: 'elastic', username: 'elastic', }, - converted_by: null, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', external_service: null, @@ -1009,7 +1007,6 @@ describe('case connector', () => { email: 'd00d@awesome.com', username: 'awesome', }, - converted_by: null, description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 1bd8321c03d13..afb9323d08f91 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -119,7 +119,7 @@ export const findSubCaseStatusStats = async ({ hasReference: ids.map((id) => { return { id, - type: SUB_CASE_SAVED_OBJECT, + type: CASE_SAVED_OBJECT, }; }), }, 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 fdbaeb8590770..176cd87dc7eff 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -63,7 +63,6 @@ export const postCaseResp = ( totalComment: 0, type: CaseType.individual, closed_by: null, - converted_by: null, created_by: defaultUser, external_service: null, status: CaseStatuses.open, From f7b136b1aa5ed55347918eeb8b4adac698cd3537 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 2 Feb 2021 17:56:00 -0500 Subject: [PATCH 23/47] Adding sub case id to comment routes --- .../case/server/client/comments/add.ts | 1 + .../server/common/models/commentable_case.ts | 15 +- .../api/cases/comments/delete_all_comments.ts | 19 ++- .../api/cases/comments/delete_comment.ts | 18 ++- .../api/cases/comments/find_comments.ts | 27 +++- .../api/cases/comments/get_all_comment.ts | 26 +++- .../api/cases/comments/patch_comment.ts | 143 ++++++++++-------- .../routes/api/cases/comments/post_comment.ts | 7 +- .../server/routes/api/cases/delete_cases.ts | 6 +- .../server/routes/api/cases/find_cases.ts | 3 +- .../case/server/routes/api/cases/get_case.ts | 4 +- .../case/server/routes/api/cases/helpers.ts | 65 +++++--- .../routes/api/cases/sub_case/get_sub_case.ts | 11 +- .../api/cases/sub_case/patch_sub_cases.ts | 4 +- x-pack/plugins/case/server/services/index.ts | 117 +++++++++++--- x-pack/plugins/case/server/services/mocks.ts | 1 + .../server/services/user_actions/helpers.ts | 17 ++- 17 files changed, 333 insertions(+), 151 deletions(-) diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 3a66faaa18a53..0951a4ab9e068 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -294,6 +294,7 @@ export const addComment = async ({ caseService.postNewComment({ client: savedObjectsClient, attributes: transformNewComment({ + // TODO: this needs to be sub if it is a sub case associationType: AssociationType.case, createdDate, ...query, 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 c2cbd0b6c78e9..c6335c66108ec 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -76,13 +76,17 @@ export class CommentableCase { // TODO: refactor this, we shouldn't really need to know the saved object type? public buildRefsToCase(): SavedObjectReference[] { - const type = this.subCase ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const subCaseSOType = SUB_CASE_SAVED_OBJECT; + const caseSOType = CASE_SAVED_OBJECT; return [ { - type, - name: `associated-${type}`, - id: this.id, + type: caseSOType, + name: `associated-${caseSOType}`, + id: this.collection.id, }, + ...(this.subCase + ? [{ type: subCaseSOType, name: `associated-${subCaseSOType}`, id: this.subCase.id }] + : []), ]; } @@ -108,10 +112,9 @@ export class CommentableCase { }); if (this.subCase) { - const subCaseComments = await this.service.getAllCaseComments({ + const subCaseComments = await this.service.getAllSubCaseComments({ client: this.soClient, id: this.subCase.id, - subCaseID: this.subCase.id, }); return CollectWithSubCaseResponseRt.encode({ 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 6ba961fb6900e..a65992f5039d3 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 @@ -10,6 +10,8 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getComments } from '../helpers'; +import { AssociationType } from '../../../../../common/api'; export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -19,6 +21,11 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + sub_case_id: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { @@ -28,10 +35,16 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic const { username, full_name, email } = await caseService.getUser({ request, response }); const deleteDate = new Date().toISOString(); - const comments = await caseService.getAllCaseComments({ + const id = request.query?.sub_case_id ?? request.params.case_id; + const comments = await getComments({ client, - id: request.params.case_id, + caseService, + id, + associationType: request.query?.sub_case_id + ? AssociationType.subCase + : AssociationType.case, }); + await Promise.all( comments.saved_objects.map((comment) => caseService.deleteComment({ @@ -48,7 +61,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic action: 'delete', actionAt: deleteDate, actionBy: { username, full_name, email }, - caseId: request.params.case_id, + caseId: id, 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 c7770810d172f..05d41a15f16e2 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 @@ -7,7 +7,7 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; @@ -22,6 +22,11 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: case_id: schema.string(), comment_id: schema.string(), }), + query: schema.maybe( + schema.object({ + sub_case_id: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { @@ -40,10 +45,13 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { + const type = request.query?.sub_case_id ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const id = request.query?.sub_case_id ?? request.params.case_id; + + const caseRef = myComment.references.find((c) => c.type === type); + if (caseRef == null || (caseRef != null && caseRef.id !== id)) { throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${id}).` ); } @@ -59,7 +67,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: action: 'delete', actionAt: deleteDate, actionBy: { username, full_name, email }, - caseId: request.params.case_id, + caseId: id, 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 0fe3a1f45fb24..2ff88b30ecf8d 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 @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as rt from 'io-ts'; + import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; @@ -12,6 +14,7 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { + AssociationType, CommentsResponseRt, SavedObjectFindOptionsRt, throwErrors, @@ -19,10 +22,16 @@ import { import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getComments } from '../helpers'; const defaultPage = 1; const defaultPerPage = 20; +const FindQueryParamsRt = rt.partial({ + ...SavedObjectFindOptionsRt.props, + sub_case_id: rt.string, +}); + export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { router.get( { @@ -38,14 +47,17 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { try { const client = context.core.savedObjects.client; const query = pipe( - SavedObjectFindOptionsRt.decode(request.query), + FindQueryParamsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) ); + const id = query.sub_case_id ?? request.params.case_id; + const associationType = query.sub_case_id ? AssociationType.subCase : AssociationType.case; const args = query ? { + caseService, client, - id: request.params.case_id, + id, options: { // We need this because the default behavior of getAllCaseComments is to return all the comments // unless the page and/or perPage is specified. Since we're spreading the query after the request can @@ -55,13 +67,20 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { ...query, sortField: 'created_at', }, + associationType, } : { + caseService, client, - id: request.params.case_id, + id, + options: { + page: defaultPage, + perPage: defaultPerPage, + }, + associationType, }; - const theComments = await caseService.getAllCaseComments(args); + const theComments = await getComments(args); return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); } catch (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 ea4ec3639ed8b..14255e5cc6ea2 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 @@ -6,7 +6,8 @@ import { schema } from '@kbn/config-schema'; -import { AllCommentsResponseRt } from '../../../../../common/api'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; @@ -21,7 +22,8 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { }), query: schema.maybe( schema.object({ - sub_case_id: schema.string(), + include_sub_case_comments: schema.maybe(schema.boolean()), + sub_case_id: schema.maybe(schema.string()), }) ), }, @@ -29,11 +31,21 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const comments = await caseService.getAllCaseComments({ - client, - id: request.params.case_id, - subCaseID: request.query?.sub_case_id, - }); + let comments: SavedObjectsFindResponse; + + if (request.query?.sub_case_id) { + comments = await caseService.getAllSubCaseComments({ + client, + id: request.query.sub_case_id, + }); + } else { + comments = await caseService.getAllCaseComments({ + client, + id: request.params.case_id, + includeSubCaseComments: request.query?.include_sub_case_comments, + }); + } + return response.ok({ body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); 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 04224c971f51f..60f56634d8542 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 @@ -11,12 +11,49 @@ import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { CommentPatchRequestRt, CaseResponseRt, throwErrors } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; +import { CommentableCase } from '../../../../common'; +import { + CommentPatchRequestRt, + CaseResponseRt, + throwErrors, + ESCaseAttributes, +} from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCaseSavedObject, decodeComment } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CaseServiceSetup } from '../../../../services'; + +interface CombinedCaseParams { + service: CaseServiceSetup; + client: SavedObjectsClientContract; + caseID: string; + subCaseID?: string; +} + +async function getCommentableCase({ service, client, caseID, subCaseID }: CombinedCaseParams) { + if (subCaseID) { + const [caseInfo, subCase] = await Promise.all([ + service.getCase({ + client, + id: caseID, + }), + service.getSubCase({ + client, + id: subCaseID, + }), + ]); + return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); + } else { + const caseInfo = await service.getCase({ + client, + id: caseID, + }); + return new CommentableCase({ collection: caseInfo, service, soClient: client }); + } +} export function initPatchCommentApi({ caseConfigureService, @@ -31,13 +68,17 @@ export function initPatchCommentApi({ params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + sub_case_id: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const caseId = request.params.case_id; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) @@ -46,9 +87,11 @@ export function initPatchCommentApi({ const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; decodeComment(queryRestAttributes); - const myCase = await caseService.getCase({ + const commentableCase = await getCommentableCase({ + service: caseService, client, - id: caseId, + caseID: request.params.case_id, + subCaseID: request.query?.sub_case_id, }); const myComment = await caseService.getComment({ @@ -64,9 +107,13 @@ export function initPatchCommentApi({ throw Boom.badRequest(`You cannot change the type of the comment.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== caseId)) { - throw Boom.notFound(`This comment ${queryCommentId} does not exist in ${caseId}).`); + const saveObjType = request.query?.sub_case_id ? 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)) { + throw Boom.notFound( + `This comment ${queryCommentId} does not exist in ${commentableCase.id}).` + ); } if (queryCommentVersion !== myComment.version) { @@ -89,71 +136,35 @@ export function initPatchCommentApi({ }, version: queryCommentVersion, }), - caseService.patchCase({ - client, - caseId, - updatedAttributes: { - updated_at: updatedDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, + commentableCase.update({ + date: updatedDate, + user: { username, full_name, email }, }), ]); - const totalCommentsFindByCases = await caseService.getAllCaseComments({ + await userActionService.postUserActions({ client, - id: caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + subCaseId: request.query?.sub_case_id, + commentId: updatedComment.id, + fields: ['comment'], + newValue: JSON.stringify(queryRestAttributes), + oldValue: JSON.stringify( + // We are interested only in ContextBasicRt attributes + // myComment.attribute contains also CommentAttributesBasicRt attributes + pick(Object.keys(queryRestAttributes), myComment.attributes) + ), + }), + ], }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client, - id: request.params.case_id, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, - }), - userActionService.postUserActions({ - client, - actions: [ - buildCommentUserActionItem({ - action: 'update', - actionAt: updatedDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - commentId: updatedComment.id, - fields: ['comment'], - newValue: JSON.stringify(queryRestAttributes), - oldValue: JSON.stringify( - // We are interested only in ContextBasicRt attributes - // myComment.attribute contains also CommentAttributesBasicRt attributes - pick(Object.keys(queryRestAttributes), myComment.attributes) - ), - }), - ], - }), - ]); - return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - comments: comments.saved_objects, - }) - ), + body: await updatedCase.encode(), }); } catch (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 08d442bccf2cb..7f402cde96f83 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 @@ -18,6 +18,11 @@ export function initPostCommentApi({ router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + sub_case_id: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, @@ -27,7 +32,7 @@ export function initPostCommentApi({ router }: RouteDeps) { } const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; + const caseId = request.query?.sub_case_id ?? request.params.case_id; const comment = request.body as CommentRequest; try { 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 86ad2b3f9b0ed..a63aa6f608b95 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 @@ -58,13 +58,9 @@ async function deleteSubCases({ const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ client, ids: caseIds }); const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); - const commentsForSubCases = await caseService.getAllCaseComments({ + const commentsForSubCases = await caseService.getAllSubCaseComments({ client, id: subCaseIDs, - subCaseID: subCaseIDs, - options: { - fields: [], - }, }); // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted 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 c068a4e0b20d7..f0d7517c1abfa 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 @@ -21,6 +21,7 @@ import { CaseType, SavedObjectFindOptions, CaseResponse, + AssociationType, } from '../../../../common/api'; import { transformCases, wrapError, escapeHatch, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; @@ -98,7 +99,7 @@ async function findCases({ client, caseService, ids: Array.from(casesMap.keys()), - type: CASE_SAVED_OBJECT, + associationType: AssociationType.case, }); const casesWithComments = new Map(); 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 93c492a7805db..18f74ca617d03 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 @@ -20,14 +20,14 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro case_id: schema.string(), }), query: schema.object({ - includeComments: schema.string({ defaultValue: 'true' }), + includeComments: schema.boolean({ defaultValue: true }), }), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const includeComments = JSON.parse(request.query.includeComments); + const includeComments = request.query.includeComments; const [theCase] = await Promise.all([ caseService.getCase({ diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index afb9323d08f91..efdb0cd0fa53b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -20,10 +20,10 @@ import { CaseStatuses, CaseType, SavedObjectFindOptions, - AssociationType, CommentType, SubCaseResponse, SubCaseAttributes, + AssociationType, } from '../../../../common/api'; import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; import { @@ -207,6 +207,35 @@ export const findCaseStatusStats = async ({ return total; }; +interface FindCommentsArgs { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + id: string | string[]; + associationType: AssociationType; + options?: SavedObjectFindOptions; +} +export const getComments = async ({ + client, + caseService, + id, + associationType, + options, +}: FindCommentsArgs) => { + if (associationType === AssociationType.subCase) { + return caseService.getAllSubCaseComments({ + client, + id, + options, + }); + } else { + return caseService.getAllCaseComments({ + client, + id, + options, + }); + } +}; + interface SubCaseStats { commentTotals: Map; alertTotals: Map; @@ -216,43 +245,41 @@ export const getCaseCommentStats = async ({ client, caseService, ids, - type, + associationType, }: { client: SavedObjectsClientContract; caseService: CaseServiceSetup; ids: string[]; - type: typeof SUB_CASE_SAVED_OBJECT | typeof CASE_SAVED_OBJECT; + associationType: AssociationType; }): Promise => { + const refType = + associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; + const allComments = await Promise.all( ids.map((id) => - caseService.getAllCaseComments({ + getComments({ client, + caseService, + associationType, id, - subCaseID: type === SUB_CASE_SAVED_OBJECT ? id : undefined, - options: { - fields: [], - page: 1, - perPage: 1, - }, + options: { page: 1, perPage: 1 }, }) ) ); - const associationType = - type === SUB_CASE_SAVED_OBJECT ? AssociationType.subCase : AssociationType.case; - - const alerts = await caseService.getAllCaseComments({ + const alerts = await getComments({ client, + caseService, + associationType, id: ids, - subCaseID: type === SUB_CASE_SAVED_OBJECT ? ids : undefined, options: { - filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}) AND ${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${associationType}`, + filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert})`, }, }); const getID = (comments: SavedObjectsFindResponse) => { return comments.saved_objects.length > 0 - ? comments.saved_objects[0].references.find((ref) => ref.type === type)?.id + ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id : undefined; }; @@ -266,7 +293,7 @@ export const getCaseCommentStats = async ({ const getFindResultID = (comment: SavedObjectsFindResult) => { const refs = comment.references; - return refs.length > 0 ? refs.find((ref) => ref.type === type)?.id : undefined; + return refs.length > 0 ? refs.find((ref) => ref.type === refType)?.id : undefined; }; const groupedAlerts = alerts.saved_objects.reduce((acc, alertsInfo) => { @@ -328,7 +355,7 @@ export const findSubCases = async ({ client, caseService, ids: subCases.saved_objects.map((subCase) => subCase.id), - type: SUB_CASE_SAVED_OBJECT, + associationType: AssociationType.subCase, }); const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { 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 8c0c77fc02f24..d8388e4083143 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 @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; -import { SubCaseResponseRt } from '../../../../../common/api'; +import { AssociationType, SubCaseResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { flattenSubCaseSavedObject, wrapError } from '../../utils'; import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; @@ -21,14 +21,14 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { sub_case_id: schema.string(), }), query: schema.object({ - includeComments: schema.string({ defaultValue: 'true' }), + includeComments: schema.boolean({ defaultValue: true }), }), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const includeComments = JSON.parse(request.query.includeComments); + const includeComments = request.query.includeComments; const subCase = await caseService.getSubCase({ client, @@ -45,14 +45,13 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { }); } - const theComments = await caseService.getAllCaseComments({ + const theComments = await caseService.getAllSubCaseComments({ client, - id: request.params.case_id, + id: request.params.sub_case_id, options: { sortField: 'created_at', sortOrder: 'asc', }, - subCaseID: request.params.sub_case_id, }); return response.ok({ 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 c57761da177d2..124125bc4bb55 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 @@ -26,6 +26,7 @@ import { ESCaseAttributes, SubCaseResponse, SubCasesResponseRt, + AssociationType, } from '../../../../../common/api'; import { SUB_CASES_PATCH_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; @@ -250,10 +251,9 @@ async function update({ // TODO: extra to new function for (const subCaseToSync of subCasesToSyncAlertsFor) { const currentSubCase = subCasesMap.get(subCaseToSync.id); - const alertComments = await caseService.getAllCaseComments({ + const alertComments = await caseService.getAllSubCaseComments({ client, id: subCaseToSync.id, - subCaseID: subCaseToSync.id, options: { filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, }, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 11b19a818e074..a711024bcd2db 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -25,7 +25,9 @@ import { User, CommentPatchAttributes, SubCaseAttributes, + AssociationType, } from '../../common/api'; +import { combineFilters } from '../routes/api/cases/helpers'; import { transformNewSubCase } from '../routes/api/utils'; import { CASE_SAVED_OBJECT, @@ -65,7 +67,19 @@ interface FindCommentsArgs { client: SavedObjectsClientContract; id: string | string[]; options?: SavedObjectFindOptions; - subCaseID?: string | string[]; +} + +interface FindCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; + includeSubCaseComments?: boolean; +} + +interface FindSubCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; } interface FindCasesArgs extends ClientArgs { @@ -139,7 +153,12 @@ export interface CaseServiceSetup { findSubCasesByCaseId( args: FindSubCasesByIDArgs ): Promise>; - getAllCaseComments(args: FindCommentsArgs): Promise>; + getAllCaseComments( + args: FindCaseCommentsArgs + ): Promise>; + getAllSubCaseComments( + args: FindSubCaseCommentsArgs + ): Promise>; getCase(args: GetCaseArgs): Promise>; getSubCase(args: GetCaseArgs): Promise>; getSubCases(args: GetSubCasesArgs): Promise>; @@ -394,38 +413,22 @@ export class CaseService implements CaseServiceSetup { } } - /** - * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). - * to override this pass in the either the page or perPage options. - */ - public async getAllCaseComments({ + private async getAllComments({ client, id, options, - subCaseID, }: FindCommentsArgs): Promise> { try { - const refs = - subCaseID == null - ? this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })) - : this.asArray(subCaseID).map((idObj) => ({ - type: SUB_CASE_SAVED_OBJECT, - id: idObj, - })); - this.log.debug(`Attempting to GET all comments for case caseID ${id} subCaseID ${subCaseID}`); + this.log.debug(`Attempting to GET all comments for id ${id}`); if (options?.page !== undefined || options?.perPage !== undefined) { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, - hasReferenceOperator: 'OR', - hasReference: refs, ...options, }); } // get the total number of comments that are in ES then we'll grab them all in one go const stats = await client.find({ type: CASE_COMMENT_SAVED_OBJECT, - hasReferenceOperator: 'OR', - hasReference: refs, fields: [], page: 1, perPage: 1, @@ -435,17 +438,85 @@ export class CaseService implements CaseServiceSetup { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, - hasReferenceOperator: 'OR', - hasReference: refs, page: 1, perPage: stats.total, ...options, }); } catch (error) { - this.log.debug(`Error on GET all comments for case ${id} subCaseID: ${subCaseID}: ${error}`); + this.log.debug(`Error on GET all comments for ${id}: ${error}`); throw error; } } + + /** + * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). + * to override this pass in the either the page or perPage options. + * + * @param includeSubCaseComments is a flag to indicate that sub case comments should be included as well, by default + * sub case comments are excluded. If the `filter` field is included in the options, it will override this behavior + */ + public async getAllCaseComments({ + client, + id, + options, + includeSubCaseComments = false, + }: FindCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); + + let filter: string | undefined; + if (!includeSubCaseComments) { + // if other filters were passed in then combine them to filter out sub case comments + filter = combineFilters( + [ + options?.filter ?? '', + `${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${AssociationType.case}`, + ], + 'AND' + ); + } + + this.log.debug(`Attempting to GET all comments for case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + filter, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + throw error; + } + } + + public async getAllSubCaseComments({ + client, + id, + options, + }: FindSubCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: SUB_CASE_SAVED_OBJECT, id: caseID })); + + this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + throw error; + } + } + public async getReporters({ client }: ClientArgs) { try { this.log.debug(`Attempting to GET all reporters`); diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 97427654fb74a..ed7e3d129e46f 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -27,6 +27,7 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ findSubCases: jest.fn(), findSubCasesByCaseId: jest.fn(), getAllCaseComments: jest.fn(), + getAllSubCaseComments: jest.fn(), getCase: jest.fn(), getCases: jest.fn(), getComment: jest.fn(), diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index c7bdc8b10b5a3..54933b3dd7f69 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -20,7 +20,11 @@ import { transformESConnectorToCaseConnector, } from '../../routes/api/cases/helpers'; import { UserActionItem } from '.'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; export const transformNewUserAction = ({ actionField, @@ -58,6 +62,7 @@ interface BuildCaseUserAction { fields: UserActionField | unknown[]; newValue?: string | unknown; oldValue?: string | unknown; + subCaseId?: string; } interface BuildCommentUserActionItem extends BuildCaseUserAction { @@ -73,6 +78,7 @@ export const buildCommentUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCommentUserActionItem): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -93,6 +99,15 @@ export const buildCommentUserActionItem = ({ name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, id: commentId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + id: subCaseId, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + }, + ] + : []), ], }); From 51504e02a72b8a8365d2d440474da164dfdd732a Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 2 Feb 2021 18:33:21 -0500 Subject: [PATCH 24/47] Removing stringify comparison --- .../user_actions/get_all_user_actions.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 0ade064228d05..4f1416591020e 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -77,7 +77,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); - expect(body[0].new_value).to.eql(JSON.stringify(userActionPostResp)); + expect(JSON.parse(body[0].new_value)).to.eql(userActionPostResp); }); it(`on close case, user action: 'update' should be called with actionFields: ['status']`, async () => { @@ -151,10 +151,18 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['connector']); expect(body[1].action).to.eql('update'); - expect(body[1].old_value).to.eql(`{"id":"none","name":"none","type":".none","fields":null}`); - expect(body[1].new_value).to.eql( - `{"id":"123","name":"Connector","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}` - ); + expect(JSON.parse(body[1].old_value)).to.eql({ + id: 'none', + name: 'none', + type: '.none', + fields: null, + }); + expect(JSON.parse(body[1].new_value)).to.eql({ + id: '123', + name: 'Connector', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }); }); it(`on update tags, user action: 'add' and 'delete' should be called with actionFields: ['tags']`, async () => { @@ -288,7 +296,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].action_field).to.eql(['comment']); expect(body[1].action).to.eql('create'); expect(body[1].old_value).to.eql(null); - expect(body[1].new_value).to.eql(JSON.stringify(postCommentUserReq)); + expect(JSON.parse(body[1].new_value)).to.eql(postCommentUserReq); }); it(`on update comment, user action: 'update' should be called with actionFields: ['comments']`, async () => { @@ -321,13 +329,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(3); expect(body[2].action_field).to.eql(['comment']); expect(body[2].action).to.eql('update'); - expect(body[2].old_value).to.eql(JSON.stringify(postCommentUserReq)); - expect(body[2].new_value).to.eql( - JSON.stringify({ - comment: newComment, - type: CommentType.user, - }) - ); + expect(JSON.parse(body[2].old_value)).to.eql(postCommentUserReq); + expect(JSON.parse(body[2].new_value)).to.eql({ + comment: newComment, + type: CommentType.user, + }); }); it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { From db09ea72401537dcb7a963b56164eba7e2752fa5 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 3 Feb 2021 17:51:59 -0500 Subject: [PATCH 25/47] Adding delete api and tests --- .../case/common/api/cases/user_actions.ts | 1 + x-pack/plugins/case/common/api/helpers.ts | 10 ++ x-pack/plugins/case/common/constants.ts | 2 +- .../server/common/models/commentable_case.ts | 6 +- x-pack/plugins/case/server/common/utils.ts | 40 +++++- .../api/cases/comments/patch_comment.ts | 11 +- .../server/routes/api/cases/find_cases.ts | 1 - .../case/server/routes/api/cases/get_case.ts | 2 + .../case/server/routes/api/cases/helpers.ts | 35 ++--- .../api/cases/sub_case/delete_sub_cases.ts | 86 +++++++++++ .../routes/api/cases/sub_case/get_sub_case.ts | 7 +- .../api/cases/sub_case/patch_sub_cases.ts | 13 +- .../plugins/case/server/routes/api/index.ts | 2 + .../plugins/case/server/routes/api/utils.ts | 10 +- .../server/saved_object_types/sub_case.ts | 2 +- x-pack/plugins/case/server/services/index.ts | 2 +- .../server/services/user_actions/helpers.ts | 11 ++ .../tests/cases/sub_cases/delete_sub_cases.ts | 90 ++++++++++++ .../tests/cases/sub_cases/get_sub_case.ts | 105 ++++++++++++++ .../case_api_integration/basic/tests/index.ts | 2 + .../case_api_integration/common/lib/mock.ts | 134 +++++++++++++++++- .../case_api_integration/common/lib/utils.ts | 95 +++++++++++++ 22 files changed, 609 insertions(+), 58 deletions(-) create mode 100644 x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index e7aa67db9287e..b19b86d52a379 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -21,6 +21,7 @@ const UserActionFieldRt = rt.array( rt.literal('title'), rt.literal('status'), rt.literal('settings'), + rt.literal('sub_case'), ]) ); const UserActionRt = rt.union([ diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 3ed12ba9a68b0..8dc8a7edda523 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -10,12 +10,22 @@ import { CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, CASE_CONFIGURE_PUSH_URL, + SUB_CASE_DETAILS_URL, + SUB_CASES_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { return CASE_DETAILS_URL.replace('{case_id}', id); }; +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 getCaseCommentsUrl = (id: string): string => { return CASE_COMMENTS_URL.replace('{case_id}', id); }; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 6b6f99feed697..5c807e3782681 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -17,7 +17,7 @@ export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`; export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`; -export const SUB_CASES_PATCH_URL = `${CASES_URL}/sub_cases`; +export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`; export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; 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 c6335c66108ec..1cea948fc7298 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -17,7 +17,7 @@ import { transformESConnectorToCaseConnector } from '../../routes/api/cases/help import { flattenCommentSavedObjects, flattenSubCaseSavedObject } from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; -import { countAlertsFindResponse } from '../index'; +import { countAlertsForID } from '../index'; interface UserInfo { username: string | null | undefined; @@ -121,7 +121,7 @@ export class CommentableCase { subCase: flattenSubCaseSavedObject({ savedObject: this.subCase, comments: subCaseComments.saved_objects, - totalAlerts: countAlertsFindResponse(subCaseComments), + totalAlerts: countAlertsForID({ comments: subCaseComments, id: this.subCase.id }), }), ...this.formatCollectionForEncoding(collectionCommentStats.total), }); @@ -139,7 +139,7 @@ export class CommentableCase { return CollectWithSubCaseResponseRt.encode({ comments: flattenCommentSavedObjects(collectionComments.saved_objects), - totalAlerts: countAlertsFindResponse(collectionComments), + totalAlerts: countAlertsForID({ comments: collectionComments, id: this.collection.id }), ...this.formatCollectionForEncoding(collectionCommentStats.total), }); } diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index 1412e3d5afd10..6d345dd9d844d 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -7,6 +7,7 @@ import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; import { CommentAttributes, CommentType } from '../../common/api'; +// TODO: write unit tests for these function export const countAlerts = (comment: SavedObjectsFindResult) => { let totalAlerts = 0; if ( @@ -22,9 +23,38 @@ export const countAlerts = (comment: SavedObjectsFindResult) return totalAlerts; }; -export const countAlertsFindResponse = (comments: SavedObjectsFindResponse) => { - return comments.saved_objects.reduce((total, comment) => { - total += countAlerts(comment); - return total; - }, 0); +/** + * Count the number of alerts for each id in the alert's references. This will result + * in a map with entries for both the collection and the individual sub cases. So the resulting + * size of the map will not equal the total number of sub cases. + */ +export const groupTotalAlertsByID = ({ + comments, +}: { + comments: SavedObjectsFindResponse; +}): Map => { + return comments.saved_objects.reduce((acc, alertsInfo) => { + for (const alert of alertsInfo.references) { + if (alert.id) { + const totalAlerts = acc.get(alert.id); + if (totalAlerts !== undefined) { + acc.set(alert.id, totalAlerts + countAlerts(alertsInfo)); + } else { + acc.set(alert.id, countAlerts(alertsInfo)); + } + } + } + + return acc; + }, new Map()); +}; + +export const countAlertsForID = ({ + comments, + id, +}: { + comments: SavedObjectsFindResponse; + id: string; +}): number | undefined => { + return groupTotalAlertsByID({ comments }).get(id); }; 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 60f56634d8542..80d4611d6d4cc 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 @@ -11,18 +11,13 @@ import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectsClientContract } from 'kibana/server'; import { CommentableCase } from '../../../../common'; -import { - CommentPatchRequestRt, - CaseResponseRt, - throwErrors, - ESCaseAttributes, -} from '../../../../../common/api'; +import { CommentPatchRequestRt, throwErrors } from '../../../../../common/api'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, flattenCaseSavedObject, decodeComment } from '../../utils'; +import { escapeHatch, wrapError, decodeComment } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { CaseServiceSetup } from '../../../../services'; 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 f0d7517c1abfa..4017aac844ba7 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 @@ -25,7 +25,6 @@ import { } from '../../../../common/api'; import { transformCases, wrapError, escapeHatch, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; -import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; import { CaseServiceSetup } from '../../../services'; import { 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 18f74ca617d03..cd260b2f4746c 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 @@ -10,6 +10,7 @@ import { CaseResponseRt } from '../../../../common/api'; import { RouteDeps } from '../types'; import { flattenCaseSavedObject, wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { countAlertsForID } from '../../../common'; export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { router.get( @@ -61,6 +62,7 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro savedObject: theCase, comments: theComments.saved_objects, totalComment: theComments.total, + totalAlerts: countAlertsForID({ comments: theComments, id: request.params.case_id }), }) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index efdb0cd0fa53b..bbc08bd1313fa 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -33,7 +33,7 @@ import { } from '../../../saved_object_types'; import { flattenSubCaseSavedObject, sortToSnake } from '../utils'; import { CaseServiceSetup } from '../../../services'; -import { countAlerts } from '../../../common'; +import { groupTotalAlertsByID } from '../../../common'; // TODO: write unit tests for these functions export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { @@ -291,22 +291,7 @@ export const getCaseCommentStats = async ({ return acc; }, new Map()); - const getFindResultID = (comment: SavedObjectsFindResult) => { - const refs = comment.references; - return refs.length > 0 ? refs.find((ref) => ref.type === refType)?.id : undefined; - }; - - const groupedAlerts = alerts.saved_objects.reduce((acc, alertsInfo) => { - const id = getFindResultID(alertsInfo); - if (id) { - const totalAlerts = acc.get(id); - if (totalAlerts !== undefined) { - acc.set(id, totalAlerts + countAlerts(alertsInfo)); - } - acc.set(id, countAlerts(alertsInfo)); - } - return acc; - }, new Map()); + const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); return { commentTotals: groupedComments, alertTotals: groupedAlerts }; }; @@ -359,25 +344,25 @@ export const findSubCases = async ({ }); const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { - const id = getCaseID(subCase); - if (id) { - const subCaseFromMap = accMap.get(id); + const parentCaseID = getCaseID(subCase); + if (parentCaseID) { + const subCaseFromMap = accMap.get(parentCaseID); if (subCaseFromMap === undefined) { const subCasesForID = [ flattenSubCaseSavedObject({ savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, }), ]; - accMap.set(id, subCasesForID); + accMap.set(parentCaseID, subCasesForID); } else { subCaseFromMap.push( flattenSubCaseSavedObject({ savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(id) ?? 0, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, }) ); } 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 new file mode 100644 index 0000000000000..0848ad9adfcac --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from '@hapi/boom'; +import { schema } from '@kbn/config-schema'; +import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; +import { RouteDeps } from '../../types'; +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) { + router.delete( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + query: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + + const [comments, subCases] = await Promise.all([ + caseService.getAllSubCaseComments({ client, id: request.query.ids }), + caseService.getSubCases({ client, ids: request.query.ids }), + ]); + + const subCaseErrors = subCases.saved_objects.filter( + (subCase) => subCase.error !== undefined + ); + + if (subCaseErrors.length > 0) { + throw Boom.notFound( + `These sub cases ${subCaseErrors + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { + const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); + acc.set(subCase.id, parentID?.id); + return acc; + }, new Map()); + + await Promise.all( + comments.saved_objects.map((comment) => + caseService.deleteComment({ client, commentId: comment.id }) + ) + ); + + await Promise.all(request.query.ids.map((id) => caseService.deleteSubCase(client, id))); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map((id) => + buildCaseUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action + // but we won't have the case ID + caseId: subCaseIDToParentID.get(id) ?? '', + subCaseId: id, + fields: ['sub_case', 'comment', 'status'], + }) + ), + }); + + return response.noContent(); + } catch (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 d8388e4083143..d20f24e9b5ca7 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 @@ -6,10 +6,11 @@ import { schema } from '@kbn/config-schema'; -import { AssociationType, SubCaseResponseRt } from '../../../../../common/api'; +import { SubCaseResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { flattenSubCaseSavedObject, wrapError } from '../../utils'; import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; +import { countAlertsForID } from '../../../../common'; export function initGetSubCaseApi({ caseService, router }: RouteDeps) { router.get( @@ -60,6 +61,10 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { savedObject: subCase, comments: theComments.saved_objects, totalComment: theComments.total, + totalAlerts: countAlertsForID({ + comments: theComments, + id: request.params.sub_case_id, + }), }) ), }); 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 124125bc4bb55..f5515a282432f 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 @@ -26,9 +26,8 @@ import { ESCaseAttributes, SubCaseResponse, SubCasesResponseRt, - AssociationType, } from '../../../../../common/api'; -import { SUB_CASES_PATCH_URL } from '../../../../../common/constants'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; import { getCaseToUpdate } from '../helpers'; @@ -126,11 +125,13 @@ async function getParentCases({ caseIds: parentIDInfo.ids, }); - const parentCaseErrors = parentCases.saved_objects.find((so) => so.error !== undefined); + const parentCaseErrors = parentCases.saved_objects.filter((so) => so.error !== undefined); - if (parentCaseErrors) { + if (parentCaseErrors.length > 0) { throw Boom.badRequest( - `Unable to find parent cases for sub cases, original error: ${parentCaseErrors.error?.message}` + `Unable to find parent cases: ${parentCaseErrors + .map((c) => c.id) + .join(', ')} for sub cases: ${subCaseIDs.join(', ')}` ); } @@ -322,7 +323,7 @@ async function update({ export function initPatchSubCasesApi({ router, caseService, userActionService }: RouteDeps) { router.patch( { - path: SUB_CASES_PATCH_URL, + path: SUB_CASES_PATCH_DEL_URL, validate: { body: escapeHatch, }, diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index d0ea5d08cc659..90caa4d0bbab4 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -33,6 +33,7 @@ import { RouteDeps } from './types'; import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; +import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; export function initCaseApi(deps: RouteDeps) { // Cases @@ -47,6 +48,7 @@ export function initCaseApi(deps: RouteDeps) { initGetSubCaseApi(deps); initPatchSubCasesApi(deps); initFindSubCasesApi(deps); + initDeleteSubCasesApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 7d7e01d5c1944..3588a98ed6f9c 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -321,13 +321,19 @@ export const sortToSnake = (sortField: string | undefined): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -const isUserContext = ( +/** + * A type narrowing function for user comments. Exporting so integration tests can use it. + */ +export const isUserContext = ( context: CommentRequest | CommentAttributes ): context is CommentRequestUserType => { return context.type === CommentType.user; }; -const isAlertContext = ( +/** + * A type narrowing function for alert comments. Exporting so integration tests can use it. + */ +export const isAlertContext = ( context: CommentRequest | CommentAttributes ): context is CommentRequestAlertType => { return context.type === CommentType.alert; diff --git a/x-pack/plugins/case/server/saved_object_types/sub_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts index 3f4d46de63f37..5f457df414858 100644 --- a/x-pack/plugins/case/server/saved_object_types/sub_case.ts +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -6,7 +6,7 @@ import { SavedObjectsType } from 'src/core/server'; -export const SUB_CASE_SAVED_OBJECT = 'sub_case'; +export const SUB_CASE_SAVED_OBJECT = 'cases-sub-case'; export const subCaseSavedObjectType: SavedObjectsType = { name: SUB_CASE_SAVED_OBJECT, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index a711024bcd2db..2342bd19d96dd 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -291,7 +291,7 @@ export class CaseService implements CaseServiceSetup { }: GetSubCasesArgs): Promise> { try { this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); - return await client.bulkGet(ids.map((id) => ({ type: CASE_SAVED_OBJECT, id }))); + 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}`); throw error; diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 54933b3dd7f69..1b8f5d11baf49 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -119,6 +119,7 @@ export const buildCaseUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCaseUserAction): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -134,6 +135,15 @@ export const buildCaseUserActionItem = ({ name: `associated-${CASE_SAVED_OBJECT}`, id: caseId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + id: subCaseId, + }, + ] + : []), ], }); @@ -145,6 +155,7 @@ const userActionFieldsAllowed: UserActionField = [ 'title', 'status', 'settings', + 'sub_case', ]; export const buildCaseUserActions = ({ 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 new file mode 100644 index 0000000000000..5a2111d134eac --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + CASES_URL, + SUB_CASES_PATCH_DEL_URL, +} from '../../../../../../plugins/case/common/constants'; +import { postCommentUserReq } from '../../../../common/lib/mock'; +import { createSubCase, deleteAllCaseItems } from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_sub_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete a sub case', async () => { + const caseInfo = await createSubCase({ supertest }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + const { body: subCase } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(200); + + expect(subCase.id).to.not.eql(undefined); + + const { body } = await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${subCase.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + expect(body).to.eql({}); + await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(404); + }); + + it(`should delete a sub case's comments when that case gets deleted`, async () => { + const caseInfo = await createSubCase({ supertest }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + // there should be two comments on the sub case now + const { + body: patchedCaseWithSubCase, + }: { body: CollectionWithSubCaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .query({ sub_case_id: caseInfo.subCase!.id }) + .send(postCommentUserReq) + .expect(200); + + const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ + patchedCaseWithSubCase.subCase!.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}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(404); + }); + + it('unhappy path - 404s when sub case id is invalid', async () => { + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["fake-id"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +} 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 new file mode 100644 index 0000000000000..5e57a1bdd18ac --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + commentsResp, + postCommentAlertReq, + removeServerGeneratedPropertiesFromComments, + removeServerGeneratedPropertiesFromSubCase, + subCaseResp, +} from '../../../../common/lib/mock'; +import { + createSubCase, + defaultCreateSubComment, + deleteAllCaseItems, +} from '../../../../common/lib/utils'; +import { + getCaseCommentsUrl, + getSubCaseDetailsUrl, +} from '../../../../../../plugins/case/common/api/helpers'; +import { + AssociationType, + CollectionWithSubCaseResponse, + SubCaseResponse, +} from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_sub_case', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return a case', async () => { + const caseInfo = await createSubCase({ supertest }); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 1, totalAlerts: 2 }) + ); + }); + + it('should return the correct number of alerts with multiple types of alerts', async () => { + const caseInfo = await createSubCase({ supertest }); + + const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest + .post(getCaseCommentsUrl(caseInfo.id)) + .query({ sub_case_id: caseInfo.subCase!.id }) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [ + { comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }, + { + comment: postCommentAlertReq, + id: singleAlert.subCase!.comments![1].id, + }, + ], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 2, totalAlerts: 3 }) + ); + }); + + it('unhappy path - 404s when case is not there', async () => { + await supertest + .get(getSubCaseDetailsUrl('fake-case-id', 'fake-sub-case-id')) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index 56b473af61e63..3d144f4d95dcb 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -32,6 +32,8 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); loadTestFile(require.resolve('./connectors/case')); + loadTestFile(require.resolve('./cases/sub_cases/delete_sub_cases')); + loadTestFile(require.resolve('./cases/sub_cases/get_sub_case')); // Migrations loadTestFile(require.resolve('./cases/migrations')); 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 176cd87dc7eff..d5450ede44532 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -16,7 +16,17 @@ import { CaseStatuses, CaseType, CaseClientPostRequest, + CommentRequestGeneratedAlertType, + SubCaseResponse, + CommentRequest, + AssociationType, + CollectionWithSubCaseResponse, } from '../../../../plugins/case/common/api'; +import { + getAlertIds, + isGeneratedAlertContext, + isAlertContext, +} from '../../../../plugins/case/server/routes/api/utils'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', @@ -33,6 +43,14 @@ export const postCaseReq: CasePostRequest = { }, }; +/** + * The fields for creating a collection style case. + */ +export const postCollectionReq: CasePostRequest = { + ...postCaseReq, + type: CaseType.collection, +}; + /** * This is needed because the post api does not allow specifying the case type. But the response will include the type. */ @@ -52,6 +70,12 @@ export const postCommentAlertReq: CommentRequestAlertType = { type: CommentType.alert, }; +export const postCommentGenAlertReq: CommentRequestGeneratedAlertType = { + alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], + index: 'test-index', + type: CommentType.generatedAlert, +}; + export const postCaseResp = ( id: string, req: CasePostRequest = postCaseReq @@ -61,7 +85,7 @@ export const postCaseResp = ( comments: [], totalAlerts: 0, totalComment: 0, - type: CaseType.individual, + type: req.type ?? CaseType.individual, closed_by: null, created_by: defaultUser, external_service: null, @@ -69,6 +93,108 @@ export const postCaseResp = ( updated_by: null, }); +interface CommentRequestWithID { + id: string; + comment: CommentRequest; +} + +export const commentsResp = ({ + comments, + associationType, +}: { + comments: CommentRequestWithID[]; + associationType: AssociationType; +}): Array> => { + return comments.map(({ comment, id }) => { + const baseFields = { + id, + created_by: defaultUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + }; + if (isGeneratedAlertContext(comment)) { + return { + associationType, + alertId: getAlertIds(comment), + index: comment.index, + type: comment.type, + ...baseFields, + }; + } else if (isAlertContext(comment)) { + return { + associationType, + ...comment, + ...baseFields, + }; + } else { + return { + associationType, + ...comment, + ...baseFields, + }; + } + }); +}; + +export const subCaseResp = ({ + id, + totalAlerts, + totalComment, + status = CaseStatuses.open, +}: { + id: string; + status?: CaseStatuses; + totalAlerts: number; + totalComment: number; +}): Partial => ({ + status, + id, + totalAlerts, + totalComment, + closed_by: null, + // TODO: add this + // created_by: defaultUser, + updated_by: defaultUser, +}); + +interface FormattedCollectionResponse { + caseInfo: Partial; + subCase?: Partial; + comments?: Array>; +} + +export const formatCollectionResponse = ( + caseInfo: CollectionWithSubCaseResponse +): FormattedCollectionResponse => { + return { + caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo), + subCase: removeServerGeneratedPropertiesFromSubCase(caseInfo.subCase), + comments: removeServerGeneratedPropertiesFromComments( + caseInfo.subCase?.comments ?? caseInfo.comments + ), + }; +}; + +export const removeServerGeneratedPropertiesFromSubCase = ( + subCase: Partial | undefined +): Partial | undefined => { + if (!subCase) { + return; + } + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, comments, ...rest } = subCase; + return rest; +}; + +export const removeServerGeneratedPropertiesFromCaseCollection = ( + config: Partial +): Partial => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, subCase, ...rest } = config; + return rest; +}; + export const removeServerGeneratedPropertiesFromCase = ( config: Partial ): Partial => { @@ -78,9 +204,9 @@ export const removeServerGeneratedPropertiesFromCase = ( }; export const removeServerGeneratedPropertiesFromComments = ( - comments: CommentResponse[] -): Array> => { - return comments.map((comment) => { + comments: CommentResponse[] | undefined +): Array> | undefined => { + return comments?.map((comment) => { // eslint-disable-next-line @typescript-eslint/naming-convention const { created_at, updated_at, version, ...rest } = comment; 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 610910cb25b23..770b28934c60d 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -3,14 +3,85 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect'; import { Client } from '@elastic/elasticsearch'; +import * as st from 'supertest'; +import supertestAsPromised from 'supertest-as-promised'; +import { CASES_URL } from '../../../../plugins/case/common/constants'; import { CasesConfigureRequest, CasesConfigureResponse, CaseConnector, ConnectorTypes, + CasePostRequest, + CollectionWithSubCaseResponse, + CommentRequestGeneratedAlertType, } from '../../../../plugins/case/common/api'; +import { postCollectionReq, postCommentGenAlertReq } from './mock'; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubComment = postCommentGenAlertReq; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubPost = postCollectionReq; + +/** + * Creates a sub case using the actions API. If a caseID isn't passed in then it will create + * the collection as well. To create a sub case a comment must be created so it uses a default + * generated alert style comment which can be overridden. + */ +export const createSubCase = async ({ + supertest, + caseID, + comment = defaultCreateSubComment, + caseInfo = defaultCreateSubPost, +}: { + supertest: st.SuperTest; + comment?: CommentRequestGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; +}): Promise => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + let collectionID: string; + + if (!caseID) { + collectionID = ( + await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(caseInfo).expect(200) + ).body.id; + } else { + collectionID = caseID; + } + const caseConnector = await supertest + .post(`/api/actions/action/${createdAction.id}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'addComment', + subActionParams: { + caseId: collectionID, + comment, + }, + }, + }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); + return caseConnector.body.data; +}; export const getConfiguration = ({ id = 'connector-1', @@ -103,6 +174,16 @@ export const removeServerGeneratedPropertiesFromConfigure = ( return rest; }; +export const deleteAllCaseItems = async (es: Client) => { + await Promise.all([ + deleteCases(es), + deleteSubCases(es), + deleteCasesUserActions(es), + deleteComments(es), + deleteConfiguration(es), + ]); +}; + export const deleteCasesUserActions = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', @@ -123,6 +204,20 @@ export const deleteCases = async (es: Client): Promise => { }); }; +/** + * Deletes all sub cases in the .kibana index. This uses ES to perform the delete and does + * not go through the case API. + */ +export const deleteSubCases = async (es: Client): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:cases-sub-case', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + export const deleteComments = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', From 96332961726bea8d639c4a1673bde568872af85c Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 4 Feb 2021 11:29:41 -0500 Subject: [PATCH 26/47] Updating license --- x-pack/plugins/case/common/api/cases/commentable_case.ts | 5 +++-- x-pack/plugins/case/common/api/cases/sub_case.ts | 5 +++-- x-pack/plugins/case/server/client/client.ts | 5 +++-- x-pack/plugins/case/server/common/index.ts | 5 +++-- .../plugins/case/server/common/models/commentable_case.ts | 5 +++-- x-pack/plugins/case/server/common/models/index.ts | 5 +++-- x-pack/plugins/case/server/common/utils.ts | 5 +++-- .../server/routes/api/cases/sub_case/delete_sub_cases.ts | 5 +++-- .../case/server/routes/api/cases/sub_case/find_sub_cases.ts | 5 +++-- .../case/server/routes/api/cases/sub_case/get_sub_case.ts | 5 +++-- .../server/routes/api/cases/sub_case/patch_sub_cases.ts | 5 +++-- x-pack/plugins/case/server/saved_object_types/sub_case.ts | 5 +++-- .../basic/tests/cases/sub_cases/delete_sub_cases.ts | 6 +++--- .../basic/tests/cases/sub_cases/get_sub_case.ts | 5 +++-- 14 files changed, 42 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/commentable_case.ts b/x-pack/plugins/case/common/api/cases/commentable_case.ts index b5dbec1c135bf..023229a90d352 100644 --- a/x-pack/plugins/case/common/api/cases/commentable_case.ts +++ b/x-pack/plugins/case/common/api/cases/commentable_case.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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'; diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts index cde54da4a787b..0284b6184669e 100644 --- a/x-pack/plugins/case/common/api/cases/sub_case.ts +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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'; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index f32ce83c5ec43..2c2a5af3930bc 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 from '@hapi/boom'; diff --git a/x-pack/plugins/case/server/common/index.ts b/x-pack/plugins/case/server/common/index.ts index 3213bfb4a4062..0960b28b3d25a 100644 --- a/x-pack/plugins/case/server/common/index.ts +++ b/x-pack/plugins/case/server/common/index.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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. */ export * from './models'; 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 1cea948fc7298..a2e046b885a2f 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 { SavedObject, SavedObjectReference, SavedObjectsClientContract } from 'src/core/server'; diff --git a/x-pack/plugins/case/server/common/models/index.ts b/x-pack/plugins/case/server/common/models/index.ts index 2771f9ab68a9f..189090c91c81c 100644 --- a/x-pack/plugins/case/server/common/models/index.ts +++ b/x-pack/plugins/case/server/common/models/index.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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. */ export * from './commentable_case'; diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index 6d345dd9d844d..203bfda9da07a 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; 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 0848ad9adfcac..c8df012acc66a 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 @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 from '@hapi/boom'; 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 526e81e8e72d0..996c4eefcbeea 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 @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 { schema } from '@kbn/config-schema'; 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 d20f24e9b5ca7..b6d9a7345dbdd 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 @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 { schema } from '@kbn/config-schema'; 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 f5515a282432f..d40b6538bbf19 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 @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 from '@hapi/boom'; diff --git a/x-pack/plugins/case/server/saved_object_types/sub_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts index 5f457df414858..c66d2eb6a7818 100644 --- a/x-pack/plugins/case/server/saved_object_types/sub_case.ts +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 { SavedObjectsType } from 'src/core/server'; 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 5a2111d134eac..3174ddfbc7dcc 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 @@ -1,9 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; 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 5e57a1bdd18ac..17df9007b42e4 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 @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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 expect from '@kbn/expect'; From 233333094d99b582cdbbb760ea7de62b6d0cc481 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 4 Feb 2021 12:50:03 -0500 Subject: [PATCH 27/47] missed license files --- x-pack/plugins/case/server/scripts/sub_cases/generator.js | 5 +++-- x-pack/plugins/case/server/scripts/sub_cases/index.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/case/server/scripts/sub_cases/generator.js b/x-pack/plugins/case/server/scripts/sub_cases/generator.js index de7477628e3a8..0c5b8bfc8550b 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/generator.js +++ b/x-pack/plugins/case/server/scripts/sub_cases/generator.js @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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. */ require('../../../../../../src/setup_node_env'); 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 c25f682bfe3a8..8e613a248a694 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * 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. */ /* eslint-disable no-console */ import yargs from 'yargs'; From 80c2161ee1523495ba868b58ac323cbf739a7ff5 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 4 Feb 2021 14:32:48 -0500 Subject: [PATCH 28/47] Integration tests passing --- .../case/server/client/cases/update.ts | 3 +- .../case/server/client/comments/add.ts | 130 +++++------------- x-pack/plugins/case/server/common/index.ts | 1 + .../server/common/models/commentable_case.ts | 118 ++++++++++------ x-pack/plugins/case/server/common/types.ts | 12 ++ .../api/cases/comments/patch_comment.ts | 21 +-- .../basic/tests/connectors/case.ts | 1 + 7 files changed, 135 insertions(+), 151 deletions(-) create mode 100644 x-pack/plugins/case/server/common/types.ts diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 4cbb781ab8e76..41fbc2fb9200b 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -188,9 +188,8 @@ export const update = async ({ const caseComments = (await caseService.getAllCaseComments({ client: savedObjectsClient, id: theCase.id, + includeSubCaseComments: true, options: { - // TODO: is this a bug? I think this will return no fields for attributes, I have no idea why when - // we attempt to access the attributes that it doesn't crash?? fields: [], filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, page: 1, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 939cbcddf027e..b23b38b25082d 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -11,19 +11,13 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server'; -import { - decodeComment, - getAlertIds, - isGeneratedAlertContext, - transformNewComment, -} from '../../routes/api/utils'; +import { decodeComment, getAlertIds, isGeneratedAlertContext } from '../../routes/api/utils'; import { throwErrors, CommentRequestRt, CommentType, CaseStatuses, - AssociationType, CaseType, SubCaseAttributes, CommentRequest, @@ -33,9 +27,8 @@ import { } from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CommentableCase } from '../../common'; +import { CommentableCase, UserInfo } from '../../common'; import { CaseClientImpl } from '..'; async function getSubCase({ @@ -83,12 +76,15 @@ const addGeneratedAlerts = async ({ decodeComment(comment); const createdDate = new Date().toISOString(); - const myCase = await caseService.getCase({ + const caseInfo = await caseService.getCase({ client: savedObjectsClient, id: caseId, }); - if (query.type === CommentType.generatedAlert && myCase.attributes.type !== CaseType.collection) { + 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'); } @@ -99,68 +95,33 @@ const addGeneratedAlerts = async ({ createdAt: createdDate, }); - const userDetails = { - username: myCase.attributes.created_by?.username, - full_name: myCase.attributes.created_by?.full_name, - email: myCase.attributes.created_by?.email, + const commentableCase = new CommentableCase({ + collection: caseInfo, + subCase, + soClient: savedObjectsClient, + service: caseService, + }); + + const userDetails: UserInfo = { + username: caseInfo.attributes.created_by?.username, + full_name: caseInfo.attributes.created_by?.full_name, + email: caseInfo.attributes.created_by?.email, }; - const [newComment, updatedCase, updatedSubCase] = await Promise.all([ - // TODO: probably move this to the service layer - caseService.postNewComment({ - client: savedObjectsClient, - attributes: transformNewComment({ - associationType: AssociationType.subCase, - createdDate, - ...query, - ...userDetails, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: myCase.id, - }, - { - type: SUB_CASE_SAVED_OBJECT, - name: `associated-${SUB_CASE_SAVED_OBJECT}`, - id: subCase.id, - }, - ], - }), - caseService.patchCase({ - client: savedObjectsClient, - caseId, - updatedAttributes: { - updated_at: createdDate, - updated_by: { - ...userDetails, - }, - }, - version: myCase.version, - }), - caseService.patchSubCase({ - client: savedObjectsClient, - subCaseId: subCase.id, - updatedAttributes: { - updated_at: createdDate, - updated_by: { - ...userDetails, - }, - }, - version: subCase.version, - }), + const [newComment, updatedCase] = await Promise.all([ + commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }), + commentableCase.update({ date: createdDate, user: userDetails }), ]); if ( (newComment.attributes.type === CommentType.alert || newComment.attributes.type === CommentType.generatedAlert) && - myCase.attributes.settings.syncAlerts + caseInfo.attributes.settings.syncAlerts ) { const ids = getAlertIds(query); await caseClient.updateAlertsStatus({ ids, - status: myCase.attributes.status, + status: caseInfo.attributes.status, indices: new Set([newComment.attributes.index]), }); } @@ -180,27 +141,7 @@ const addGeneratedAlerts = async ({ ], }); - return new CommentableCase({ - collection: { - ...myCase, - ...updatedCase, - attributes: { - ...myCase.attributes, - ...updatedCase.attributes, - }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - subCase: { - ...subCase, - ...updatedSubCase, - attributes: { ...subCase.attributes, ...updatedSubCase.attributes }, - version: updatedSubCase.version ?? subCase.version, - references: subCase.references, - }, - service: caseService, - soClient: savedObjectsClient, - }).encode(); + return updatedCase.encode(); }; async function getCombinedCase( @@ -290,21 +231,14 @@ export const addComment = async ({ // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); + const userInfo: UserInfo = { + username, + full_name, + email, + }; const [newComment, updatedCase] = await Promise.all([ - caseService.postNewComment({ - client: savedObjectsClient, - attributes: transformNewComment({ - // TODO: this needs to be sub if it is a sub case - associationType: AssociationType.case, - createdDate, - ...query, - username, - full_name, - email, - }), - references: combinedCase.buildRefsToCase(), - }), + combinedCase.createComment({ createdDate, user: userInfo, commentReq: query }), // This will return a full new CombinedCase object that has the updated and base fields // merged together so let's use the return value from now on combinedCase.update({ @@ -314,9 +248,7 @@ export const addComment = async ({ ]); if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { - const ids = Array.isArray(newComment.attributes.alertId) - ? newComment.attributes.alertId - : [newComment.attributes.alertId]; + const ids = getAlertIds(query); await caseClient.updateAlertsStatus({ ids, status: updatedCase.status, diff --git a/x-pack/plugins/case/server/common/index.ts b/x-pack/plugins/case/server/common/index.ts index 0960b28b3d25a..b07ed5d4ae2d6 100644 --- a/x-pack/plugins/case/server/common/index.ts +++ b/x-pack/plugins/case/server/common/index.ts @@ -7,3 +7,4 @@ export * from './models'; export * from './utils'; +export * from './types'; 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 a2e046b885a2f..928c89d2d9a57 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -5,26 +5,33 @@ * 2.0. */ -import { SavedObject, SavedObjectReference, SavedObjectsClientContract } from 'src/core/server'; import { + SavedObject, + SavedObjectReference, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from 'src/core/server'; +import { + AssociationType, CaseSettings, CaseStatuses, - CollectionWithSubCaseAttributes, + CollectionWithSubCaseResponse, CollectWithSubCaseResponseRt, + CommentAttributes, + CommentPatchRequest, + CommentRequest, ESCaseAttributes, SubCaseAttributes, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; -import { flattenCommentSavedObjects, flattenSubCaseSavedObject } from '../../routes/api/utils'; +import { + flattenCommentSavedObjects, + flattenSubCaseSavedObject, + transformNewComment, +} from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; -import { countAlertsForID } from '../index'; - -interface UserInfo { - username: string | null | undefined; - full_name: string | null | undefined; - email: string | null | undefined; -} +import { countAlertsForID, UserInfo } from '../index'; interface CommentableCaseParams { collection: SavedObject; @@ -65,18 +72,7 @@ export class CommentableCase { return this.collection.attributes.settings; } - public get attributes(): CollectionWithSubCaseAttributes { - return { - subCase: this.subCase?.attributes, - case: { - ...this.collection.attributes, - connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), - }, - }; - } - - // TODO: refactor this, we shouldn't really need to know the saved object type? - public buildRefsToCase(): SavedObjectReference[] { + private buildRefsToCase(): SavedObjectReference[] { const subCaseSOType = SUB_CASE_SAVED_OBJECT; const caseSOType = CASE_SAVED_OBJECT; return [ @@ -91,6 +87,50 @@ export class CommentableCase { ]; } + public async updateComment({ + updateRequest, + updatedAt, + user, + }: { + updateRequest: CommentPatchRequest; + updatedAt: string; + user: UserInfo; + }): Promise> { + const { id, version, ...queryRestAttributes } = updateRequest; + + return this.service.patchComment({ + client: this.soClient, + commentId: id, + updatedAttributes: { + ...queryRestAttributes, + updated_at: updatedAt, + updated_by: user, + }, + version, + }); + } + + public async createComment({ + createdDate, + user, + commentReq, + }: { + createdDate: string; + user: UserInfo; + commentReq: CommentRequest; + }): Promise> { + return this.service.postNewComment({ + client: this.soClient, + attributes: transformNewComment({ + associationType: this.subCase ? AssociationType.subCase : AssociationType.case, + createdDate, + ...commentReq, + ...user, + }), + references: this.buildRefsToCase(), + }); + } + private formatCollectionForEncoding(totalComment: number) { return { id: this.collection.id, @@ -101,7 +141,7 @@ export class CommentableCase { }; } - public async encode() { + public async encode(): Promise { const collectionCommentStats = await this.service.getAllCaseComments({ client: this.soClient, id: this.collection.id, @@ -146,8 +186,10 @@ export class CommentableCase { } public async update({ date, user }: { date: string; user: UserInfo }): Promise { + let updatedSubCaseAttributes: SavedObject | undefined; + if (this.subCase) { - const updated = await this.service.patchSubCase({ + const updatedSubCase = await this.service.patchSubCase({ client: this.soClient, subCaseId: this.subCase.id, updatedAttributes: { @@ -159,22 +201,17 @@ export class CommentableCase { version: this.subCase.version, }); - return new CommentableCase({ - soClient: this.soClient, - service: this.service, - collection: this.collection, - subCase: { - ...this.subCase, - attributes: { - ...this.subCase.attributes, - ...updated.attributes, - }, - version: updated.version ?? this.subCase.version, + updatedSubCaseAttributes = { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updatedSubCase.attributes, }, - }); + version: updatedSubCase.version ?? this.subCase.version, + }; } - const updated = await this.service.patchCase({ + const updatedCase = await this.service.patchCase({ client: this.soClient, caseId: this.collection.id, updatedAttributes: { @@ -184,16 +221,17 @@ export class CommentableCase { 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, - ...updated.attributes, + ...updatedCase.attributes, }, - version: updated.version ?? this.collection.version, + version: updatedCase.version ?? this.collection.version, }, - subCase: this.subCase, + subCase: updatedSubCaseAttributes, soClient: this.soClient, service: this.service, }); diff --git a/x-pack/plugins/case/server/common/types.ts b/x-pack/plugins/case/server/common/types.ts new file mode 100644 index 0000000000000..4435877865fb3 --- /dev/null +++ b/x-pack/plugins/case/server/common/types.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export interface UserInfo { + username: string | null | undefined; + full_name: string | null | undefined; + email: string | null | undefined; +} 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 0fdbea20da2b4..5a11f6728b37c 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 @@ -13,7 +13,7 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; import { SavedObjectsClientContract } from 'kibana/server'; -import { CommentableCase } from '../../../../common'; +import { CommentableCase, UserInfo } from '../../../../common'; import { CommentPatchRequestRt, throwErrors } from '../../../../../common/api'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; @@ -120,17 +120,18 @@ export function initPatchCommentApi({ // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request, response }); + const userInfo: UserInfo = { + username, + full_name, + email, + }; + const updatedDate = new Date().toISOString(); const [updatedComment, updatedCase] = await Promise.all([ - caseService.patchComment({ - client, - commentId: queryCommentId, - updatedAttributes: { - ...queryRestAttributes, - updated_at: updatedDate, - updated_by: { email, full_name, username }, - }, - version: queryCommentVersion, + commentableCase.updateComment({ + updateRequest: query, + updatedAt: updatedDate, + user: userInfo, }), commentableCase.update({ date: updatedDate, diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index ab75d6b4ff542..9997ee0f7b2b9 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -757,6 +757,7 @@ export default ({ getService }: FtrProviderContext): void => { expect({ ...data, comments }).to.eql({ ...postCaseResp(caseRes.body.id), comments, + totalAlerts: 1, totalComment: 1, updated_by: { email: null, From eed7f560e939917dd40776f62b680bd9773153a1 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 4 Feb 2021 16:16:05 -0500 Subject: [PATCH 29/47] Adding more tests for sub cases --- .../api/cases/comments/find_comments.ts | 4 +- .../api/cases/sub_case/find_sub_cases.ts | 6 +- .../plugins/case/server/routes/api/index.ts | 9 ++ .../tests/cases/sub_cases/delete_sub_cases.ts | 4 +- .../tests/cases/sub_cases/find_sub_cases.ts | 102 ++++++++++++++++++ .../tests/cases/sub_cases/get_sub_case.ts | 4 +- .../case_api_integration/basic/tests/index.ts | 1 + .../case_api_integration/common/lib/mock.ts | 14 ++- .../case_api_integration/common/lib/utils.ts | 54 +++++++++- 9 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts 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 8ea604257679e..34de888809fa0 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 @@ -24,9 +24,7 @@ import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { getComments } from '../helpers'; - -const defaultPage = 1; -const defaultPerPage = 20; +import { defaultPage, defaultPerPage } from '../..'; const FindQueryParamsRt = rt.partial({ ...SavedObjectFindOptionsRt.props, 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 996c4eefcbeea..5b596707aefaa 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 @@ -22,6 +22,7 @@ import { RouteDeps } from '../../types'; import { escapeHatch, transformSubCases, wrapError } from '../../utils'; import { SUB_CASES_URL } from '../../../../../common/constants'; import { constructQueries, findSubCases, findSubCaseStatusStats } from '../helpers'; +import { defaultPage, defaultPerPage } from '../..'; export function initFindSubCasesApi({ caseService, router }: RouteDeps) { router.get( @@ -49,6 +50,8 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { caseService, options: { sortField: 'created_at', + page: defaultPage, + perPage: defaultPerPage, ...queryParams, }, }); @@ -72,7 +75,8 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { open, inProgress, closed, - total: subCases.subCasesMap.size, + // there should only be one entry in the map for the requested case ID + total: subCases.subCasesMap.get(request.params.case_id)?.length ?? 0, }) ), }); diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 0cd19b0e57d45..e6b1e771ca77b 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -36,6 +36,15 @@ import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; +/** + * Default page number when interacting with the saved objects API. + */ +export const defaultPage = 1; +/** + * Default number of results when interacting with the saved objects API. + */ +export const defaultPerPage = 20; + export function initCaseApi(deps: RouteDeps) { // Cases initDeleteCasesApi(deps); 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 3174ddfbc7dcc..87c004193281b 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 @@ -27,7 +27,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('should delete a sub case', async () => { - const caseInfo = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); expect(caseInfo.subCase?.id).to.not.eql(undefined); const { body: subCase } = await supertest @@ -51,7 +51,7 @@ export default function ({ getService }: FtrProviderContext) { }); it(`should delete a sub case's comments when that case gets deleted`, async () => { - const caseInfo = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); expect(caseInfo.subCase?.id).to.not.eql(undefined); // there should be two comments on the sub case now 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 new file mode 100644 index 0000000000000..3bb9e74585cd5 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts @@ -0,0 +1,102 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { findSubCasesResp, postCollectionReq } from '../../../../common/lib/mock'; +import { createSubCase, deleteAllCaseItems } from '../../../../common/lib/utils'; +import { getSubCasesUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { + CaseResponse, + CaseStatuses, + SubCasesFindResponse, +} from '../../../../../../plugins/case/common/api'; +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('find_sub_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should not find any sub cases when none exist', async () => { + const { body: caseResp }: { body: CaseResponse } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + const { body: findSubCases } = await supertest + .get(`${getSubCasesUrl(caseResp.id)}/_find`) + .expect(200); + + expect(findSubCases).to.eql({ + page: 1, + per_page: 20, + total: 0, + subCases: [], + count_open_cases: 0, + count_closed_cases: 0, + count_in_progress_cases: 0, + }); + }); + + it('should return a sub cases with comment stats', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 1, + // find should not return the comments themselves only the stats + subCases: [{ ...caseInfo.subCase!, comments: [], totalComment: 1, totalAlerts: 2 }], + count_open_cases: 1, + }); + }); + + it('should return multiple sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); + const subCase2Resp = await createSubCase({ supertest, caseID: caseInfo.id }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 2, + // find should not return the comments themselves only the stats + subCases: [ + { + // there should only be 1 closed sub case + ...subCase2Resp.modifiedSubCases![0], + comments: [], + totalComment: 1, + totalAlerts: 2, + status: CaseStatuses.closed, + }, + { + ...subCase2Resp.newSubCaseInfo.subCase, + comments: [], + totalComment: 1, + totalAlerts: 2, + }, + ], + count_open_cases: 1, + count_closed_cases: 1, + }); + }); + }); +}; 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 17df9007b42e4..82b7dd6983bc1 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 @@ -41,7 +41,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should return a case', async () => { - const caseInfo = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); const { body }: { body: SubCaseResponse } = await supertest .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) @@ -62,7 +62,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should return the correct number of alerts with multiple types of alerts', async () => { - const caseInfo = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest .post(getCaseCommentsUrl(caseInfo.id)) diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index 1a985a138374c..0343f4470013b 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -35,6 +35,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./connectors/case')); loadTestFile(require.resolve('./cases/sub_cases/delete_sub_cases')); loadTestFile(require.resolve('./cases/sub_cases/get_sub_case')); + loadTestFile(require.resolve('./cases/sub_cases/find_sub_cases')); // Migrations loadTestFile(require.resolve('./cases/migrations')); 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 29afa9c8f9215..b6e5e6d7cebfd 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -22,6 +22,7 @@ import { CommentRequest, AssociationType, CollectionWithSubCaseResponse, + SubCasesFindResponse, } from '../../../../plugins/case/common/api'; import { getAlertIds, @@ -214,12 +215,21 @@ export const removeServerGeneratedPropertiesFromComments = ( }); }; -export const findCasesResp: CasesFindResponse = { +const findCommon = { page: 1, per_page: 20, total: 0, - cases: [], count_open_cases: 0, count_closed_cases: 0, count_in_progress_cases: 0, }; + +export const findCasesResp: CasesFindResponse = { + ...findCommon, + cases: [], +}; + +export const findSubCasesResp: SubCasesFindResponse = { + ...findCommon, + subCases: [], +}; 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 5340dc855286a..8e41e9a12adf4 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { Client } from '@elastic/elasticsearch'; import * as st from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; -import { CASES_URL } from '../../../../plugins/case/common/constants'; +import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../plugins/case/common/constants'; import { CasesConfigureRequest, CasesConfigureResponse, @@ -18,8 +18,12 @@ import { CasePostRequest, CollectionWithSubCaseResponse, CommentRequestGeneratedAlertType, + SubCasesFindResponse, + CaseStatuses, + SubCasesResponse, } from '../../../../plugins/case/common/api'; import { postCollectionReq, postCommentGenAlertReq } from './mock'; +import { getSubCasesUrl } from '../../../../plugins/case/common/api/helpers'; /** * Variable to easily access the default comment for the createSubCase function. @@ -31,22 +35,39 @@ export const defaultCreateSubComment = postCommentGenAlertReq; */ export const defaultCreateSubPost = postCollectionReq; +interface CreateSubCaseResp { + newSubCaseInfo: CollectionWithSubCaseResponse; + modifiedSubCases?: SubCasesResponse; +} + /** * Creates a sub case using the actions API. If a caseID isn't passed in then it will create * the collection as well. To create a sub case a comment must be created so it uses a default * generated alert style comment which can be overridden. */ -export const createSubCase = async ({ +export const createSubCase = async (args: { + supertest: st.SuperTest; + comment?: CommentRequestGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; +}): Promise => { + return createSubCaseComment({ ...args, forceNewSubCase: true }); +}; + +export const createSubCaseComment = async ({ supertest, caseID, comment = defaultCreateSubComment, caseInfo = defaultCreateSubPost, + // if true it will close any open sub cases and force a new sub case to be opened + forceNewSubCase = false, }: { supertest: st.SuperTest; comment?: CommentRequestGeneratedAlertType; caseID?: string; caseInfo?: CasePostRequest; -}): Promise => { + forceNewSubCase?: boolean; +}): Promise => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -66,6 +87,31 @@ export const createSubCase = async ({ } else { collectionID = caseID; } + + let closedSubCases: SubCasesResponse | undefined; + if (forceNewSubCase) { + const { body: subCasesResp }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(collectionID)}/_find`) + .expect(200); + + if (subCasesResp.subCases.length > 0) { + // mark the sub case as closed so a new sub case will be created on the next comment + closedSubCases = ( + await supertest + .patch(SUB_CASES_PATCH_DEL_URL) + .set('kbn-xsrf', 'true') + .send({ + subCases: subCasesResp.subCases.map((subCase) => ({ + id: subCase.id, + version: subCase.version, + status: CaseStatuses.closed, + })), + }) + .expect(200) + ).body; + } + } + const caseConnector = await supertest .post(`/api/actions/action/${createdAction.id}/_execute`) .set('kbn-xsrf', 'foo') @@ -81,7 +127,7 @@ export const createSubCase = async ({ .expect(200); expect(caseConnector.body.status).to.eql('ok'); - return caseConnector.body.data; + return { newSubCaseInfo: caseConnector.body.data, modifiedSubCases: closedSubCases }; }; export const getConfiguration = ({ From acf20f4644055a5836852845a3aaa0b3c2f85703 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Fri, 5 Feb 2021 17:34:25 -0500 Subject: [PATCH 30/47] Find int tests, scoped client, patch sub user actions --- .../case/common/api/cases/user_actions.ts | 26 +- .../client/alerts/update_status.test.ts | 2 +- .../server/client/alerts/update_status.ts | 9 +- .../case/server/client/cases/update.ts | 299 ++++++++++-------- x-pack/plugins/case/server/client/client.ts | 13 +- .../case/server/client/comments/add.ts | 1 + .../plugins/case/server/client/index.test.ts | 12 +- x-pack/plugins/case/server/client/mocks.ts | 9 +- x-pack/plugins/case/server/client/types.ts | 9 +- .../case/server/connectors/case/index.ts | 4 +- x-pack/plugins/case/server/index.ts | 11 + .../routes/api/__fixtures__/route_contexts.ts | 8 +- .../api/cases/comments/delete_all_comments.ts | 6 +- .../api/cases/comments/delete_comment.ts | 6 +- .../api/cases/comments/get_all_comment.ts | 10 +- .../api/cases/comments/patch_comment.ts | 8 +- .../routes/api/cases/comments/post_comment.ts | 4 +- .../server/routes/api/cases/delete_cases.ts | 2 +- .../routes/api/cases/sub_case/get_sub_case.ts | 8 +- .../api/cases/sub_case/patch_sub_cases.ts | 62 ++-- .../case/server/services/alerts/index.test.ts | 12 +- .../case/server/services/alerts/index.ts | 14 +- .../server/services/user_actions/helpers.ts | 73 ++++- .../security_solution/server/plugin.ts | 33 +- .../basic/tests/cases/find_cases.ts | 131 +++++++- .../basic/tests/cases/patch_cases.ts | 28 +- .../tests/cases/sub_cases/delete_sub_cases.ts | 2 +- .../tests/cases/sub_cases/find_sub_cases.ts | 4 + .../tests/cases/sub_cases/get_sub_case.ts | 2 +- .../case_api_integration/common/lib/utils.ts | 38 ++- 30 files changed, 558 insertions(+), 288 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 8c912e48cb0cc..de9e88993df9a 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -12,19 +12,18 @@ import { UserRT } from '../user'; /* To the next developer, if you add/removed fields here * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too */ -const UserActionFieldRt = rt.array( - rt.union([ - rt.literal('comment'), - rt.literal('connector'), - rt.literal('description'), - rt.literal('pushed'), - rt.literal('tags'), - rt.literal('title'), - rt.literal('status'), - rt.literal('settings'), - rt.literal('sub_case'), - ]) -); +const UserActionFieldTypeRt = rt.union([ + rt.literal('comment'), + rt.literal('connector'), + rt.literal('description'), + rt.literal('pushed'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + rt.literal('settings'), + rt.literal('sub_case'), +]); +const UserActionFieldRt = rt.array(UserActionFieldTypeRt); const UserActionRt = rt.union([ rt.literal('add'), rt.literal('create'), @@ -61,3 +60,4 @@ export type CaseUserActionsResponse = rt.TypeOf; export type UserActionField = rt.TypeOf; +export type UserActionFieldType = rt.TypeOf; 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 39d6db85e5c4d..c8df1c8ab74f3 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 @@ -21,7 +21,7 @@ describe('updateAlertsStatus', () => { }); expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ - callCluster: expect.any(Function), + scopedClusterClient: 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 49143c0336460..a142079a7ccc8 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 { ILegacyScopedClusterClient } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import { CaseStatuses } from '../../../common/api'; import { AlertServiceContract } from '../../services'; @@ -14,8 +14,7 @@ interface UpdateAlertsStatusArgs { ids: string[]; status: CaseStatuses; indices: Set; - // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + scopedClusterClient: ElasticsearchClient; } // TODO: remove this file @@ -24,7 +23,7 @@ export const updateAlertsStatus = async ({ ids, status, indices, - callCluster, + scopedClusterClient, }: UpdateAlertsStatusArgs): Promise => { - await alertsService.updateAlertsStatus({ ids, status, indices, callCluster }); + await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient }); }; diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 41fbc2fb9200b..dabecf46ea981 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -10,7 +10,13 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { KibanaRequest, SavedObjectsClientContract, SavedObjectsFindResponse } from 'kibana/server'; +import { + KibanaRequest, + SavedObject, + SavedObjectsBulkResponse, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { @@ -24,6 +30,8 @@ import { CasesUpdateRequest, CasesUpdateRequestRt, CommentType, + ESCaseAttributes, + CaseType, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -35,6 +43,31 @@ import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { CaseClientImpl } from '..'; +/** + * Throws an error if any of the requests attempt to update a collection style cases' status field. + */ +function throwIfUpdateStatusOfCollection( + requests: ESCasePatchRequest[], + cases: SavedObjectsBulkResponse +) { + const casesMap = cases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + const requestsUpdatingStatusOfCollection = requests.filter( + (req) => + req.status !== undefined && casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingStatusOfCollection.length > 0) { + const ids = requestsUpdatingStatusOfCollection.map((req) => req.id); + throw Boom.badRequest( + `Updating the status of a collection is not allowed ids: [${ids.join(', ')}]` + ); + } +} + interface UpdateArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; @@ -107,147 +140,147 @@ export const update = async ({ return Object.keys(updateCaseAttributes).length > 0; }); - if (updateFilterCases.length > 0) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - 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, + if (updateFilterCases.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } + + throwIfUpdateStatusOfCollection(updateFilterCases, myCases); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + 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, + }; + }), + }); - // 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 + ); + }); + + for (const theCase of [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]) { + const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); + const totalComments = await caseService.getAllCaseComments({ + client: savedObjectsClient, + id: theCase.id, + options: { + fields: [], + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + page: 1, + perPage: 1, + }, }); - for (const theCase of [ - ...casesWithSyncSettingChangedToOn, - ...casesWithStatusChangedAndSynced, - ]) { - const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); - const totalComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - id: theCase.id, - options: { - fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - page: 1, - perPage: 1, - }, - }); + // TODO: double check that this logic will get all sub case comments and include them in the updates + const caseComments = (await caseService.getAllCaseComments({ + client: savedObjectsClient, + id: theCase.id, + includeSubCaseComments: true, + options: { + fields: [], + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + page: 1, + perPage: totalComments.total, + }, + // The filter guarantees that the comments will be of type alert + })) as SavedObjectsFindResponse<{ alertId: string | string[]; index: string }>; - // TODO: double check that this logic will get all sub case comments and include them in the updates - const caseComments = (await caseService.getAllCaseComments({ - client: savedObjectsClient, - id: theCase.id, - includeSubCaseComments: true, - options: { - fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - page: 1, - perPage: totalComments.total, - }, - // The filter guarantees that the comments will be of type alert - })) as SavedObjectsFindResponse<{ alertId: string | string[]; index: string }>; - - // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) - const idsAndIndices = caseComments.saved_objects.reduce( - (acc: { ids: string[]; indices: Set }, comment) => { - const alertId = comment.attributes.alertId; - const ids = Array.isArray(alertId) ? alertId : [alertId]; - acc.ids.push(...ids); - acc.indices.add(comment.attributes.index); - return acc; - }, - { ids: [], indices: new Set() } - ); - - if (idsAndIndices.ids.length > 0) { - caseClient.updateAlertsStatus({ - ids: idsAndIndices.ids, - // Either there is a status update or the syncAlerts got turned on. - status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, - indices: idsAndIndices.indices, - }); - } - } + // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) + const idsAndIndices = caseComments.saved_objects.reduce( + (acc: { ids: string[]; indices: Set }, comment) => { + const alertId = comment.attributes.alertId; + const ids = Array.isArray(alertId) ? alertId : [alertId]; + acc.ids.push(...ids); + acc.indices.add(comment.attributes.index); + return acc; + }, + { ids: [], indices: new Set() } + ); - 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, - }, - }); + if (idsAndIndices.ids.length > 0) { + caseClient.updateAlertsStatus({ + ids: idsAndIndices.ids, + // Either there is a status update or the syncAlerts got turned on. + status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, + indices: idsAndIndices.indices, }); + } + } - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: buildCaseUserActions({ - originalCases: myCases.saved_objects, - updatedCases: updatedCases.saved_objects, - actionDate: updatedDt, - actionBy: { email, full_name, username }, - }), + 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, + }, + }); }); - return CasesResponseRt.encode(returnUpdatedCase); - } - throw Boom.notAcceptable('All update fields are identical to current version.'); + 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); }; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index 2c2a5af3930bc..5dfd819b7311c 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -10,11 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - ILegacyScopedClusterClient, - KibanaRequest, - SavedObjectsClientContract, -} from 'src/core/server'; +import { ElasticsearchClient, KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; import { CaseClientFactoryArguments, CaseClient, @@ -46,8 +42,7 @@ import { // TODO: rename export class CaseClientImpl implements CaseClient { - // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think - private readonly _callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + private readonly _scopedClusterClient: ElasticsearchClient; private readonly _caseConfigureService: CaseConfigureServiceSetup; private readonly _caseService: CaseServiceSetup; private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; @@ -58,7 +53,7 @@ export class CaseClientImpl implements CaseClient { // TODO: refactor so these are created in the constructor instead of passed in constructor(clientArgs: CaseClientFactoryArguments) { - this._callCluster = clientArgs.callCluster; + this._scopedClusterClient = clientArgs.scopedClusterClient; this._caseConfigureService = clientArgs.caseConfigureService; this._caseService = clientArgs.caseService; this._connectorMappingsService = clientArgs.connectorMappingsService; @@ -149,7 +144,7 @@ export class CaseClientImpl implements CaseClient { return updateAlertsStatus({ ...args, alertsService: this._alertsService, - callCluster: this._callCluster, + scopedClusterClient: this._scopedClusterClient, }); } } diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index b23b38b25082d..134954d38e5d8 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -47,6 +47,7 @@ async function getSubCase({ return mostRecentSubCase; } + // TODO: add action for sub_case creation // else need to create a new sub case return caseService.createSubCase(savedObjectsClient, createdAt, caseId); } diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index c92defbd8893f..1545bc6b1249f 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -6,10 +6,10 @@ */ import { KibanaRequest } from 'kibana/server'; -// TODO: fix this -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, +} from '../../../../../src/core/server/mocks'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -22,7 +22,7 @@ jest.mock('./client'); import { CaseClientImpl } from './client'; import { createExternalCaseClient } from './index'; -const esLegacyCluster = legacyClientMock.createScopedClusterClient(); +const esClient = elasticsearchServiceMock.createElasticsearchClient(); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); @@ -34,7 +34,7 @@ const userActionService = createUserActionServiceMock(); describe('createExternalCaseClient()', () => { test('it creates the client correctly', async () => { createExternalCaseClient({ - callCluster: esLegacyCluster.callAsCurrentUser, + scopedClusterClient: esClient, alertsService, caseConfigureService, caseService, diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 8b8fd0a9b7140..4d0cf751d5bbe 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -6,10 +6,7 @@ */ import { KibanaRequest } from 'kibana/server'; -// TODO: fix this -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { actionsClientMock } from '../../../actions/server/mocks'; import { AlertServiceContract, @@ -48,7 +45,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ alertsService: jest.Mocked; }; }> => { - const esLegacyCluster = legacyClientMock.createScopedClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); @@ -77,7 +74,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ connectorMappingsService, userActionService, alertsService, - callCluster: esLegacyCluster.callAsCurrentUser, + scopedClusterClient: esClient, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 1ac78fad46dea..bb259aacfc238 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,11 +5,7 @@ * 2.0. */ -import { - ILegacyScopedClusterClient, - KibanaRequest, - SavedObjectsClientContract, -} from 'kibana/server'; +import { ElasticsearchClient, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CaseClientPostRequest, @@ -58,8 +54,7 @@ export interface CaseClientUpdateAlertsStatus { } export interface CaseClientFactoryArguments { - // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + scopedClusterClient: ElasticsearchClient; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index f5ba4cec112a1..1078f305f5e31 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -68,12 +68,12 @@ async function executor( const { subAction, subActionParams } = params; let data: CaseExecutorResponse | null = null; - const { savedObjectsClient, callCluster } = services; + const { savedObjectsClient, scopedClusterClient } = services; // TODO: ??? calling a constructor in a curry generates this error, TypeError: _client.CaseClientImpl is not a constructor // const caseClient = new CaseClientImpl({ const caseClient = createExternalCaseClient({ savedObjectsClient, - callCluster, + scopedClusterClient, // TODO: refactor this request: {} as KibanaRequest, caseService, diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts index 9fdc62c0f4ab0..8af07edeb252a 100644 --- a/x-pack/plugins/case/server/index.ts +++ b/x-pack/plugins/case/server/index.ts @@ -13,3 +13,14 @@ export { CaseRequestContext } from './types'; export const config = { schema: ConfigSchema }; export const plugin = (initializerContext: PluginInitializerContext) => new CasePlugin(initializerContext); + +/** + * Remove these once the security solution no longer has to access them when registering its plugin + */ +export { + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + CASE_CONFIGURE_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, +} from './saved_object_types'; 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 64902cfeaeff5..08ccb85694f7b 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 @@ -6,9 +6,7 @@ */ import { KibanaRequest } from 'src/core/server'; -import { loggingSystemMock } from 'src/core/server/mocks'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; import { actionsClientMock } from '../../../../../actions/server/mocks'; import { createExternalCaseClient } from '../../../client'; import { @@ -27,7 +25,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); const log = loggingSystemMock.create().get('case'); - const esLegacyCluster = legacyClientMock.createScopedClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); const caseService = new CaseService( log, @@ -69,7 +67,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { getUserActions: jest.fn(), }, alertsService, - callCluster: esLegacyCluster.callAsCurrentUser, + scopedClusterClient: esClient, }); return context; 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 5e07024d1989b..bae6bf46cd2ad 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 @@ -24,7 +24,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic }), query: schema.maybe( schema.object({ - sub_case_id: schema.maybe(schema.string()), + subCaseID: schema.maybe(schema.string()), }) ), }, @@ -36,12 +36,12 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic const { username, full_name, email } = await caseService.getUser({ request, response }); const deleteDate = new Date().toISOString(); - const id = request.query?.sub_case_id ?? request.params.case_id; + const id = request.query?.subCaseID ?? request.params.case_id; const comments = await getComments({ client, caseService, id, - associationType: request.query?.sub_case_id + associationType: request.query?.subCaseID ? AssociationType.subCase : AssociationType.case, }); 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 bb697be985f07..b0bb98fa25b91 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({ - sub_case_id: 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?.sub_case_id ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - const id = request.query?.sub_case_id ?? 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)) { 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 f5870f931bffb..f0c40ccb862d9 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 @@ -23,8 +23,8 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { }), query: schema.maybe( schema.object({ - include_sub_case_comments: schema.maybe(schema.boolean()), - sub_case_id: schema.maybe(schema.string()), + includeSubCaseComments: schema.maybe(schema.boolean()), + subCaseID: schema.maybe(schema.string()), }) ), }, @@ -34,16 +34,16 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { const client = context.core.savedObjects.client; let comments: SavedObjectsFindResponse; - if (request.query?.sub_case_id) { + if (request.query?.subCaseID) { comments = await caseService.getAllSubCaseComments({ client, - id: request.query.sub_case_id, + id: request.query.subCaseID, }); } else { comments = await caseService.getAllCaseComments({ client, id: request.params.case_id, - includeSubCaseComments: request.query?.include_sub_case_comments, + includeSubCaseComments: request.query?.includeSubCaseComments, }); } 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 5a11f6728b37c..c6ecdac6e4fa3 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 @@ -66,7 +66,7 @@ export function initPatchCommentApi({ }), query: schema.maybe( schema.object({ - sub_case_id: 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?.sub_case_id, + 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?.sub_case_id ? 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)) { @@ -147,7 +147,7 @@ export function initPatchCommentApi({ actionAt: updatedDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, - subCaseId: request.query?.sub_case_id, + 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 394f161af4fdd..95b611950bd41 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({ - sub_case_id: 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?.sub_case_id ?? 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/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index 829f7b10b8407..1cf770874d94d 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 @@ -150,7 +150,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, - fields: ['comment', 'description', 'status', 'tags', 'title'], + fields: ['comment', 'description', 'status', 'tags', 'title', 'sub_case'], }) ), }); 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 b6d9a7345dbdd..87b9e15bc1dec 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 @@ -20,7 +20,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { validate: { params: schema.object({ case_id: schema.string(), - sub_case_id: schema.string(), + subCaseID: schema.string(), }), query: schema.object({ includeComments: schema.boolean({ defaultValue: true }), @@ -34,7 +34,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { const subCase = await caseService.getSubCase({ client, - id: request.params.sub_case_id, + id: request.params.subCaseID, }); if (!includeComments) { @@ -49,7 +49,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { const theComments = await caseService.getAllSubCaseComments({ client, - id: request.params.sub_case_id, + id: request.params.subCaseID, options: { sortField: 'created_at', sortOrder: 'asc', @@ -64,7 +64,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { totalComment: theComments.total, totalAlerts: countAlertsForID({ comments: theComments, - id: request.params.sub_case_id, + id: request.params.subCaseID, }), }) ), 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 d40b6538bbf19..3acf56c31a20b 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 @@ -32,6 +32,11 @@ import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; import { getCaseToUpdate } from '../helpers'; +import { UserInfo } from '../../../../common'; +import { + buildCaseUserActions, + buildSubCaseUserActions, +} from '../../../../services/user_actions/helpers'; interface UpdateArgs { client: SavedObjectsClientContract; @@ -145,6 +150,25 @@ async function getParentCases({ }, new Map>()); } +function getValidUpdateRequests( + toUpdate: SubCasePatchRequest[], + subCasesMap: Map> +): SubCasePatchRequest[] { + const validatedSubCaseAttributes: SubCasePatchRequest[] = toUpdate.map((updateCase) => { + const currentCase = subCasesMap.get(updateCase.id); + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...updateCase, + }) + : { id: updateCase.id, version: updateCase.version }; + }); + + return validatedSubCaseAttributes.filter((updateCase: SubCasePatchRequest) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); +} + async function update({ client, caseService, @@ -170,22 +194,7 @@ async function update({ checkNonExistingOrConflict(query.subCases, subCasesMap); - // TODO: extract to new function - const validatedSubCaseAttributes: SubCasePatchRequest[] = query.subCases.map((updateCase) => { - const currentCase = subCasesMap.get(updateCase.id); - return currentCase != null - ? getCaseToUpdate(currentCase.attributes, { - ...updateCase, - }) - : { id: updateCase.id, version: updateCase.version }; - }); - - const nonEmptySubCaseRequests = validatedSubCaseAttributes.filter( - (updateCase: SubCasePatchRequest) => { - const { id, version, ...updateCaseAttributes } = updateCase; - return Object.keys(updateCaseAttributes).length > 0; - } - ); + const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); if (nonEmptySubCaseRequests.length <= 0) { throw Boom.notAcceptable('All update fields are identical to current version.'); @@ -206,8 +215,11 @@ async function update({ client, subCases: nonEmptySubCaseRequests.map((thisCase) => { const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; - // TODO: type this - let closedInfo = {}; + let closedInfo: { closed_at: string | null; closed_by: UserInfo | null } = { + closed_at: null, + closed_by: null, + }; + if ( updateSubCaseAttributes.status && updateSubCaseAttributes.status === CaseStatuses.closed @@ -307,16 +319,16 @@ async function update({ [] ); - // TODO: need to implement one for sub case - /* await userActionService.postUserActions({ + // TODO: figure out what we need to save + await userActionService.postUserActions({ client, - actions: buildCaseUserActions({ - originalCases: bulkSubCases.saved_objects, - updatedCases: updatedCases.saved_objects, - actionDate: updatedDt, + actions: buildSubCaseUserActions({ + originalSubCases: bulkSubCases.saved_objects, + updatedSubCases: updatedCases.saved_objects, + actionDate: updatedAt, actionBy: { email, full_name, username }, }), - });*/ + }); return SubCasesResponseRt.encode(returnUpdatedSubCases); } 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 64348409ac452..e898345cb1b14 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -8,12 +8,10 @@ import { KibanaRequest } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; -// TODO: need to fix this -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { legacyClientMock } from 'src/core/server/elasticsearch/legacy/mocks'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; describe('updateAlertsStatus', () => { - const esLegacyCluster = legacyClientMock.createScopedClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('happy path', () => { let alertService: AlertServiceContract; @@ -22,7 +20,7 @@ describe('updateAlertsStatus', () => { indices: new Set(['.siem-signals']), request: {} as KibanaRequest, status: CaseStatuses.closed, - callCluster: esLegacyCluster.callAsCurrentUser, + scopedClusterClient: esClient, }; beforeEach(async () => { @@ -33,7 +31,7 @@ describe('updateAlertsStatus', () => { test('it update the status of the alert correctly', async () => { await alertService.updateAlertsStatus(args); - expect(esLegacyCluster.callAsCurrentUser).toHaveBeenCalledWith('updateByQuery', { + expect(esClient.updateByQuery).toHaveBeenCalledWith({ body: { query: { ids: { values: args.ids } }, script: { lang: 'painless', source: `ctx._source.signal.status = '${args.status}'` }, @@ -51,7 +49,7 @@ describe('updateAlertsStatus', () => { ids: ['alert-id-1'], status: CaseStatuses.closed, indices: new Set(['']), - callCluster: esLegacyCluster.callAsCurrentUser, + scopedClusterClient: esClient, }); }).rejects.toThrow(); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 62a31050e94ef..4e04b48ef114e 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -7,7 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ILegacyScopedClusterClient } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; export type AlertServiceContract = PublicMethodsOf; @@ -16,14 +16,18 @@ interface UpdateAlertsStatusArgs { ids: string[]; status: CaseStatuses; indices: Set; - // TODO: we have to use the one that the actions API gives us which is deprecated, but we'll need it updated there first I think - callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; + scopedClusterClient: ElasticsearchClient; } export class AlertService { constructor() {} - public async updateAlertsStatus({ ids, status, indices, callCluster }: UpdateAlertsStatusArgs) { + public async updateAlertsStatus({ + ids, + status, + indices, + scopedClusterClient, + }: UpdateAlertsStatusArgs) { /** * remove empty strings from the indices, I'm not sure how likely this is but in the case that * the document doesn't have _index set the security_solution code sets the value to an empty string @@ -35,7 +39,7 @@ export class AlertService { } // The above check makes sure that esClient is defined. - const result = await callCluster('updateByQuery', { + const result = await scopedClusterClient.updateByQuery({ index: sanitizedIndices, conflicts: 'abort', body: { diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 68e60f97db5cf..72ec127d52c78 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -15,6 +15,8 @@ import { UserActionField, ESCaseAttributes, User, + UserActionFieldType, + SubCaseAttributes, } from '../../../common/api'; import { isTwoArraysDifference, @@ -159,16 +161,23 @@ const userActionFieldsAllowed: UserActionField = [ 'sub_case', ]; -export const buildCaseUserActions = ({ +const buildGenericCaseUserActions = ({ actionDate, actionBy, originalCases, updatedCases, + allowedFields, + getField, }: { actionDate: string; actionBy: User; - originalCases: Array>; - updatedCases: Array>; + originalCases: Array>; + updatedCases: Array>; + allowedFields: UserActionField; + getField: ( + attributes: Pick, 'attributes'>, + field: UserActionFieldType + ) => unknown; }): UserActionItem[] => updatedCases.reduce((acc, updatedItem) => { const originalItem = originalCases.find((oItem) => oItem.id === updatedItem.id); @@ -176,16 +185,9 @@ export const buildCaseUserActions = ({ let userActions: UserActionItem[] = []; const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach((field) => { - if (userActionFieldsAllowed.includes(field)) { - const origValue = - field === 'connector' && originalItem.attributes.connector - ? transformESConnectorToCaseConnector(originalItem.attributes.connector) - : get(originalItem, ['attributes', field]); - - const updatedValue = - field === 'connector' && updatedItem.attributes.connector - ? transformESConnectorToCaseConnector(updatedItem.attributes.connector) - : get(updatedItem, ['attributes', field]); + if (allowedFields.includes(field)) { + const origValue = getField(originalItem, field); + const updatedValue = getField(updatedItem, field); if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) { userActions = [ @@ -253,3 +255,48 @@ export const buildCaseUserActions = ({ } return acc; }, []); + +/** + * Create a user action for an updated sub case. + */ +export const buildSubCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalSubCases: Array>; + updatedSubCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => get(so, ['attributes', field]); + + return buildGenericCaseUserActions({ + actionDate: args.actionDate, + actionBy: args.actionBy, + originalCases: args.originalSubCases, + updatedCases: args.updatedSubCases, + allowedFields: ['status'], + getField, + }); +}; + +/** + * Create a user action for an updated case. + */ +export const buildCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => { + return field === 'connector' && so.attributes.connector + ? transformESConnectorToCaseConnector(so.attributes.connector) + : get(so, ['attributes', field]); + }; + + return buildGenericCaseUserActions({ ...args, allowedFields: userActionFieldsAllowed, getField }); +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 0d5d83582b42b..2e023142bc043 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -77,6 +77,13 @@ import { } from '../../../../src/plugins/telemetry/server'; import { licenseService } from './lib/license/license'; import { PolicyWatcher } from './endpoint/lib/policy/license_watch'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_CONFIGURE_SAVED_OBJECT, + CASE_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../case/server'; export interface SetupPlugins { alerts: AlertingSetup; @@ -117,6 +124,14 @@ const securitySubPlugins = [ `${APP_ID}:${SecurityPageName.administration}`, ]; +const caseSavedObjects = [ + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + CASE_CONFIGURE_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, +]; + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config$: Observable; @@ -216,14 +231,7 @@ export class Plugin implements IPlugin { @@ -18,9 +24,7 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('find_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should return empty response', async () => { @@ -242,6 +246,123 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.count_in_progress_cases).to.eql(1); }); + describe('stats with sub cases', () => { + let collection: CreateSubCaseResp; + beforeEach(async () => { + collection = await createSubCase({ supertest }); + + const [, , { body: toCloseCase }] = await Promise.all([ + setStatus({ + supertest, + cases: [ + { + id: collection.newSubCaseInfo.subCase!.id, + version: collection.newSubCaseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + ]); + + await setStatus({ + supertest, + cases: [ + { + id: toCloseCase.id, + version: toCloseCase.version, + status: CaseStatuses.closed, + }, + ], + type: 'case', + }); + }); + it('correctly counts stats without using a filter', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for open cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&status=open`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for individual cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.individual}`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id }); + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}`) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection and open cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id }); + const { body }: { body: CasesFindResponse } = await supertest + .get( + `${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}&status=${CaseStatuses.open}` + ) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(1); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + 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}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + }); + it('unhappy path - 400s when bad query supplied', async () => { await supertest .get(`${CASES_URL}/_find?perPage=true`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index 1b51ec9ba1171..fc268b9322032 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -15,9 +15,10 @@ import { defaultUser, postCaseReq, postCaseResp, + postCollectionReq, removeServerGeneratedPropertiesFromCase, } from '../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions } from '../../../common/lib/utils'; +import { deleteAllCaseItems } from '../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -38,8 +39,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('patch_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should patch a case', async () => { @@ -127,6 +127,28 @@ export default ({ getService }: FtrProviderContext): void => { .expect(404); }); + it("should 400 when attempting to update a collection case's status", async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(400); + }); + it('unhappy path - 406s when excess data sent', async () => { const { body: postedCase } = await supertest .post(CASES_URL) 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 87c004193281b..3d36226b53a33 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 @@ -60,7 +60,7 @@ export default function ({ getService }: FtrProviderContext) { }: { body: CollectionWithSubCaseResponse } = await supertest .post(`${CASES_URL}/${caseInfo.id}/comments`) .set('kbn-xsrf', 'true') - .query({ sub_case_id: caseInfo.subCase!.id }) + .query({ subCaseID: caseInfo.subCase!.id }) .send(postCommentUserReq) .expect(200); 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 3bb9e74585cd5..1bc1195b0d964 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 @@ -98,5 +98,9 @@ export default ({ getService }: FtrProviderContext): void => { count_closed_cases: 1, }); }); + + // TODO: + // tests for sorting on status, sort order, sort field + // tests for in-progress stats }); }; 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 82b7dd6983bc1..a8c0903053d06 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 @@ -66,7 +66,7 @@ export default ({ getService }: FtrProviderContext): void => { const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest .post(getCaseCommentsUrl(caseInfo.id)) - .query({ sub_case_id: caseInfo.subCase!.id }) + .query({ subCaseID: caseInfo.subCase!.id }) .set('kbn-xsrf', 'true') .send(postCommentAlertReq) .expect(200); 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 8e41e9a12adf4..eb19051e3a8fb 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -21,10 +21,36 @@ import { SubCasesFindResponse, CaseStatuses, SubCasesResponse, + CasesResponse, } from '../../../../plugins/case/common/api'; import { postCollectionReq, postCommentGenAlertReq } from './mock'; import { getSubCasesUrl } from '../../../../plugins/case/common/api/helpers'; +interface SetStatusCasesParams { + id: string; + version: string; + status: CaseStatuses; +} + +export const setStatus = async ({ + supertest, + cases, + type, +}: { + supertest: st.SuperTest; + cases: SetStatusCasesParams[]; + type: 'case' | 'sub_case'; +}): Promise => { + const url = type === 'case' ? CASES_URL : SUB_CASES_PATCH_DEL_URL; + const patchFields = type === 'case' ? { cases } : { subCases: cases }; + const { body }: { body: CasesResponse | SubCasesResponse } = await supertest + .patch(url) + .set('kbn-xsrf', 'true') + .send(patchFields) + .expect(200); + return body; +}; + /** * Variable to easily access the default comment for the createSubCase function. */ @@ -35,7 +61,10 @@ export const defaultCreateSubComment = postCommentGenAlertReq; */ export const defaultCreateSubPost = postCollectionReq; -interface CreateSubCaseResp { +/** + * Response structure for the createSubCase and createSubCaseComment functions. + */ +export interface CreateSubCaseResp { newSubCaseInfo: CollectionWithSubCaseResponse; modifiedSubCases?: SubCasesResponse; } @@ -94,14 +123,17 @@ export const createSubCaseComment = async ({ .get(`${getSubCasesUrl(collectionID)}/_find`) .expect(200); - if (subCasesResp.subCases.length > 0) { + const nonClosed = subCasesResp.subCases.filter( + (subCase) => subCase.status !== CaseStatuses.closed + ); + if (nonClosed.length > 0) { // mark the sub case as closed so a new sub case will be created on the next comment closedSubCases = ( await supertest .patch(SUB_CASES_PATCH_DEL_URL) .set('kbn-xsrf', 'true') .send({ - subCases: subCasesResp.subCases.map((subCase) => ({ + subCases: nonClosed.map((subCase) => ({ id: subCase.id, version: subCase.version, status: CaseStatuses.closed, From ee0fea68aa7521ef3742c81a5044431674035ae0 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Fri, 5 Feb 2021 22:38:37 -0500 Subject: [PATCH 31/47] fixing types and call cluster --- x-pack/plugins/case/server/index.ts | 11 ----------- x-pack/plugins/case/server/plugin.ts | 4 ++-- .../api/cases/sub_case/patch_sub_cases.ts | 5 +---- .../plugins/security_solution/server/plugin.ts | 17 +++++------------ 4 files changed, 8 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts index 8af07edeb252a..9fdc62c0f4ab0 100644 --- a/x-pack/plugins/case/server/index.ts +++ b/x-pack/plugins/case/server/index.ts @@ -13,14 +13,3 @@ export { CaseRequestContext } from './types'; export const config = { schema: ConfigSchema }; export const plugin = (initializerContext: PluginInitializerContext) => new CasePlugin(initializerContext); - -/** - * Remove these once the security solution no longer has to access them when registering its plugin - */ -export { - CASE_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, - CASE_COMMENT_SAVED_OBJECT, - CASE_CONFIGURE_SAVED_OBJECT, - CASE_USER_ACTION_SAVED_OBJECT, -} from './saved_object_types'; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 388f9ec5667d5..7e087cce810fc 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -129,7 +129,7 @@ export class CasePlugin { request: KibanaRequest ) => { return createExternalCaseClient({ - callCluster: context.core.elasticsearch.legacy.client.callAsCurrentUser, + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: core.savedObjects.getScopedClient(request), request, caseService: this.caseService!, @@ -170,7 +170,7 @@ export class CasePlugin { return { getCaseClient: () => { return new CaseClientImpl({ - callCluster: context.core.elasticsearch.legacy.client.callAsCurrentUser, + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), caseService, caseConfigureService, 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 3acf56c31a20b..7a69c0e8a2482 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 @@ -33,10 +33,7 @@ import { RouteDeps } from '../../types'; import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; import { getCaseToUpdate } from '../helpers'; import { UserInfo } from '../../../../common'; -import { - buildCaseUserActions, - buildSubCaseUserActions, -} from '../../../../services/user_actions/helpers'; +import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; interface UpdateArgs { client: SavedObjectsClientContract; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2e023142bc043..6076510f3879f 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -77,13 +77,6 @@ import { } from '../../../../src/plugins/telemetry/server'; import { licenseService } from './lib/license/license'; import { PolicyWatcher } from './endpoint/lib/policy/license_watch'; -import { - CASE_COMMENT_SAVED_OBJECT, - CASE_CONFIGURE_SAVED_OBJECT, - CASE_SAVED_OBJECT, - CASE_USER_ACTION_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, -} from '../../case/server'; export interface SetupPlugins { alerts: AlertingSetup; @@ -125,11 +118,11 @@ const securitySubPlugins = [ ]; const caseSavedObjects = [ - CASE_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, - CASE_COMMENT_SAVED_OBJECT, - CASE_CONFIGURE_SAVED_OBJECT, - CASE_USER_ACTION_SAVED_OBJECT, + 'cases', + 'cases-comments', + 'cases-sub-case', + 'cases-configure', + 'cases-user-actions', ]; export class Plugin implements IPlugin { From a4458d1f4c18ce2604141edc3a919fdfe3df1996 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Sat, 6 Feb 2021 09:22:09 -0500 Subject: [PATCH 32/47] fixing get sub case param issue --- .../case/server/routes/api/cases/sub_case/get_sub_case.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 87b9e15bc1dec..b6d9a7345dbdd 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 @@ -20,7 +20,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { validate: { params: schema.object({ case_id: schema.string(), - subCaseID: schema.string(), + sub_case_id: schema.string(), }), query: schema.object({ includeComments: schema.boolean({ defaultValue: true }), @@ -34,7 +34,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { const subCase = await caseService.getSubCase({ client, - id: request.params.subCaseID, + id: request.params.sub_case_id, }); if (!includeComments) { @@ -49,7 +49,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { const theComments = await caseService.getAllSubCaseComments({ client, - id: request.params.subCaseID, + id: request.params.sub_case_id, options: { sortField: 'created_at', sortOrder: 'asc', @@ -64,7 +64,7 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { totalComment: theComments.total, totalAlerts: countAlertsForID({ comments: theComments, - id: request.params.subCaseID, + id: request.params.sub_case_id, }), }) ), From e2a020aa5807c83e530697dc366214a18ca9733e Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Sat, 6 Feb 2021 11:27:11 -0500 Subject: [PATCH 33/47] Adding user actions for sub cases --- .../case/server/client/comments/add.ts | 70 +++--- .../server/common/models/commentable_case.ts | 201 +++++++++++------- .../api/cases/comments/patch_comment.ts | 19 +- .../server/services/user_actions/helpers.ts | 71 +++++-- 4 files changed, 235 insertions(+), 126 deletions(-) diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 134954d38e5d8..a5241d66c6768 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -25,7 +25,10 @@ import { ContextTypeGeneratedAlertRt, CommentRequestGeneratedAlertType, } from '../../../common/api'; -import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { + buildCaseUserActionItem, + buildCommentUserActionItem, +} from '../../services/user_actions/helpers'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CommentableCase, UserInfo } from '../../common'; @@ -36,20 +39,37 @@ async function getSubCase({ savedObjectsClient, caseId, createdAt, + userActionService, + user, }: { caseService: CaseServiceSetup; savedObjectsClient: SavedObjectsClientContract; caseId: string; createdAt: string; + userActionService: CaseUserActionServiceSetup; + user: UserInfo; }): Promise> { const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { return mostRecentSubCase; } - // TODO: add action for sub_case creation - // else need to create a new sub case - return caseService.createSubCase(savedObjectsClient, createdAt, caseId); + const newSubCase = await caseService.createSubCase(savedObjectsClient, createdAt, caseId); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdAt, + actionBy: user, + caseId, + subCaseId: newSubCase.id, + fields: ['status', 'sub_case'], + newValue: JSON.stringify({ status: newSubCase.attributes.status }), + }), + ], + }); + return newSubCase; } interface AddCommentFromRuleArgs { @@ -89,11 +109,19 @@ const addGeneratedAlerts = async ({ throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); } + const userDetails: UserInfo = { + username: caseInfo.attributes.created_by?.username, + full_name: caseInfo.attributes.created_by?.full_name, + email: caseInfo.attributes.created_by?.email, + }; + const subCase = await getSubCase({ caseService, savedObjectsClient, caseId, createdAt: createdDate, + userActionService, + user: userDetails, }); const commentableCase = new CommentableCase({ @@ -103,16 +131,10 @@ const addGeneratedAlerts = async ({ service: caseService, }); - const userDetails: UserInfo = { - username: caseInfo.attributes.created_by?.username, - full_name: caseInfo.attributes.created_by?.full_name, - email: caseInfo.attributes.created_by?.email, - }; - - const [newComment, updatedCase] = await Promise.all([ - commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }), - commentableCase.update({ date: createdDate, user: userDetails }), - ]); + const { + comment: newComment, + commentableCase: updatedCase, + } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); if ( (newComment.attributes.type === CommentType.alert || @@ -134,7 +156,8 @@ const addGeneratedAlerts = async ({ action: 'create', actionAt: createdDate, actionBy: { ...userDetails }, - caseId: subCase.id, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, commentId: newComment.id, fields: ['comment'], newValue: JSON.stringify(query), @@ -238,15 +261,11 @@ export const addComment = async ({ email, }; - const [newComment, updatedCase] = await Promise.all([ - combinedCase.createComment({ createdDate, user: userInfo, commentReq: query }), - // This will return a full new CombinedCase object that has the updated and base fields - // merged together so let's use the return value from now on - combinedCase.update({ - date: createdDate, - 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); @@ -264,7 +283,8 @@ export const addComment = async ({ action: 'create', actionAt: createdDate, actionBy: { username, full_name, email }, - caseId: updatedCase.id, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, commentId: newComment.id, fields: ['comment'], newValue: JSON.stringify(query), 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 928c89d2d9a57..8864b9fb68c5f 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -33,6 +33,16 @@ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_typ import { CaseServiceSetup } from '../../services'; import { countAlertsForID, UserInfo } from '../index'; +interface UpdateCommentResp { + comment: SavedObjectsUpdateResponse; + commentableCase: CommentableCase; +} + +interface NewCommentResp { + comment: SavedObject; + commentableCase: CommentableCase; +} + interface CommentableCaseParams { collection: SavedObject; subCase?: SavedObject; @@ -60,18 +70,35 @@ export class CommentableCase { return this.subCase?.attributes.status ?? this.collection.attributes.status; } + /** + * This property is used to abstract away which element is actually being acted upon in this class. + * If the sub case was initialized then it will be the focus of creating comments. So if you want the id + * of the saved object that the comment is primarily being attached to use this property. + * + * This is a little confusing because the created comment will have references to both the sub case and the + * collection but from the UI's perspective only the sub case really has the comment attached to it. + */ public get id(): string { return this.subCase?.id ?? this.collection.id; } - public get version(): string | undefined { - return this.subCase?.version ?? this.collection.version; - } - public get settings(): CaseSettings { return this.collection.attributes.settings; } + /** + * These functions break the abstraction of this class but they are needed to build the comment user action item. + * Another potential solution would be to implement another function that handles creating the user action in this + * class so that we don't need to expose these properties. + */ + public get caseId(): string { + return this.collection.id; + } + + public get subCaseId(): string | undefined { + return this.subCase?.id; + } + private buildRefsToCase(): SavedObjectReference[] { const subCaseSOType = SUB_CASE_SAVED_OBJECT; const caseSOType = CASE_SAVED_OBJECT; @@ -87,6 +114,61 @@ export class CommentableCase { ]; } + private async update({ date, user }: { date: string; user: UserInfo }): Promise { + 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, + }; + } + + 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, + }); + } + + /** + * Update a comment and update the corresponding case's update_at and updated_by fields. + */ public async updateComment({ updateRequest, updatedAt, @@ -95,21 +177,31 @@ export class CommentableCase { updateRequest: CommentPatchRequest; updatedAt: string; user: UserInfo; - }): Promise> { + }): Promise { const { id, version, ...queryRestAttributes } = updateRequest; - return this.service.patchComment({ - client: this.soClient, - commentId: id, - updatedAttributes: { - ...queryRestAttributes, - updated_at: updatedAt, - updated_by: user, - }, - version, - }); + 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, + }; } + /** + * Create a new comment on the appropriate case. This updates the case's updated_at and updated_by fields. + */ public async createComment({ createdDate, user, @@ -118,17 +210,24 @@ export class CommentableCase { createdDate: string; user: UserInfo; commentReq: CommentRequest; - }): Promise> { - return this.service.postNewComment({ - client: this.soClient, - attributes: transformNewComment({ - associationType: this.subCase ? AssociationType.subCase : AssociationType.case, - createdDate, - ...commentReq, - ...user, + }): Promise { + 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, + }; } private formatCollectionForEncoding(totalComment: number) { @@ -184,56 +283,4 @@ export class CommentableCase { ...this.formatCollectionForEncoding(collectionCommentStats.total), }); } - - public async update({ date, user }: { date: string; user: UserInfo }): Promise { - 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, - }; - } - - 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, - }); - } } 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 c6ecdac6e4fa3..81ffaadcdcc92 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 @@ -127,17 +127,14 @@ export function initPatchCommentApi({ }; const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase] = await Promise.all([ - commentableCase.updateComment({ - updateRequest: query, - updatedAt: updatedDate, - user: userInfo, - }), - commentableCase.update({ - date: updatedDate, - user: { username, full_name, email }, - }), - ]); + const { + comment: updatedComment, + commentableCase: updatedCase, + } = await commentableCase.updateComment({ + updateRequest: query, + updatedAt: updatedDate, + user: userInfo, + }); await userActionService.postUserActions({ client, diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 72ec127d52c78..c600a96234b3d 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -161,25 +161,46 @@ const userActionFieldsAllowed: UserActionField = [ 'sub_case', ]; +interface CaseSubIDs { + caseId: string; + subCaseId?: string; +} + +type GetCaseAndSubID = (so: SavedObjectsUpdateResponse) => CaseSubIDs; +type GetField = ( + attributes: Pick, 'attributes'>, + field: UserActionFieldType +) => unknown; + +/** + * Abstraction functions to retrieve a given field and the caseId and subCaseId depending on + * whether we're interacting with a case or a sub case. + */ +interface Getters { + getField: GetField; + getCaseAndSubID: GetCaseAndSubID; +} + const buildGenericCaseUserActions = ({ actionDate, actionBy, originalCases, updatedCases, allowedFields, - getField, + getters, }: { actionDate: string; actionBy: User; originalCases: Array>; updatedCases: Array>; allowedFields: UserActionField; - getField: ( - attributes: Pick, 'attributes'>, - field: UserActionFieldType - ) => unknown; -}): UserActionItem[] => - updatedCases.reduce((acc, updatedItem) => { + getters: Getters; +}): UserActionItem[] => { + const { getCaseAndSubID, getField } = getters; + return updatedCases.reduce((acc, updatedItem) => { + const { caseId, subCaseId } = getCaseAndSubID(updatedItem); + // regardless of whether we're looking at a sub case or case, the id field will always be used to match between + // the original and the updated saved object const originalItem = originalCases.find((oItem) => oItem.id === updatedItem.id); if (originalItem != null) { let userActions: UserActionItem[] = []; @@ -196,7 +217,8 @@ const buildGenericCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: updatedValue, oldValue: origValue, @@ -211,7 +233,8 @@ const buildGenericCaseUserActions = ({ action: 'add', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.addedItems.join(', '), }), @@ -225,7 +248,8 @@ const buildGenericCaseUserActions = ({ action: 'delete', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.deletedItems.join(', '), }), @@ -242,7 +266,8 @@ const buildGenericCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: JSON.stringify(updatedValue), oldValue: JSON.stringify(origValue), @@ -255,6 +280,7 @@ const buildGenericCaseUserActions = ({ } return acc; }, []); +}; /** * Create a user action for an updated sub case. @@ -270,13 +296,23 @@ export const buildSubCaseUserActions = (args: { field: UserActionFieldType ) => get(so, ['attributes', field]); + const getCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + const caseId = so.references?.find((ref) => ref.type === CASE_SAVED_OBJECT)?.id ?? ''; + return { caseId, subCaseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID, + }; + return buildGenericCaseUserActions({ actionDate: args.actionDate, actionBy: args.actionBy, originalCases: args.originalSubCases, updatedCases: args.updatedSubCases, allowedFields: ['status'], - getField, + getters, }); }; @@ -298,5 +334,14 @@ export const buildCaseUserActions = (args: { : get(so, ['attributes', field]); }; - return buildGenericCaseUserActions({ ...args, allowedFields: userActionFieldsAllowed, getField }); + const caseGetIds: GetCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + return { caseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID: caseGetIds, + }; + + return buildGenericCaseUserActions({ ...args, allowedFields: userActionFieldsAllowed, getters }); }; From dfccb2e6c64bec8ba283011969e99fd2eb201465 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Sun, 7 Feb 2021 17:56:37 -0500 Subject: [PATCH 34/47] Preventing alerts on collections and refactoring user --- .../plugins/case/common/api/cases/sub_case.ts | 1 + .../case/server/client/comments/add.ts | 21 +++++----- x-pack/plugins/case/server/common/index.ts | 1 - .../server/common/models/commentable_case.ts | 22 +++++++++-- x-pack/plugins/case/server/common/types.ts | 12 ------ x-pack/plugins/case/server/common/utils.ts | 2 + .../api/cases/comments/find_comments.ts | 3 +- .../api/cases/comments/patch_comment.ts | 6 +-- .../api/cases/sub_case/patch_sub_cases.ts | 4 +- .../plugins/case/server/routes/api/utils.ts | 11 +++++- .../server/saved_object_types/sub_case.ts | 13 +++++++ x-pack/plugins/case/server/services/index.ts | 38 +++++++++++++------ .../tests/cases/comments/post_comment.ts | 15 ++++++++ .../case_api_integration/common/lib/mock.ts | 3 +- 14 files changed, 103 insertions(+), 49 deletions(-) delete mode 100644 x-pack/plugins/case/server/common/types.ts diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts index 0284b6184669e..4a0ecdd3176f0 100644 --- a/x-pack/plugins/case/common/api/cases/sub_case.ts +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -24,6 +24,7 @@ export const SubCaseAttributesRt = rt.intersection([ closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, + created_by: rt.union([UserRT, rt.null]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index a5241d66c6768..5b7c409ea51e5 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -24,6 +24,7 @@ import { CollectionWithSubCaseResponse, ContextTypeGeneratedAlertRt, CommentRequestGeneratedAlertType, + User, } from '../../../common/api'; import { buildCaseUserActionItem, @@ -31,7 +32,7 @@ import { } from '../../services/user_actions/helpers'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CommentableCase, UserInfo } from '../../common'; +import { CommentableCase } from '../../common'; import { CaseClientImpl } from '..'; async function getSubCase({ @@ -47,14 +48,19 @@ async function getSubCase({ caseId: string; createdAt: string; userActionService: CaseUserActionServiceSetup; - user: UserInfo; + user: User; }): Promise> { const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { return mostRecentSubCase; } - const newSubCase = await caseService.createSubCase(savedObjectsClient, createdAt, caseId); + const newSubCase = await caseService.createSubCase({ + client: savedObjectsClient, + createdAt, + caseId, + createdBy: user, + }); await userActionService.postUserActions({ client: savedObjectsClient, actions: [ @@ -109,7 +115,7 @@ const addGeneratedAlerts = async ({ throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); } - const userDetails: UserInfo = { + const userDetails: User = { username: caseInfo.attributes.created_by?.username, full_name: caseInfo.attributes.created_by?.full_name, email: caseInfo.attributes.created_by?.email, @@ -248,14 +254,9 @@ export const addComment = async ({ const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); - // An alert cannot be attach to a closed case. - if (query.type === CommentType.alert && combinedCase.status === CaseStatuses.closed) { - throw Boom.badRequest('Alert cannot be attached to a closed case'); - } - // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); - const userInfo: UserInfo = { + const userInfo: User = { username, full_name, email, diff --git a/x-pack/plugins/case/server/common/index.ts b/x-pack/plugins/case/server/common/index.ts index b07ed5d4ae2d6..0960b28b3d25a 100644 --- a/x-pack/plugins/case/server/common/index.ts +++ b/x-pack/plugins/case/server/common/index.ts @@ -7,4 +7,3 @@ export * from './models'; export * from './utils'; -export * from './types'; 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 8864b9fb68c5f..9827118ee8e29 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import Boom from '@hapi/boom'; import { SavedObject, @@ -15,13 +16,16 @@ import { AssociationType, CaseSettings, CaseStatuses, + CaseType, CollectionWithSubCaseResponse, CollectWithSubCaseResponseRt, CommentAttributes, CommentPatchRequest, CommentRequest, + CommentType, ESCaseAttributes, SubCaseAttributes, + User, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; import { @@ -31,7 +35,7 @@ import { } from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; -import { countAlertsForID, UserInfo } from '../index'; +import { countAlertsForID } from '../index'; interface UpdateCommentResp { comment: SavedObjectsUpdateResponse; @@ -114,7 +118,7 @@ export class CommentableCase { ]; } - private async update({ date, user }: { date: string; user: UserInfo }): Promise { + private async update({ date, user }: { date: string; user: User }): Promise { let updatedSubCaseAttributes: SavedObject | undefined; if (this.subCase) { @@ -176,7 +180,7 @@ export class CommentableCase { }: { updateRequest: CommentPatchRequest; updatedAt: string; - user: UserInfo; + user: User; }): Promise { const { id, version, ...queryRestAttributes } = updateRequest; @@ -208,9 +212,19 @@ export class CommentableCase { commentReq, }: { createdDate: string; - user: UserInfo; + 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'); + } + + 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, diff --git a/x-pack/plugins/case/server/common/types.ts b/x-pack/plugins/case/server/common/types.ts deleted file mode 100644 index 4435877865fb3..0000000000000 --- a/x-pack/plugins/case/server/common/types.ts +++ /dev/null @@ -1,12 +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. - */ - -export interface UserInfo { - username: string | null | undefined; - full_name: string | null | undefined; - email: string | null | undefined; -} diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index 203bfda9da07a..cba89c9fb74ca 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -8,6 +8,8 @@ import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; import { CommentAttributes, CommentType } from '../../common/api'; +export const defaultSortField = 'created_at'; + // TODO: write unit tests for these function export const countAlerts = (comment: SavedObjectsFindResult) => { let totalAlerts = 0; 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 34de888809fa0..47c00f510c485 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 @@ -63,8 +63,8 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { // still override this behavior. page: defaultPage, perPage: defaultPerPage, - ...query, sortField: 'created_at', + ...query, }, associationType, } @@ -75,6 +75,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { options: { page: defaultPage, perPage: defaultPerPage, + sortField: 'created_at', }, associationType, }; 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 81ffaadcdcc92..f7e2f580b5928 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 @@ -13,8 +13,8 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; import { SavedObjectsClientContract } from 'kibana/server'; -import { CommentableCase, UserInfo } from '../../../../common'; -import { CommentPatchRequestRt, throwErrors } from '../../../../../common/api'; +import { CommentableCase } from '../../../../common'; +import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; @@ -120,7 +120,7 @@ export function initPatchCommentApi({ // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request, response }); - const userInfo: UserInfo = { + const userInfo: User = { username, full_name, email, 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 7a69c0e8a2482..6067fdef409a6 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 @@ -27,12 +27,12 @@ import { ESCaseAttributes, SubCaseResponse, SubCasesResponseRt, + User, } from '../../../../../common/api'; import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; import { getCaseToUpdate } from '../helpers'; -import { UserInfo } from '../../../../common'; import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; interface UpdateArgs { @@ -212,7 +212,7 @@ async function update({ client, subCases: nonEmptySubCaseRequests.map((thisCase) => { const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; - let closedInfo: { closed_at: string | null; closed_by: UserInfo | null } = { + let closedInfo: { closed_at: string | null; closed_by: User | null } = { closed_at: null, closed_by: null, }; diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index a94b25115161f..cb159420242cf 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -42,6 +42,7 @@ import { ContextTypeGeneratedAlertRt, SubCasesFindResponse, AttributesTypeAlerts, + User, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -49,12 +50,18 @@ import { SortFieldCase, TotalCommentByCase } from './types'; // TODO: refactor these functions to a common location, this is used by the caseClient too -// TODO: maybe inline this -export const transformNewSubCase = (createdAt: string): SubCaseAttributes => { +export const transformNewSubCase = ({ + createdAt, + createdBy, +}: { + createdAt: string; + createdBy: User; +}): SubCaseAttributes => { return { closed_at: null, closed_by: null, created_at: createdAt, + created_by: createdBy, status: CaseStatuses.open, updated_at: null, updated_by: null, diff --git a/x-pack/plugins/case/server/saved_object_types/sub_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts index c66d2eb6a7818..da89b19346e4e 100644 --- a/x-pack/plugins/case/server/saved_object_types/sub_case.ts +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -34,6 +34,19 @@ export const subCaseSavedObjectType: SavedObjectsType = { created_at: { type: 'date', }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, status: { type: 'keyword', }, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 585eaa8e1d31a..08f0f29ffcb2b 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -28,6 +28,7 @@ import { SubCaseAttributes, AssociationType, } from '../../common/api'; +import { defaultSortField } from '../common'; import { combineFilters } from '../routes/api/cases/helpers'; import { transformNewSubCase } from '../routes/api/utils'; import { @@ -99,6 +100,12 @@ interface PostCaseArgs extends ClientArgs { attributes: ESCaseAttributes; } +interface CreateSubCaseArgs extends ClientArgs { + createdAt: string; + caseId: string; + createdBy: User; +} + interface PostCommentArgs extends ClientArgs { attributes: CommentAttributes; references: SavedObjectReference[]; @@ -178,11 +185,7 @@ export interface CaseServiceSetup { client: SavedObjectsClientContract, caseId: string ): Promise | undefined>; - createSubCase( - client: SavedObjectsClientContract, - createdAt: string, - caseId: string - ): Promise>; + createSubCase(args: CreateSubCaseArgs): Promise>; patchSubCase(args: PatchSubCase): Promise>; patchSubCases(args: PatchSubCases): Promise>; } @@ -193,14 +196,15 @@ export class CaseService implements CaseServiceSetup { private readonly authentication?: SecurityPluginSetup['authc'] ) {} - public async createSubCase( - client: SavedObjectsClientContract, - createdAt: string, - caseId: string - ): Promise> { + public async createSubCase({ + client, + createdAt, + caseId, + createdBy, + }: CreateSubCaseArgs): Promise> { try { this.log.debug(`Attempting to POST a new sub case`); - return client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase(createdAt), { + return client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase({ createdAt, createdBy }), { references: [ { type: CASE_SAVED_OBJECT, @@ -332,7 +336,11 @@ export class CaseService implements CaseServiceSetup { }: FindCasesArgs): Promise> { try { this.log.debug(`Attempting to find cases`); - return await client.find({ ...options, type: CASE_SAVED_OBJECT }); + return await client.find({ + sortField: defaultSortField, + ...options, + type: CASE_SAVED_OBJECT, + }); } catch (error) { this.log.debug(`Error on find cases: ${error}`); throw error; @@ -349,6 +357,7 @@ export class CaseService implements CaseServiceSetup { // grab all sub cases if (options?.page !== undefined || options?.perPage !== undefined) { return client.find({ + sortField: defaultSortField, ...options, type: SUB_CASE_SAVED_OBJECT, }); @@ -358,12 +367,14 @@ export class CaseService implements CaseServiceSetup { fields: [], page: 1, perPage: 1, + sortField: defaultSortField, ...options, type: SUB_CASE_SAVED_OBJECT, }); return client.find({ page: 1, perPage: stats.total, + sortField: defaultSortField, ...options, type: SUB_CASE_SAVED_OBJECT, }); @@ -424,6 +435,7 @@ export class CaseService implements CaseServiceSetup { if (options?.page !== undefined || options?.perPage !== undefined) { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, + sortField: defaultSortField, ...options, }); } @@ -433,6 +445,7 @@ export class CaseService implements CaseServiceSetup { fields: [], page: 1, perPage: 1, + sortField: defaultSortField, // spread the options after so the caller can override the default behavior if they want ...options, }); @@ -441,6 +454,7 @@ export class CaseService implements CaseServiceSetup { type: CASE_COMMENT_SAVED_OBJECT, page: 1, perPage: stats.total, + sortField: defaultSortField, ...options, }); } catch (error) { 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 087eb79dde7d2..6fa8077290678 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 @@ -17,6 +17,7 @@ import { postCaseReq, postCommentUserReq, postCommentAlertReq, + postCollectionReq, } from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; import { @@ -209,6 +210,20 @@ export default ({ getService }: FtrProviderContext): void => { .expect(400); }); + it('400s when adding an alert to a collection case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(400); + }); + describe('alerts', () => { beforeEach(async () => { await esArchiver.load('auditbeat/hosts'); 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 b6e5e6d7cebfd..a4320c28526b3 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -155,8 +155,7 @@ export const subCaseResp = ({ totalAlerts, totalComment, closed_by: null, - // TODO: add this - // created_by: defaultUser, + created_by: defaultUser, updated_by: defaultUser, }); From 9903a51159880532ac705a66927e324c1d3b2a01 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Sun, 7 Feb 2021 18:56:05 -0500 Subject: [PATCH 35/47] Allowing type to be updated for ind cases --- x-pack/plugins/case/common/api/cases/case.ts | 21 +--- .../case/server/client/cases/update.ts | 97 ++++++++++++++++--- x-pack/plugins/case/server/client/client.ts | 26 +---- .../basic/tests/cases/patch_cases.ts | 82 +++++++++++++++- 4 files changed, 171 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 41aeecfe04435..c20b0837c1c31 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -25,20 +25,16 @@ const SettingsRt = rt.type({ syncAlerts: rt.boolean, }); -const CaseBasicNoTypeRt = rt.type({ +const CaseBasicRt = rt.type({ description: rt.string, status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + type: CaseTypeRt, connector: CaseConnectorRt, settings: SettingsRt, }); -const CaseBasicRt = rt.type({ - ...CaseBasicNoTypeRt.props, - type: CaseTypeRt, -}); - const CaseExternalServiceBasicRt = rt.type({ connector_id: rt.string, connector_name: rt.string, @@ -140,23 +136,12 @@ export const CasesFindResponseRt = rt.intersection([ ]); export const CasePatchRequestRt = rt.intersection([ - rt.partial(CaseBasicNoTypeRt.props), - rt.type({ id: rt.string, version: rt.string }), -]); - -/** - * This is so the convert request can just pass the request along to the internal - * update functionality. We don't want to expose the type field in the API request though - * so users can't change a collection back to a normal case. - */ -export const CaseUpdateRequestRt = rt.intersection([ rt.partial(CaseBasicRt.props), rt.type({ id: rt.string, version: rt.string }), ]); export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); -export const CasesUpdateRequestRt = rt.type({ cases: rt.array(CaseUpdateRequestRt) }); export type CaseAttributes = rt.TypeOf; /** @@ -171,8 +156,6 @@ export type CasesResponse = rt.TypeOf; export type CasesFindRequest = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; -// The update request is different from the patch request in that it allow updating the type field -export type CasesUpdateRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CaseExternalServiceRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index dabecf46ea981..bb853b5039648 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -13,7 +13,6 @@ import { identity } from 'fp-ts/lib/function'; import { KibanaRequest, SavedObject, - SavedObjectsBulkResponse, SavedObjectsClientContract, SavedObjectsFindResponse, } from 'kibana/server'; @@ -27,11 +26,11 @@ import { CasePatchRequest, CasesResponse, CaseStatuses, - CasesUpdateRequest, - CasesUpdateRequestRt, + CasesPatchRequestRt, CommentType, ESCaseAttributes, CaseType, + CasesPatchRequest, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -48,13 +47,8 @@ import { CaseClientImpl } from '..'; */ function throwIfUpdateStatusOfCollection( requests: ESCasePatchRequest[], - cases: SavedObjectsBulkResponse + casesMap: Map> ) { - const casesMap = cases.saved_objects.reduce((acc, so) => { - acc.set(so.id, so); - return acc; - }, new Map>()); - const requestsUpdatingStatusOfCollection = requests.filter( (req) => req.status !== undefined && casesMap.get(req.id)?.attributes.type === CaseType.collection @@ -68,13 +62,81 @@ function throwIfUpdateStatusOfCollection( } } +/** + * Throws an error if any of the requests attempt to update a collection style case to an individual one. + */ +function throwIfUpdateTypeCollectionToIndividual( + requests: ESCasePatchRequest[], + casesMap: Map> +) { + const requestsUpdatingTypeCollectionToInd = requests.filter( + (req) => + req.type === CaseType.individual && + casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingTypeCollectionToInd.length > 0) { + const ids = requestsUpdatingTypeCollectionToInd.map((req) => req.id); + throw Boom.badRequest( + `Converting a collection to an individual case is not allowed ids: [${ids.join(', ')}]` + ); + } +} + +/** + * Throws an error if any of the requests attempt to update an individual style cases' type field to a collection + * when alerts are attached to the case. + */ +async function throwIfInvalidUpdateOfTypeWithAlerts({ + requests, + caseService, + client, +}: { + requests: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}) { + const getAlertsForID = async (caseToUpdate: ESCasePatchRequest) => { + const alerts = await caseService.getAllCaseComments({ + client, + id: caseToUpdate.id, + options: { + fields: [], + // there should never be generated alerts attached to an individual case but we'll check anyway + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + page: 1, + perPage: 1, + }, + }); + + return { id: caseToUpdate.id, alerts }; + }; + + const requestsUpdatingTypeField = requests.filter((req) => req.type === CaseType.collection); + const casesAlertTotals = await Promise.all( + requestsUpdatingTypeField.map((caseToUpdate) => getAlertsForID(caseToUpdate)) + ); + + // grab the cases that have at least one alert comment attached to them + const typeUpdateWithAlerts = casesAlertTotals.filter((caseInfo) => caseInfo.alerts.total > 0); + + if (typeUpdateWithAlerts.length > 0) { + const ids = typeUpdateWithAlerts.map((req) => req.id); + throw Boom.badRequest( + `Converting a case to a collection is not allowed when it has alert comments, ids: [${ids.join( + ', ' + )}]` + ); + } +} + interface UpdateArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; request: KibanaRequest; caseClient: CaseClientImpl; - cases: CasesUpdateRequest; + cases: CasesPatchRequest; } export const update = async ({ @@ -86,7 +148,7 @@ export const update = async ({ cases, }: UpdateArgs): Promise => { const query = pipe( - excess(CasesUpdateRequestRt).decode(cases), + excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) ); @@ -144,7 +206,18 @@ export const update = async ({ throw Boom.notAcceptable('All update fields are identical to current version.'); } - throwIfUpdateStatusOfCollection(updateFilterCases, myCases); + 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 } = await caseService.getUser({ request }); diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index 5dfd819b7311c..2d8277913f09d 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -5,11 +5,6 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - import { ElasticsearchClient, KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; import { CaseClientFactoryArguments, @@ -32,13 +27,7 @@ import { CaseUserActionServiceSetup, AlertServiceContract, } from '../services'; -import { - CasesPatchRequest, - CasesPatchRequestRt, - CasePostRequest, - excess, - throwErrors, -} from '../../common/api'; +import { CasesPatchRequest, CasePostRequest } from '../../common/api'; // TODO: rename export class CaseClientImpl implements CaseClient { @@ -95,22 +84,13 @@ export class CaseClientImpl implements CaseClient { }); } - /** - * This enforces the restriction of not changing the case type field - * @param args requested cases to be updated - */ - public async update(args: CasesPatchRequest) { - const validatedCases = pipe( - excess(CasesPatchRequestRt).decode(args), - fold(throwErrors(Boom.badRequest), identity) - ); - + public async update(cases: CasesPatchRequest) { return update({ savedObjectsClient: this._savedObjectsClient, caseService: this._caseService, userActionService: this._userActionService, request: this.request, - cases: validatedCases, + cases, caseClient: this, }); } diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index fc268b9322032..dcc49152e4db8 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -10,12 +10,14 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../plugins/case/common/api'; +import { CaseType, CommentType } from '../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCaseResp, postCollectionReq, + postCommentAlertReq, + postCommentUserReq, removeServerGeneratedPropertiesFromCase, } from '../../../common/lib/mock'; import { deleteAllCaseItems } from '../../../common/lib/utils'; @@ -127,6 +129,84 @@ export default ({ getService }: FtrProviderContext): void => { .expect(404); }); + it('should 400 and not allow converting a collection back to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + type: CaseType.individual, + }, + ], + }) + .expect(400); + }); + + it('should allow converting an individual case to a collection when it does not have alerts', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(200); + }); + + it('should 400 when attempting to update an individual case to a collection when it has alerts attached to it', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(400); + }); + it("should 400 when attempting to update a collection case's status", async () => { const { body: postedCase } = await supertest .post(CASES_URL) From 1441a762ed84ffa3ee0fbe1f1c91b578382df00b Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Mon, 8 Feb 2021 13:29:27 -0500 Subject: [PATCH 36/47] Refactoring and writing tests --- x-pack/plugins/case/server/client/client.ts | 20 - .../plugins/case/server/common/utils.test.ts | 235 +++++++++++ x-pack/plugins/case/server/common/utils.ts | 32 +- .../api/cases/comments/delete_all_comments.ts | 4 +- .../api/cases/comments/find_comments.ts | 3 +- .../server/routes/api/cases/find_cases.ts | 114 +---- .../case/server/routes/api/cases/helpers.ts | 323 +------------- .../routes/api/cases/status/get_status.ts | 7 +- .../api/cases/sub_case/find_sub_cases.ts | 10 +- .../case/server/routes/api/utils.test.ts | 158 ------- .../plugins/case/server/routes/api/utils.ts | 18 +- .../case/server/saved_object_types/cases.ts | 2 +- x-pack/plugins/case/server/services/index.ts | 394 +++++++++++++++++- x-pack/plugins/case/server/services/mocks.ts | 6 + 14 files changed, 689 insertions(+), 637 deletions(-) create mode 100644 x-pack/plugins/case/server/common/utils.test.ts diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index 2d8277913f09d..45d0638d9394b 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -53,26 +53,6 @@ export class CaseClientImpl implements CaseClient { this._alertsService = clientArgs.alertsService; } - public get caseService(): CaseServiceSetup { - return this._caseService; - } - - public get caseConfigureService(): CaseConfigureServiceSetup { - return this._caseConfigureService; - } - - public get connectorMappingsService(): ConnectorMappingsServiceSetup { - return this._connectorMappingsService; - } - - public get userActionService(): CaseUserActionServiceSetup { - return this._userActionService; - } - - public get alertsService(): AlertServiceContract { - return this._alertsService; - } - public async create(caseInfo: CasePostRequest) { return create({ savedObjectsClient: this._savedObjectsClient, diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts new file mode 100644 index 0000000000000..cf1d6e3c91762 --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -0,0 +1,235 @@ +/* + * 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 { SavedObjectsFindResponse } from 'kibana/server'; +import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { transformNewComment } from '../routes/api/utils'; +import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; + +interface CommentReference { + ids: string[]; + comments: CommentRequest[]; +} + +function createCommentFindResponse( + commentRequests: CommentReference[] +): SavedObjectsFindResponse { + const resp: SavedObjectsFindResponse = { + page: 0, + per_page: 0, + total: 0, + saved_objects: [], + }; + + for (const { ids, comments } of commentRequests) { + for (const id of ids) { + for (const comment of comments) { + resp.saved_objects.push({ + id: '', + references: [{ id, type: '', name: '' }], + score: 0, + type: '', + attributes: transformNewComment({ + ...comment, + associationType: AssociationType.case, + createdDate: '', + }), + }); + } + } + } + + return resp; +} + +describe('common utils', () => { + describe('combineFilters', () => { + it("creates a filter string with two values and'd together", () => { + expect(combineFilters(['a', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('creates a filter string with three values or together', () => { + expect(combineFilters(['a', 'b', 'c'], 'OR')).toBe('(a OR b OR c)'); + }); + + it('ignores empty strings', () => { + expect(combineFilters(['', 'a', '', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('returns an empty string if all filters are empty strings', () => { + expect(combineFilters(['', ''], 'OR')).toBe(''); + }); + + it('returns an empty string if the filters are undefined', () => { + expect(combineFilters(undefined, 'OR')).toBe(''); + }); + + it('returns a value without parenthesis when only a single filter is provided', () => { + expect(combineFilters(['a'], 'OR')).toBe('a'); + }); + + it('returns a string without parenthesis when only a single non empty filter is provided', () => { + expect(combineFilters(['', ''], 'AND')).toBe(''); + }); + }); + + describe('countAlerts', () => { + it('returns 0 when no alerts are found', () => { + expect( + countAlerts( + createCommentFindResponse([ + { ids: ['1'], comments: [{ comment: '', type: CommentType.user }] }, + ]).saved_objects[0] + ) + ).toBe(0); + }); + + it('returns 3 alerts for a single generated alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alerts: [{ _id: 'a' }, { _id: 'b' }, { _id: 'c' }], + index: '', + type: CommentType.generatedAlert, + }, + ], + }, + ]).saved_objects[0] + ) + ); + }); + + it('returns 3 alerts for a single alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b', 'c'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]).saved_objects[0] + ) + ); + }); + }); + + describe('groupTotalAlertsByID', () => { + it('returns a map with one entry and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([['1', 2]]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 0 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + { + ids: ['2'], + comments: [ + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 0], + ]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 2], + ]) + ); + }); + }); + + describe('countAlertsForID', () => { + it('returns 2 alerts for id 1 when the map has multiple entries', () => { + expect( + countAlertsForID({ + id: '1', + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual(2); + }); + }); +}); diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index cba89c9fb74ca..a05278d67d885 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -8,9 +8,36 @@ import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; import { CommentAttributes, CommentType } from '../../common/api'; +/** + * Default sort field for querying saved objects. + */ export const defaultSortField = 'created_at'; -// TODO: write unit tests for these function +/** + * Combines multiple filter expressions using the specified operator and parenthesis if multiple expressions exist. + * This will ignore empty string filters. If a single valid filter is found it will not wrap in parenthesis. + * + * @param filters an array of filters to combine using the specified operator + * @param operator AND or OR + */ +export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { + const noEmptyStrings = filters?.filter((value) => value !== ''); + const joinedExp = noEmptyStrings?.join(` ${operator} `); + // if undefined or an empty string + if (!joinedExp) { + return ''; + } else if ((noEmptyStrings?.length ?? 0) > 1) { + // if there were multiple filters, wrap them in () + return `(${joinedExp})`; + } else { + // return a single value not wrapped in () + return joinedExp; + } +}; + +/** + * Counts the total alert IDs within a single comment. + */ export const countAlerts = (comment: SavedObjectsFindResult) => { let totalAlerts = 0; if ( @@ -52,6 +79,9 @@ export const groupTotalAlertsByID = ({ }, new Map()); }; +/** + * Counts the total alert IDs for a single case or sub case ID. + */ export const countAlertsForID = ({ comments, id, 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 bae6bf46cd2ad..2713c77f794c7 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 @@ -11,7 +11,6 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { getComments } from '../helpers'; import { AssociationType } from '../../../../../common/api'; export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { @@ -37,9 +36,8 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic const deleteDate = new Date().toISOString(); const id = request.query?.subCaseID ?? request.params.case_id; - const comments = await getComments({ + const comments = await caseService.getCommentsByAssociation({ client, - caseService, id, associationType: request.query?.subCaseID ? AssociationType.subCase 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 47c00f510c485..ab0110d8cd8b0 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 @@ -23,7 +23,6 @@ import { import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { getComments } from '../helpers'; import { defaultPage, defaultPerPage } from '../..'; const FindQueryParamsRt = rt.partial({ @@ -80,7 +79,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { associationType, }; - const theComments = await getComments(args); + const theComments = await caseService.getCommentsByAssociation(args); return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); } catch (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 470a9f905a265..8ba83b42c06d7 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 @@ -11,116 +11,16 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsClientContract, SavedObjectsFindResult } from 'kibana/server'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors, caseStatuses, - SubCaseResponse, - ESCaseAttributes, - CaseType, - SavedObjectFindOptions, - CaseResponse, - AssociationType, } from '../../../../common/api'; -import { transformCases, wrapError, escapeHatch, flattenCaseSavedObject } from '../utils'; +import { transformCases, wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; -import { CaseServiceSetup } from '../../../services'; -import { - constructQueries, - findCaseStatusStats, - findSubCases, - getCaseCommentStats, -} from './helpers'; - -interface Collection { - case: SavedObjectsFindResult; - subCases?: SubCaseResponse[]; -} - -interface CasesMapWithPageInfo { - casesMap: Map; - page: number; - perPage: number; -} - -/** - * Returns a map of all cases combined with their sub cases if they are collections. - */ -async function findCases({ - client, - caseOptions, - subCaseOptions, - caseService, -}: { - client: SavedObjectsClientContract; - caseOptions: SavedObjectFindOptions; - subCaseOptions?: SavedObjectFindOptions; - caseService: CaseServiceSetup; -}): Promise { - const cases = await caseService.findCases({ - client, - options: caseOptions, - }); - - const subCasesResp = await findSubCases({ - client, - options: subCaseOptions, - caseService, - ids: cases.saved_objects - .filter((caseInfo) => caseInfo.type === CaseType.collection) - .map((caseInfo) => caseInfo.id), - }); - const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { - const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); - - /** - * This will include empty collections unless the query explicitly requested type === CaseType.individual, in which - * case we'd not have any collections anyway. - */ - accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); - return accMap; - }, new Map()); - - /** - * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases - * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case - * and the parent. The associationType field allows us to determine which type of case the comment is attached to. - * - * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. - * Once we have it we can build the maps. - * - * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) - * in another request (the one below this comment). - */ - const totalCommentsForCases = await getCaseCommentStats({ - client, - caseService, - ids: Array.from(casesMap.keys()), - associationType: AssociationType.case, - }); - - const casesWithComments = new Map(); - for (const [id, caseInfo] of casesMap.entries()) { - casesWithComments.set( - id, - flattenCaseSavedObject({ - savedObject: caseInfo.case, - totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, - totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, - subCases: caseInfo.subCases, - }) - ); - } - - return { - casesMap: casesWithComments, - page: cases.page, - perPage: cases.per_page, - }; -} +import { constructQueryOptions } from './helpers'; export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( @@ -146,23 +46,21 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: caseType: queryParams.type, }; - const caseQueries = constructQueries(queryArgs); + const caseQueries = constructQueryOptions(queryArgs); - const cases = await findCases({ + const cases = await caseService.findCasesGroupedByID({ client, caseOptions: { ...queryParams, ...caseQueries.case }, subCaseOptions: caseQueries.subCase, - caseService, }); const [openCases, inProgressCases, closedCases] = await Promise.all([ ...caseStatuses.map((status) => { - const statusQuery = constructQueries({ ...queryArgs, status }); - return findCaseStatusStats({ + const statusQuery = constructQueryOptions({ ...queryArgs, status }); + return caseService.findCaseStatusStats({ client, caseOptions: statusQuery.case, subCaseOptions: statusQuery.subCase, - caseService, }); }), ]); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index c09820d61ce7c..ad43d6d33c072 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -8,11 +8,7 @@ import { get, isPlainObject } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { - SavedObjectsClientContract, - SavedObjectsFindResponse, - SavedObjectsFindResult, -} from 'kibana/server'; +import { SavedObjectsFindResponse } from 'kibana/server'; import { CaseConnector, ESCaseConnector, @@ -21,36 +17,13 @@ import { CaseStatuses, CaseType, SavedObjectFindOptions, - CommentType, - SubCaseResponse, - SubCaseAttributes, - AssociationType, } from '../../../../common/api'; import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; -import { - CASE_COMMENT_SAVED_OBJECT, - CASE_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, -} from '../../../saved_object_types'; -import { flattenSubCaseSavedObject, sortToSnake } from '../utils'; -import { CaseServiceSetup } from '../../../services'; -import { groupTotalAlertsByID } from '../../../common'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { sortToSnake } from '../utils'; +import { combineFilters } from '../../../common'; // TODO: write unit tests for these functions -export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { - const noEmptyStrings = filters?.filter((value) => value !== ''); - const joinedExp = noEmptyStrings?.join(` ${operator} `); - // if undefined or an empty string - if (!joinedExp) { - return ''; - } else if ((noEmptyStrings?.length ?? 0) > 1) { - // if there were multiple filters, wrap them in () - return `(${joinedExp})`; - } else { - // return a single value not wrapped in () - return joinedExp; - } -}; export const addStatusFilter = ({ status, @@ -96,284 +69,6 @@ export const buildFilter = ({ ); }; -/** - * Calculates the number of sub cases for a given set of options for a set of case IDs. - */ -export const findSubCaseStatusStats = async ({ - client, - options, - caseService, - ids, -}: { - client: SavedObjectsClientContract; - options: SavedObjectFindOptions; - caseService: CaseServiceSetup; - ids: string[]; -}): Promise => { - const subCases = await caseService.findSubCases({ - client, - options: { - ...options, - page: 1, - perPage: 1, - fields: [], - hasReference: ids.map((id) => { - return { - id, - type: CASE_SAVED_OBJECT, - }; - }), - }, - }); - - return subCases.total; -}; - -// TODO: move to the service layer -/** - * Retrieves the number of cases that exist with a given status (open, closed, etc). - * This also counts sub cases. Parent cases are excluded from the statistics. - */ -export const findCaseStatusStats = async ({ - client, - caseOptions, - caseService, - subCaseOptions, -}: { - client: SavedObjectsClientContract; - caseOptions: SavedObjectFindOptions; - subCaseOptions?: SavedObjectFindOptions; - caseService: CaseServiceSetup; -}): Promise => { - const casesStats = await caseService.findCases({ - client, - options: { - ...caseOptions, - fields: [], - page: 1, - perPage: 1, - }, - }); - - /** - * This could be made more performant. What we're doing here is retrieving all cases - * that match the API request's filters instead of just counts. This is because we need to grab - * the ids for the parent cases that match those filters. Then we use those IDS to count how many - * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. - * - * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single - * query for each type to calculate the totals using the filters. This has drawbacks though: - * - * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid - * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot - * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. - * - * Another option is to prevent the ability from update the parent case's details all together once it's created. A user - * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same - * parent would have different titles, tags, etc. - * - * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases - * don't have the same title and tags, we'd need to account for that as well. - */ - const cases = await caseService.findCases({ - client, - options: { - ...caseOptions, - // TODO: move this to a variable that the cases spec uses to define the field - fields: ['type'], - page: 1, - perPage: casesStats.total, - }, - }); - - const caseIds = cases.saved_objects - .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) - .map((caseInfo) => caseInfo.id); - - let subCasesTotal = 0; - - if (subCaseOptions) { - subCasesTotal = await findSubCaseStatusStats({ - client, - options: subCaseOptions, - caseService, - ids: caseIds, - }); - } - - const total = - cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) - .length + subCasesTotal; - - return total; -}; - -interface FindCommentsArgs { - client: SavedObjectsClientContract; - caseService: CaseServiceSetup; - id: string | string[]; - associationType: AssociationType; - options?: SavedObjectFindOptions; -} -export const getComments = async ({ - client, - caseService, - id, - associationType, - options, -}: FindCommentsArgs) => { - if (associationType === AssociationType.subCase) { - return caseService.getAllSubCaseComments({ - client, - id, - options, - }); - } else { - return caseService.getAllCaseComments({ - client, - id, - options, - }); - } -}; - -interface SubCaseStats { - commentTotals: Map; - alertTotals: Map; -} - -export const getCaseCommentStats = async ({ - client, - caseService, - ids, - associationType, -}: { - client: SavedObjectsClientContract; - caseService: CaseServiceSetup; - ids: string[]; - associationType: AssociationType; -}): Promise => { - const refType = - associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; - - const allComments = await Promise.all( - ids.map((id) => - getComments({ - client, - caseService, - associationType, - id, - options: { page: 1, perPage: 1 }, - }) - ) - ); - - const alerts = await getComments({ - client, - caseService, - associationType, - id: ids, - options: { - filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert})`, - }, - }); - - const getID = (comments: SavedObjectsFindResponse) => { - return comments.saved_objects.length > 0 - ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id - : undefined; - }; - - const groupedComments = allComments.reduce((acc, comments) => { - const id = getID(comments); - if (id) { - acc.set(id, comments.total); - } - return acc; - }, new Map()); - - const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); - return { commentTotals: groupedComments, alertTotals: groupedAlerts }; -}; - -interface SubCasesMapWithPageInfo { - subCasesMap: Map; - page: number; - perPage: number; -} - -/** - * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. - */ -export const findSubCases = async ({ - client, - options, - caseService, - ids, -}: { - client: SavedObjectsClientContract; - options?: SavedObjectFindOptions; - caseService: CaseServiceSetup; - ids: string[]; -}): Promise => { - const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { - return subCase.references.length > 0 ? subCase.references[0].id : undefined; - }; - - if (!options) { - return { subCasesMap: new Map(), page: 0, perPage: 0 }; - } - - const subCases = await caseService.findSubCases({ - client, - options: { - ...options, - hasReference: ids.map((id) => { - return { - id, - type: CASE_SAVED_OBJECT, - }; - }), - }, - }); - - const subCaseComments = await getCaseCommentStats({ - client, - caseService, - ids: subCases.saved_objects.map((subCase) => subCase.id), - associationType: AssociationType.subCase, - }); - - const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { - const parentCaseID = getCaseID(subCase); - if (parentCaseID) { - const subCaseFromMap = accMap.get(parentCaseID); - - if (subCaseFromMap === undefined) { - const subCasesForID = [ - flattenSubCaseSavedObject({ - savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, - }), - ]; - accMap.set(parentCaseID, subCasesForID); - } else { - subCaseFromMap.push( - flattenSubCaseSavedObject({ - savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, - }) - ); - } - } - return accMap; - }, new Map()); - - return { subCasesMap, page: subCases.page, perPage: subCases.per_page }; -}; - /** * Constructs the filters used for finding cases and sub cases. * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases @@ -400,7 +95,7 @@ export const findSubCases = async ({ * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other * filter criteria. When we search for sub cases we will use that status filter in that find call as well. */ -export const constructQueries = ({ +export const constructQueryOptions = ({ tags, reporters, status, @@ -535,14 +230,16 @@ export const isTwoArraysDifference = ( return null; }; -// TODO: rename -interface Versioned { +interface CaseWithIDVersion { id: string; version: string; [key: string]: unknown; } -export const getCaseToUpdate = (currentCase: unknown, queryCase: Versioned): Versioned => +export const getCaseToUpdate = ( + currentCase: unknown, + queryCase: CaseWithIDVersion +): CaseWithIDVersion => Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); 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 cdfbf78d430dd..f3cd0e2bdda5c 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 @@ -10,7 +10,7 @@ import { wrapError } from '../../utils'; import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; import { CASE_STATUS_URL } from '../../../../../common/constants'; -import { constructQueries, findCaseStatusStats } from '../helpers'; +import { constructQueryOptions } from '../helpers'; export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { router.get( @@ -24,12 +24,11 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { const [openCases, inProgressCases, closedCases] = await Promise.all([ ...caseStatuses.map((status) => { - const statusQuery = constructQueries({ status }); - return findCaseStatusStats({ + const statusQuery = constructQueryOptions({ status }); + return caseService.findCaseStatusStats({ client, caseOptions: statusQuery.case, subCaseOptions: statusQuery.subCase, - caseService, }); }), ]); 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 5b596707aefaa..330fae3095dbb 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 @@ -21,7 +21,7 @@ import { import { RouteDeps } from '../../types'; import { escapeHatch, transformSubCases, wrapError } from '../../utils'; import { SUB_CASES_URL } from '../../../../../common/constants'; -import { constructQueries, findSubCases, findSubCaseStatusStats } from '../helpers'; +import { constructQueryOptions } from '../helpers'; import { defaultPage, defaultPerPage } from '../..'; export function initFindSubCasesApi({ caseService, router }: RouteDeps) { @@ -44,10 +44,9 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { ); const ids = [request.params.case_id]; - const subCases = await findSubCases({ + const subCases = await caseService.findSubCasesGroupByCase({ client, ids, - caseService, options: { sortField: 'created_at', page: defaultPage, @@ -58,11 +57,10 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { const [open, inProgress, closed] = await Promise.all([ ...caseStatuses.map((status) => { - const { subCase } = constructQueries({ status }); - return findSubCaseStatusStats({ + const { subCase } = constructQueryOptions({ status }); + return caseService.findSubCaseStatusStats({ client, options: subCase ?? {}, - caseService, ids, }); }), diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 7eb5f7a9cbd35..1efec927efb62 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -10,7 +10,6 @@ import { transformNewComment, wrapError, transformCases, - flattenCaseSavedObjects, flattenCaseSavedObject, flattenCommentSavedObjects, transformComments, @@ -559,163 +558,6 @@ describe('Utils', () => { }); }); - // TODO: remove these - describe('flattenCaseSavedObjects', () => { - it('flattens correctly', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, - ] - `); - }); - - it('it handles total comments correctly when caseId is not in extraCaseData', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, - ] - `); - }); - - it('inserts missing connector', () => { - const extraCaseData = [ - { - caseId: mockCaseNoConnectorId.id, - totalComment: 0, - }, - ]; - - // @ts-ignore this is to update old case saved objects to include connector - const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-no-connector_id", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, - ] - `); - }); - }); - describe('flattenCaseSavedObject', () => { it('flattens correctly', () => { const myCase = { ...mockCases[2] }; diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index cb159420242cf..4099a5af8bc8b 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -46,7 +46,7 @@ import { } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; -import { SortFieldCase, TotalCommentByCase } from './types'; +import { SortFieldCase } from './types'; // TODO: refactor these functions to a common location, this is used by the caseClient too @@ -230,22 +230,6 @@ export const transformSubCases = ({ count_closed_cases: closed, }); -// TODO: remove because it is no longer used -export const flattenCaseSavedObjects = ( - savedObjects: Array>, - totalCommentByCase: TotalCommentByCase[] -): CaseResponse[] => - savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [ - ...acc, - flattenCaseSavedObject({ - savedObject, - totalComment: - totalCommentByCase.find((tc) => tc.caseId === savedObject.id)?.totalComments ?? 0, - }), - ]; - }, []); - export const flattenCaseSavedObject = ({ savedObject, comments = [], diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 457ec45e7788c..5f413ea27c4a7 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -118,7 +118,7 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, - // parent or individual + // collection or individual type: { type: 'keyword', }, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 08f0f29ffcb2b..3e68ddc8b1d98 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -16,6 +16,7 @@ import { SavedObjectReference, SavedObjectsBulkUpdateResponse, SavedObjectsBulkResponse, + SavedObjectsFindResult, } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; @@ -27,10 +28,17 @@ import { CommentPatchAttributes, SubCaseAttributes, AssociationType, + SubCaseResponse, + CommentType, + CaseType, + CaseResponse, } from '../../common/api'; -import { defaultSortField } from '../common'; -import { combineFilters } from '../routes/api/cases/helpers'; -import { transformNewSubCase } from '../routes/api/utils'; +import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; +import { + flattenCaseSavedObject, + flattenSubCaseSavedObject, + transformNewSubCase, +} from '../routes/api/utils'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT, @@ -92,6 +100,12 @@ interface FindSubCasesByIDArgs extends FindCasesArgs { ids: string[]; } +interface FindSubCasesStatusStats { + client: SavedObjectsClientContract; + options: SavedObjectFindOptions; + ids: string[]; +} + interface GetCommentArgs extends ClientArgs { commentId: string; } @@ -151,7 +165,35 @@ interface GetUserArgs { response?: KibanaResponseFactory; } -// TODO: split this up into comments, case, sub case, possibly more? +interface SubCasesMapWithPageInfo { + subCasesMap: Map; + page: number; + perPage: number; +} + +interface CaseCommentStats { + commentTotals: Map; + alertTotals: Map; +} + +interface FindCommentsByAssociationArgs { + client: SavedObjectsClientContract; + id: string | string[]; + associationType: AssociationType; + options?: SavedObjectFindOptions; +} + +interface Collection { + case: SavedObjectsFindResult; + subCases?: SubCaseResponse[]; +} + +interface CasesMapWithPageInfo { + casesMap: Map; + page: number; + perPage: number; +} + export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; @@ -188,6 +230,30 @@ export interface CaseServiceSetup { createSubCase(args: CreateSubCaseArgs): Promise>; patchSubCase(args: PatchSubCase): Promise>; patchSubCases(args: PatchSubCases): Promise>; + findSubCaseStatusStats(args: FindSubCasesStatusStats): Promise; + getCommentsByAssociation( + args: FindCommentsByAssociationArgs + ): Promise>; + getCaseCommentStats(args: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise; + findSubCasesGroupByCase(args: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise; + findCaseStatusStats(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; + findCasesGroupedByID(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; } export class CaseService implements CaseServiceSetup { @@ -196,6 +262,326 @@ export class CaseService implements CaseServiceSetup { private readonly authentication?: SecurityPluginSetup['authc'] ) {} + /** + * Returns a map of all cases combined with their sub cases if they are collections. + */ + public async findCasesGroupedByID({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const cases = await this.findCases({ + client, + options: caseOptions, + }); + + const subCasesResp = await this.findSubCasesGroupByCase({ + client, + options: subCaseOptions, + ids: cases.saved_objects + .filter((caseInfo) => caseInfo.type === CaseType.collection) + .map((caseInfo) => caseInfo.id), + }); + const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { + const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); + + /** + * This will include empty collections unless the query explicitly requested type === CaseType.individual, in which + * case we'd not have any collections anyway. + */ + accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); + return accMap; + }, new Map()); + + /** + * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases + * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case + * and the parent. The associationType field allows us to determine which type of case the comment is attached to. + * + * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. + * Once we have it we can build the maps. + * + * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) + * in another request (the one below this comment). + */ + const totalCommentsForCases = await this.getCaseCommentStats({ + client, + ids: Array.from(casesMap.keys()), + associationType: AssociationType.case, + }); + + const casesWithComments = new Map(); + for (const [id, caseInfo] of casesMap.entries()) { + casesWithComments.set( + id, + flattenCaseSavedObject({ + savedObject: caseInfo.case, + totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, + totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, + subCases: caseInfo.subCases, + }) + ); + } + + return { + casesMap: casesWithComments, + page: cases.page, + perPage: cases.per_page, + }; + } + + /** + * Retrieves the number of cases that exist with a given status (open, closed, etc). + * This also counts sub cases. Parent cases are excluded from the statistics. + */ + public async findCaseStatusStats({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const casesStats = await this.findCases({ + client, + options: { + ...caseOptions, + fields: [], + page: 1, + perPage: 1, + }, + }); + + /** + * This could be made more performant. What we're doing here is retrieving all cases + * that match the API request's filters instead of just counts. This is because we need to grab + * the ids for the parent cases that match those filters. Then we use those IDS to count how many + * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. + * + * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single + * query for each type to calculate the totals using the filters. This has drawbacks though: + * + * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid + * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot + * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. + * + * Another option is to prevent the ability from update the parent case's details all together once it's created. A user + * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same + * parent would have different titles, tags, etc. + * + * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases + * don't have the same title and tags, we'd need to account for that as well. + */ + const cases = await this.findCases({ + client, + options: { + ...caseOptions, + // TODO: move this to a variable that the cases spec uses to define the field + fields: ['type'], + page: 1, + perPage: casesStats.total, + }, + }); + + const caseIds = cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id); + + let subCasesTotal = 0; + + if (subCaseOptions) { + subCasesTotal = await this.findSubCaseStatusStats({ + client, + options: subCaseOptions, + ids: caseIds, + }); + } + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) + .length + subCasesTotal; + + return total; + } + + /** + * Retrieves the comments attached to a case or sub case. + */ + public async getCommentsByAssociation({ + client, + id, + associationType, + options, + }: FindCommentsByAssociationArgs): Promise> { + if (associationType === AssociationType.subCase) { + return this.getAllSubCaseComments({ + client, + id, + options, + }); + } else { + return this.getAllCaseComments({ + client, + id, + options, + }); + } + } + + /** + * Returns the number of total comments and alerts for a case (or sub case) + */ + public async getCaseCommentStats({ + client, + ids, + associationType, + }: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise { + const refType = + associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; + + const allComments = await Promise.all( + ids.map((id) => + this.getCommentsByAssociation({ + client, + associationType, + id, + options: { page: 1, perPage: 1 }, + }) + ) + ); + + const alerts = await this.getCommentsByAssociation({ + client, + associationType, + id: ids, + options: { + filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert})`, + }, + }); + + const getID = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.length > 0 + ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id + : undefined; + }; + + const groupedComments = allComments.reduce((acc, comments) => { + const id = getID(comments); + if (id) { + acc.set(id, comments.total); + } + return acc; + }, new Map()); + + const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); + return { commentTotals: groupedComments, alertTotals: groupedAlerts }; + } + + /** + * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. + */ + public async findSubCasesGroupByCase({ + client, + options, + ids, + }: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise { + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; + + if (!options) { + return { subCasesMap: new Map(), page: 0, perPage: 0 }; + } + + const subCases = await this.findSubCases({ + client, + options: { + ...options, + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const subCaseComments = await this.getCaseCommentStats({ + client, + ids: subCases.saved_objects.map((subCase) => subCase.id), + associationType: AssociationType.subCase, + }); + + const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { + const parentCaseID = getCaseID(subCase); + if (parentCaseID) { + const subCaseFromMap = accMap.get(parentCaseID); + + if (subCaseFromMap === undefined) { + const subCasesForID = [ + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }), + ]; + accMap.set(parentCaseID, subCasesForID); + } else { + subCaseFromMap.push( + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }) + ); + } + } + return accMap; + }, new Map()); + + return { subCasesMap, page: subCases.page, perPage: subCases.per_page }; + } + + /** + * Calculates the number of sub cases for a given set of options for a set of case IDs. + */ + public async findSubCaseStatusStats({ + client, + options, + ids, + }: FindSubCasesStatusStats): Promise { + const subCases = await this.findSubCases({ + client, + options: { + ...options, + page: 1, + perPage: 1, + fields: [], + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + return subCases.total; + } + public async createSubCase({ client, createdAt, diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 9395d1cdb8a5c..c0e332a256133 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -46,6 +46,12 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ patchComments: jest.fn(), patchSubCase: jest.fn(), patchSubCases: jest.fn(), + findSubCaseStatusStats: jest.fn(), + getCommentsByAssociation: jest.fn(), + getCaseCommentStats: jest.fn(), + findSubCasesGroupByCase: jest.fn(), + findCaseStatusStats: jest.fn(), + findCasesGroupedByID: jest.fn(), }); export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ From 8cde2b58c65e5aabb99043f65d5b6f8e8031ea78 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Mon, 8 Feb 2021 16:41:44 -0500 Subject: [PATCH 37/47] Fixing sub case status filtering --- x-pack/plugins/case/common/api/cases/case.ts | 7 +- .../api/cases/sub_case/find_sub_cases.ts | 13 +- x-pack/plugins/case/server/services/index.ts | 4 +- .../basic/tests/cases/find_cases.ts | 15 +- .../tests/cases/sub_cases/delete_sub_cases.ts | 18 +- .../tests/cases/sub_cases/find_sub_cases.ts | 162 +++++++++++++++++- .../tests/cases/sub_cases/get_sub_case.ts | 13 +- .../case_api_integration/common/lib/utils.ts | 51 ++++-- 8 files changed, 253 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index c20b0837c1c31..bc98c51e1002c 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -19,6 +19,11 @@ export enum CaseType { individual = 'individual', } +/** + * Exposing the field used to define the case type so that it can be used for filtering in saved object find queries. + */ +export const caseTypeField = 'type'; + const CaseTypeRt = rt.union([rt.literal(CaseType.collection), rt.literal(CaseType.individual)]); const SettingsRt = rt.type({ @@ -30,7 +35,7 @@ const CaseBasicRt = rt.type({ status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, - type: CaseTypeRt, + [caseTypeField]: CaseTypeRt, connector: CaseConnectorRt, settings: SettingsRt, }); 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 330fae3095dbb..98052ccaeaba8 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 @@ -44,6 +44,11 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { ); const ids = [request.params.case_id]; + const { subCase: subCaseQueryOptions } = constructQueryOptions({ + status: queryParams.status, + sortByField: queryParams.sortField, + }); + const subCases = await caseService.findSubCasesGroupByCase({ client, ids, @@ -52,15 +57,19 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { page: defaultPage, perPage: defaultPerPage, ...queryParams, + ...subCaseQueryOptions, }, }); const [open, inProgress, closed] = await Promise.all([ ...caseStatuses.map((status) => { - const { subCase } = constructQueryOptions({ status }); + const { subCase: statusQueryOptions } = constructQueryOptions({ + status, + sortByField: queryParams.sortField, + }); return caseService.findSubCaseStatusStats({ client, - options: subCase ?? {}, + options: statusQueryOptions ?? {}, ids, }); }), diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 3e68ddc8b1d98..84f4302a4725a 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -32,6 +32,7 @@ import { CommentType, CaseType, CaseResponse, + caseTypeField, } from '../../common/api'; import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; import { @@ -381,8 +382,7 @@ export class CaseService implements CaseServiceSetup { client, options: { ...caseOptions, - // TODO: move this to a variable that the cases spec uses to define the field - fields: ['type'], + fields: [caseTypeField], page: 1, perPage: casesStats.total, }, 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 0fe6f58210708..a2bc0acbcf17c 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 @@ -15,6 +15,8 @@ import { createSubCase, setStatus, CreateSubCaseResp, + createCaseAction, + deleteCaseAction, } from '../../../common/lib/utils'; import { CasesFindResponse, CaseStatuses, CaseType } from '../../../../../plugins/case/common/api'; @@ -248,8 +250,15 @@ export default ({ getService }: FtrProviderContext): void => { describe('stats with sub cases', () => { let collection: CreateSubCaseResp; + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); beforeEach(async () => { - collection = await createSubCase({ supertest }); + collection = await createSubCase({ supertest, actionID }); const [, , { body: toCloseCase }] = await Promise.all([ setStatus({ @@ -315,7 +324,7 @@ export default ({ getService }: FtrProviderContext): void => { it('correctly counts stats with a filter for collection cases with multiple sub cases', async () => { // this will force the first sub case attached to the collection to be closed // so we'll have one closed sub case and one open sub case - await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id }); + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); const { body }: { body: CasesFindResponse } = await supertest .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}`) .expect(200); @@ -330,7 +339,7 @@ export default ({ getService }: FtrProviderContext): void => { it('correctly counts stats with a filter for collection and open cases with multiple sub cases', async () => { // this will force the first sub case attached to the collection to be closed // so we'll have one closed sub case and one open sub case - await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id }); + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); const { body }: { body: CasesFindResponse } = await supertest .get( `${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}&status=${CaseStatuses.open}` 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 3d36226b53a33..537afbe825068 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 @@ -12,7 +12,12 @@ import { SUB_CASES_PATCH_DEL_URL, } from '../../../../../../plugins/case/common/constants'; import { postCommentUserReq } from '../../../../common/lib/mock'; -import { createSubCase, deleteAllCaseItems } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api'; @@ -22,12 +27,19 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); describe('delete_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); afterEach(async () => { await deleteAllCaseItems(es); }); it('should delete a sub case', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); expect(caseInfo.subCase?.id).to.not.eql(undefined); const { body: subCase } = await supertest @@ -51,7 +63,7 @@ export default function ({ getService }: FtrProviderContext) { }); it(`should delete a sub case's comments when that case gets deleted`, async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); expect(caseInfo.subCase?.id).to.not.eql(undefined); // there should be two comments on the sub case now 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 1bc1195b0d964..3463b37250980 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 @@ -9,7 +9,13 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { findSubCasesResp, postCollectionReq } from '../../../../common/lib/mock'; -import { createSubCase, deleteAllCaseItems } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; import { getSubCasesUrl } from '../../../../../../plugins/case/common/api/helpers'; import { CaseResponse, @@ -24,6 +30,13 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('find_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); afterEach(async () => { await deleteAllCaseItems(es); }); @@ -51,7 +64,7 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should return a sub cases with comment stats', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body }: { body: SubCasesFindResponse } = await supertest .get(`${getSubCasesUrl(caseInfo.id)}/_find`) @@ -67,8 +80,8 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should return multiple sub cases', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); - const subCase2Resp = await createSubCase({ supertest, caseID: caseInfo.id }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const subCase2Resp = await createSubCase({ supertest, caseID: caseInfo.id, actionID }); const { body }: { body: SubCasesFindResponse } = await supertest .get(`${getSubCasesUrl(caseInfo.id)}/_find`) @@ -99,8 +112,143 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - // TODO: - // tests for sorting on status, sort order, sort field - // tests for in-progress stats + it('should only return open when filtering for open', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.open}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return closed when filtering for closed', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.closed}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return in progress when filtering for in progress', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses['in-progress']}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); + + it('should sort on createdAt field in descending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=desc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.subCases[1].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on createdAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on updatedAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=updatedAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); }); }; 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 a8c0903053d06..cd5a1ed85742f 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 @@ -16,9 +16,11 @@ import { subCaseResp, } from '../../../../common/lib/mock'; import { + createCaseAction, createSubCase, defaultCreateSubComment, deleteAllCaseItems, + deleteCaseAction, } from '../../../../common/lib/utils'; import { getCaseCommentsUrl, @@ -36,12 +38,19 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('get_sub_case', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); afterEach(async () => { await deleteAllCaseItems(es); }); it('should return a case', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body }: { body: SubCaseResponse } = await supertest .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) @@ -62,7 +71,7 @@ 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 }); + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest .post(getCaseCommentsUrl(caseInfo.id)) 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 eb19051e3a8fb..b7471826057b9 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -32,6 +32,9 @@ interface SetStatusCasesParams { status: CaseStatuses; } +/** + * Sets the status of some cases or sub cases. The cases field must be all of one type. + */ export const setStatus = async ({ supertest, cases, @@ -79,10 +82,38 @@ export const createSubCase = async (args: { comment?: CommentRequestGeneratedAlertType; caseID?: string; caseInfo?: CasePostRequest; + actionID?: string; }): Promise => { return createSubCaseComment({ ...args, forceNewSubCase: true }); }; +/** + * Add case as a connector + */ +export const createCaseAction = async (supertest: st.SuperTest) => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + return createdAction.id; +}; + +export const deleteCaseAction = async ( + supertest: st.SuperTest, + id: string +) => { + await supertest.delete(`/api/actions/action/${id}`).set('kbn-xsrf', 'foo'); +}; + +/** + * Creates a sub case using the actions APIs. This will handle forcing a creation of a new sub case even if one exists + * if the forceNewSubCase parameter is set to true. + */ export const createSubCaseComment = async ({ supertest, caseID, @@ -90,22 +121,22 @@ export const createSubCaseComment = async ({ caseInfo = defaultCreateSubPost, // if true it will close any open sub cases and force a new sub case to be opened forceNewSubCase = false, + actionID, }: { supertest: st.SuperTest; comment?: CommentRequestGeneratedAlertType; caseID?: string; caseInfo?: CasePostRequest; forceNewSubCase?: boolean; + actionID?: string; }): Promise => { - const { body: createdAction } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A case connector', - actionTypeId: '.case', - config: {}, - }) - .expect(200); + let actionIDToUse: string; + + if (actionID === undefined) { + actionIDToUse = await createCaseAction(supertest); + } else { + actionIDToUse = actionID; + } let collectionID: string; @@ -145,7 +176,7 @@ export const createSubCaseComment = async ({ } const caseConnector = await supertest - .post(`/api/actions/action/${createdAction.id}/_execute`) + .post(`/api/actions/action/${actionIDToUse}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { From c35a735ca6356fe45d748695a29ddc93a8421a32 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 9 Feb 2021 09:20:53 -0500 Subject: [PATCH 38/47] Adding more tests not allowing gen alerts patch --- .../plugins/case/common/api/cases/comment.ts | 2 +- .../api/cases/comments/find_comments.ts | 6 +- .../tests/cases/comments/find_comments.ts | 42 ++++++++++++- .../basic/tests/cases/comments/get_comment.ts | 26 ++++++-- .../tests/cases/comments/patch_comment.ts | 58 +++++++++++++++++- .../tests/cases/comments/post_comment.ts | 59 ++++++++++++++++++- 6 files changed, 179 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 74c0ab30688d6..14c7f99c521d7 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -87,7 +87,7 @@ export const CommentPatchRequestRt = rt.intersection([ * Partial updates are not allowed. * We want to prevent the user for changing the type without removing invalid fields. */ - ContextBasicRt, + rt.union([ContextTypeUserRt, ContextTypeAlertRt]), rt.type({ id: rt.string, version: rt.string }), ]); 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 ab0110d8cd8b0..3431c340c791e 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, - sub_case_id: 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.sub_case_id ?? request.params.case_id; - const associationType = query.sub_case_id ? 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/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 824ea40d38ace..585333291111e 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 @@ -9,9 +9,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -102,5 +110,35 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + 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}`) + .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}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; 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 89efc927de5e3..b041002cfd940 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 @@ -10,7 +10,13 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentResponse, CommentType } from '../../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -18,10 +24,15 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('get_comment', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should get a comment', async () => { @@ -45,6 +56,13 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment).to.eql(patchedCase.comments[0]); }); + 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}`) + .expect(200); + expect(comment.type).to.be(CommentType.generatedAlert); + }); it('unhappy path - 404s when comment is not there', async () => { await supertest .get(`${CASES_URL}/fake-id/comments/fake-comment`) 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 73cce973eef94..c9f2206bb022c 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,14 +10,25 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { + CollectionWithSubCaseResponse, + CommentType, +} from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -31,6 +42,49 @@ export default ({ getService }: FtrProviderContext): void => { await deleteCasesUserActions(es); }); + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + 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}`) + .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}`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedSubCase.subCase!.comments![1].id, + version: patchedSubCase.subCase!.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); + }); + }); + it('should patch a comment', async () => { const { body: postedCase } = await supertest .post(CASES_URL) 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 6fa8077290678..1ce011985d9e6 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 @@ -11,15 +11,24 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, postCollectionReq, + postCommentGenAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -224,6 +233,20 @@ export default ({ getService }: FtrProviderContext): void => { .expect(400); }); + it('400s when adding a generated alert to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentGenAlertReq) + .expect(400); + }); + describe('alerts', () => { beforeEach(async () => { await esArchiver.load('auditbeat/hosts'); @@ -336,5 +359,37 @@ export default ({ getService }: FtrProviderContext): void => { expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); }); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('posts a new comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // 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}`) + .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}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; From 338cd16d9811d1395cbaff4a4d3f12c3ee8449de Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 9 Feb 2021 14:06:20 -0500 Subject: [PATCH 39/47] Working unit tests --- .../case/common/api/cases/case.ts.orig | 195 ------- .../plugins/case/common/api/helpers.ts.orig | 43 -- x-pack/plugins/case/common/constants.ts.orig | 47 -- .../plugins/case/server/client/cases/get.ts | 3 + .../plugins/case/server/client/cases/push.ts | 11 +- .../case/server/client/index.test.ts.orig | 48 -- .../plugins/case/server/client/index.ts.orig | 33 -- .../plugins/case/server/client/mocks.ts.orig | 88 ---- x-pack/plugins/case/server/client/types.ts | 1 + .../plugins/case/server/client/types.ts.orig | 123 ----- .../case/server/connectors/case/index.ts | 3 +- x-pack/plugins/case/server/plugin.ts | 12 +- x-pack/plugins/case/server/plugin.ts.orig | 192 ------- .../create_mock_so_repository.ts.orig | 305 ----------- .../__fixtures__/mock_saved_objects.ts.orig | 482 ------------------ .../api/__fixtures__/route_contexts.ts.orig | 71 --- .../routes/api/cases/delete_cases.test.ts | 21 +- .../api/cases/delete_cases.test.ts.orig | 129 ----- .../server/routes/api/cases/delete_cases.ts | 2 - .../case/server/routes/api/cases/get_case.ts | 7 +- .../server/routes/api/cases/get_case.ts.orig | 40 -- .../server/routes/api/cases/push_case.test.ts | 5 +- .../case/server/routes/api/cases/push_case.ts | 4 + .../server/routes/api/cases/push_case.ts.orig | 55 -- .../api/cases/status/get_status.test.ts.orig | 85 --- .../case/server/routes/api/utils.ts.orig | 367 ------------- 26 files changed, 32 insertions(+), 2340 deletions(-) delete mode 100644 x-pack/plugins/case/common/api/cases/case.ts.orig delete mode 100644 x-pack/plugins/case/common/api/helpers.ts.orig delete mode 100644 x-pack/plugins/case/common/constants.ts.orig delete mode 100644 x-pack/plugins/case/server/client/index.test.ts.orig delete mode 100644 x-pack/plugins/case/server/client/index.ts.orig delete mode 100644 x-pack/plugins/case/server/client/mocks.ts.orig delete mode 100644 x-pack/plugins/case/server/client/types.ts.orig delete mode 100644 x-pack/plugins/case/server/plugin.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/cases/get_case.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/cases/push_case.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts.orig delete mode 100644 x-pack/plugins/case/server/routes/api/utils.ts.orig diff --git a/x-pack/plugins/case/common/api/cases/case.ts.orig b/x-pack/plugins/case/common/api/cases/case.ts.orig deleted file mode 100644 index 8e80da382b588..0000000000000 --- a/x-pack/plugins/case/common/api/cases/case.ts.orig +++ /dev/null @@ -1,195 +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 { NumberFromString } from '../saved_object'; -import { UserRT } from '../user'; -import { CommentResponseRt } from './comment'; -import { CasesStatusResponseRt, CaseStatusRt } from './status'; -import { CaseConnectorRt, ESCaseConnector } from '../connectors'; -import { SubCaseResponseRt } from './sub_case'; - -export enum CaseType { - collection = 'collection', - individual = 'individual', -} - -/** - * Exposing the field used to define the case type so that it can be used for filtering in saved object find queries. - */ -export const caseTypeField = 'type'; - -const CaseTypeRt = rt.union([rt.literal(CaseType.collection), rt.literal(CaseType.individual)]); - -const SettingsRt = rt.type({ - syncAlerts: rt.boolean, -}); - -const CaseBasicRt = rt.type({ - description: rt.string, - status: CaseStatusRt, - tags: rt.array(rt.string), - title: rt.string, - [caseTypeField]: CaseTypeRt, - connector: CaseConnectorRt, - settings: SettingsRt, -}); - -const CaseExternalServiceBasicRt = rt.type({ - connector_id: rt.string, - connector_name: rt.string, - external_id: rt.string, - external_title: rt.string, - external_url: rt.string, -}); - -const CaseFullExternalServiceRt = rt.union([ - rt.intersection([ - CaseExternalServiceBasicRt, - rt.type({ - pushed_at: rt.string, - pushed_by: UserRT, - }), - ]), - rt.null, -]); - -export const CaseAttributesRt = rt.intersection([ - CaseBasicRt, - rt.type({ - closed_at: rt.union([rt.string, rt.null]), - closed_by: rt.union([UserRT, rt.null]), - created_at: rt.string, - created_by: UserRT, - external_service: CaseFullExternalServiceRt, - updated_at: rt.union([rt.string, rt.null]), - updated_by: rt.union([UserRT, rt.null]), - }), -]); - -const CasePostRequestNoTypeRt = rt.type({ - description: rt.string, - tags: rt.array(rt.string), - title: rt.string, - connector: CaseConnectorRt, - settings: SettingsRt, -}); - -/** - * This type is used for validating a create case request. It requires that the type field be defined. - */ -export const CaseClientPostRequestRt = rt.type({ - ...CasePostRequestNoTypeRt.props, - type: CaseTypeRt, -}); - -/** - * This type is not used for validation when decoding a request because intersection does not have props defined which - * required for the excess function. Instead we use this as the type used by the UI. This allows the type field to be - * optional and the server will handle setting it to a default value before validating that the request - * has all the necessary fields. CaseClientPostRequestRt is used for validation. - */ -export const CasePostRequestRt = rt.intersection([ - rt.partial({ type: CaseTypeRt }), - CasePostRequestNoTypeRt, -]); - -export const CasesFindRequestRt = rt.partial({ - type: CaseTypeRt, - tags: rt.union([rt.array(rt.string), rt.string]), - status: CaseStatusRt, - reporters: rt.union([rt.array(rt.string), rt.string]), - defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), - fields: rt.array(rt.string), - page: NumberFromString, - perPage: NumberFromString, - search: rt.string, - searchFields: rt.array(rt.string), - sortField: rt.string, - sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), -}); - -export const CaseResponseRt = rt.intersection([ - CaseAttributesRt, - rt.type({ - id: rt.string, - totalComment: rt.number, - totalAlerts: rt.number, - version: rt.string, - }), - rt.partial({ - subCases: rt.array(SubCaseResponseRt), - comments: rt.array(CommentResponseRt), - }), -]); - -export const CasesFindResponseRt = rt.intersection([ - rt.type({ - cases: rt.array(CaseResponseRt), - page: rt.number, - per_page: rt.number, - total: rt.number, - }), - CasesStatusResponseRt, -]); - -export const CasePatchRequestRt = rt.intersection([ - rt.partial(CaseBasicRt.props), - rt.type({ id: rt.string, version: rt.string }), -]); - -export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); -export const CasesResponseRt = rt.array(CaseResponseRt); - -export const CasePushRequestParamsRt = rt.type({ - case_id: rt.string, - connector_id: rt.string, -}); - -export const ExternalServiceResponseRt = rt.intersection([ - rt.type({ - title: rt.string, - id: rt.string, - pushedDate: rt.string, - url: rt.string, - }), - rt.partial({ - comments: rt.array( - rt.intersection([ - rt.type({ - commentId: rt.string, - pushedDate: rt.string, - }), - rt.partial({ externalCommentId: rt.string }), - ]) - ), - }), -]); - -export type CaseAttributes = rt.TypeOf; -/** - * This field differs from the CasePostRequest in that the post request's type field can be optional. This type requires - * that the type field be defined. The CasePostRequest should be used in most places (the UI etc). This type is really - * only necessary for validation. - */ -export type CaseClientPostRequest = rt.TypeOf; -export type CasePostRequest = rt.TypeOf; -export type CaseResponse = rt.TypeOf; -export type CasesResponse = rt.TypeOf; -export type CasesFindRequest = rt.TypeOf; -export type CasesFindResponse = rt.TypeOf; -export type CasePatchRequest = rt.TypeOf; -export type CasesPatchRequest = rt.TypeOf; -export type CaseFullExternalService = rt.TypeOf; -export type CaseSettings = rt.TypeOf; -export type ExternalServiceResponse = rt.TypeOf; - -export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; -export type ESCasePatchRequest = Omit & { - connector?: ESCaseConnector; -}; diff --git a/x-pack/plugins/case/common/api/helpers.ts.orig b/x-pack/plugins/case/common/api/helpers.ts.orig deleted file mode 100644 index 9c290c0a4d612..0000000000000 --- a/x-pack/plugins/case/common/api/helpers.ts.orig +++ /dev/null @@ -1,43 +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 { - CASE_DETAILS_URL, - CASE_COMMENTS_URL, - CASE_USER_ACTIONS_URL, - CASE_COMMENT_DETAILS_URL, - SUB_CASE_DETAILS_URL, - SUB_CASES_URL, - CASE_PUSH_URL, -} from '../constants'; - -export const getCaseDetailsUrl = (id: string): string => { - return CASE_DETAILS_URL.replace('{case_id}', id); -}; - -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 getCaseCommentsUrl = (id: string): string => { - return CASE_COMMENTS_URL.replace('{case_id}', id); -}; - -export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): string => { - return CASE_COMMENT_DETAILS_URL.replace('{case_id}', caseId).replace('{comment_id}', commentId); -}; - -export const getCaseUserActionUrl = (id: string): string => { - return CASE_USER_ACTIONS_URL.replace('{case_id}', id); -}; -export const getCasePushUrl = (caseId: string, connectorId: string): string => { - return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId); -}; diff --git a/x-pack/plugins/case/common/constants.ts.orig b/x-pack/plugins/case/common/constants.ts.orig deleted file mode 100644 index 5d34ed120ff6f..0000000000000 --- a/x-pack/plugins/case/common/constants.ts.orig +++ /dev/null @@ -1,47 +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. - */ - -export const APP_ID = 'case'; - -/** - * Case routes - */ - -export const CASES_URL = '/api/cases'; -export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; -export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; -export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; - -export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`; -export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; -export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; - -export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; -export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; -export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; -export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`; -export const CASE_STATUS_URL = `${CASES_URL}/status`; -export const CASE_TAGS_URL = `${CASES_URL}/tags`; -export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; - -/** - * Action routes - */ - -export const ACTION_URL = '/api/actions'; -export const ACTION_TYPES_URL = '/api/actions/list_action_types'; -export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; -export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; -export const JIRA_ACTION_TYPE_ID = '.jira'; -export const RESILIENT_ACTION_TYPE_ID = '.resilient'; - -export const SUPPORTED_CONNECTORS = [ - SERVICENOW_ITSM_ACTION_TYPE_ID, - SERVICENOW_SIR_ACTION_TYPE_ID, - JIRA_ACTION_TYPE_ID, - RESILIENT_ACTION_TYPE_ID, -]; diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts index 974c46bd0f370..eab43a0c4d453 100644 --- a/x-pack/plugins/case/server/client/cases/get.ts +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -16,6 +16,7 @@ interface GetParams { caseService: CaseServiceSetup; id: string; includeComments?: boolean; + includeSubCaseComments?: boolean; } export const get = async ({ @@ -23,6 +24,7 @@ export const get = async ({ caseService, id, includeComments = false, + includeSubCaseComments = false, }: GetParams): Promise => { const theCase = await caseService.getCase({ client: savedObjectsClient, @@ -44,6 +46,7 @@ export const get = async ({ sortField: 'created_at', sortOrder: 'asc', }, + includeSubCaseComments, }); return CaseResponseRt.encode( diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index 0608c4c13a2b4..e7f54b7fa3235 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -44,6 +44,11 @@ const createError = (e: Error | BoomType, message: string): Error | BoomType => return Error(message); }; +interface AlertInfo { + ids: string[]; + indices: Set; +} + interface PushParams { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; @@ -93,11 +98,11 @@ export const push = async ({ ); } - const { ids, indices }: { ids: string[]; indices: Set } = theCase?.comments + const { ids, indices }: AlertInfo = theCase?.comments ?.filter(isCommentAlertType) - .reduce( + .reduce( (acc, comment) => { - ids.push(...getAlertIds(comment)); + acc.ids.push(...getAlertIds(comment)); acc.indices.add(comment.index); return acc; }, diff --git a/x-pack/plugins/case/server/client/index.test.ts.orig b/x-pack/plugins/case/server/client/index.test.ts.orig deleted file mode 100644 index 1545bc6b1249f..0000000000000 --- a/x-pack/plugins/case/server/client/index.test.ts.orig +++ /dev/null @@ -1,48 +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 { KibanaRequest } from 'kibana/server'; -import { - elasticsearchServiceMock, - savedObjectsClientMock, -} from '../../../../../src/core/server/mocks'; -import { - connectorMappingsServiceMock, - createCaseServiceMock, - createConfigureServiceMock, - createUserActionServiceMock, - createAlertServiceMock, -} from '../services/mocks'; - -jest.mock('./client'); -import { CaseClientImpl } from './client'; -import { createExternalCaseClient } from './index'; - -const esClient = elasticsearchServiceMock.createElasticsearchClient(); -const caseConfigureService = createConfigureServiceMock(); -const alertsService = createAlertServiceMock(); -const caseService = createCaseServiceMock(); -const connectorMappingsService = connectorMappingsServiceMock(); -const request = {} as KibanaRequest; -const savedObjectsClient = savedObjectsClientMock.create(); -const userActionService = createUserActionServiceMock(); - -describe('createExternalCaseClient()', () => { - test('it creates the client correctly', async () => { - createExternalCaseClient({ - scopedClusterClient: esClient, - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - expect(CaseClientImpl).toHaveBeenCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/case/server/client/index.ts.orig b/x-pack/plugins/case/server/client/index.ts.orig deleted file mode 100644 index 06f0dfd40ee7d..0000000000000 --- a/x-pack/plugins/case/server/client/index.ts.orig +++ /dev/null @@ -1,33 +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 { CaseClientFactoryArguments, CaseClient } from './types'; -import { CaseClientImpl } from './client'; - -export { CaseClientImpl } from './client'; -export { CaseClient } from './types'; - -// TODO: this screws up the mocking because it won't mock out CaseClientImpl's methods -/* export const createExternalCaseClient = ( - clientArgs: CaseClientFactoryArguments -): CaseClientPluginContract => { - const client = new CaseClientImpl(clientArgs); - return { - create: async (args: CaseClientPostRequest) => client.create(args), - addComment: async (args: CaseClientAddComment) => client.addComment(args), - getFields: async (args: ConfigureFields) => client.getFields(args), - getMappings: async (args: MappingsClient) => client.getMappings(args), - update: async (args: CasesPatchRequest) => client.update(args), - updateAlertsStatus: async (args: CaseClientUpdateAlertsStatus) => - client.updateAlertsStatus(args), - }; -};*/ - -export const createExternalCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { - const client = new CaseClientImpl(clientArgs); - return client; -}; diff --git a/x-pack/plugins/case/server/client/mocks.ts.orig b/x-pack/plugins/case/server/client/mocks.ts.orig deleted file mode 100644 index f55a7f690a53c..0000000000000 --- a/x-pack/plugins/case/server/client/mocks.ts.orig +++ /dev/null @@ -1,88 +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 { KibanaRequest } from 'kibana/server'; -import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; -import { - AlertServiceContract, - CaseConfigureService, - CaseService, - CaseUserActionServiceSetup, - ConnectorMappingsService, -} from '../services'; -import { CaseClient } from './types'; -import { authenticationMock } from '../routes/api/__fixtures__'; -import { createExternalCaseClient } from '.'; - -export type CaseClientPluginContractMock = jest.Mocked; -export const createExternalCaseClientMock = (): CaseClientPluginContractMock => ({ - addComment: jest.fn(), - create: jest.fn(), - get: jest.fn(), - push: jest.fn(), - getAlerts: jest.fn(), - getFields: jest.fn(), - getMappings: jest.fn(), - getUserActions: jest.fn(), - update: jest.fn(), - updateAlertsStatus: jest.fn(), -}); - -export const createCaseClientWithMockSavedObjectsClient = async ({ - savedObjectsClient, - badAuth = false, - omitFromContext = [], -}: { - savedObjectsClient: any; - badAuth?: boolean; - omitFromContext?: string[]; -}): Promise<{ - client: CaseClient; - services: { - userActionService: jest.Mocked; - alertsService: jest.Mocked; - }; -}> => { - const esClient = elasticsearchServiceMock.createElasticsearchClient(); - // const actionsMock = createActionsClient(); - const log = loggingSystemMock.create().get('case'); - const request = {} as KibanaRequest; - - const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); - const caseService = new CaseService(log, auth); - const caseConfigureServicePlugin = new CaseConfigureService(log); - const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - - const caseConfigureService = await caseConfigureServicePlugin.setup(); - - const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const userActionService = { - getUserActions: jest.fn(), - postUserActions: jest.fn(), - }; - - const alertsService = { - initialize: jest.fn(), - updateAlertsStatus: jest.fn(), - getAlerts: jest.fn(), - }; - - const caseClient = createExternalCaseClient({ - savedObjectsClient, - request, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - scopedClusterClient: esClient, - }); - return { - client: caseClient, - services: { userActionService, alertsService }, - }; -}; diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index e435c4c7c0191..6a7d499630543 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -42,6 +42,7 @@ export interface CaseClientUpdate { export interface CaseClientGet { id: string; includeComments?: boolean; + includeSubCaseComments?: boolean; } export interface CaseClientPush { diff --git a/x-pack/plugins/case/server/client/types.ts.orig b/x-pack/plugins/case/server/client/types.ts.orig deleted file mode 100644 index e435c4c7c0191..0000000000000 --- a/x-pack/plugins/case/server/client/types.ts.orig +++ /dev/null @@ -1,123 +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 { ElasticsearchClient, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; -import { ActionsClient } from '../../../actions/server'; -import { - CaseClientPostRequest, - CasePostRequest, - CaseResponse, - CasesPatchRequest, - CasesResponse, - CaseStatuses, - CollectionWithSubCaseResponse, - CommentRequest, - ConnectorMappingsAttributes, - GetFieldsResponse, - CaseUserActionsResponse, -} from '../../common/api'; -import { - CaseConfigureServiceSetup, - CaseServiceSetup, - CaseUserActionServiceSetup, - AlertServiceContract, -} from '../services'; -import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; -import { CaseClientGetAlertsResponse } from './alerts/types'; - -// TODO: Remove unused types - -export interface CaseClientCreate { - theCase: CaseClientPostRequest; -} - -export interface CaseClientUpdate { - cases: CasesPatchRequest; -} - -export interface CaseClientGet { - id: string; - includeComments?: boolean; -} - -export interface CaseClientPush { - actionsClient: ActionsClient; - caseClient: CaseClient; - caseId: string; - connectorId: string; -} - -export interface CaseClientAddComment { - caseId: string; - comment: CommentRequest; -} - -export interface CaseClientAddInternalComment { - caseId: string; - comment: CommentRequest; -} - -export interface CaseClientUpdateAlertsStatus { - ids: string[]; - status: CaseStatuses; - indices: Set; -} - -export interface CaseClientGetAlerts { - ids: string[]; - indices: Set; -} - -export interface CaseClientGetUserActions { - caseId: string; -} - -export interface MappingsClient { - actionsClient: ActionsClient; - connectorId: string; - connectorType: string; -} - -export interface CaseClientFactoryArguments { - scopedClusterClient: ElasticsearchClient; - caseConfigureService: CaseConfigureServiceSetup; - caseService: CaseServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; - request: KibanaRequest; - // response: KibanaResponseFactory; - savedObjectsClient: SavedObjectsClientContract; - userActionService: CaseUserActionServiceSetup; - alertsService: AlertServiceContract; -} - -export interface ConfigureFields { - actionsClient: ActionsClient; - connectorId: string; - connectorType: string; -} - -/** - * This represents the interface that other plugins can access. - */ -export interface CaseClient { - addComment(args: CaseClientAddComment): Promise; - create(theCase: CasePostRequest): Promise; - get(args: CaseClientGet): Promise; - getAlerts(args: CaseClientGetAlerts): Promise; - getFields(args: ConfigureFields): Promise; - getMappings(args: MappingsClient): Promise; - getUserActions(args: CaseClientGetUserActions): Promise; - push(args: CaseClientPush): Promise; - update(args: CasesPatchRequest): Promise; - updateAlertsStatus(args: CaseClientUpdateAlertsStatus): Promise; -} - -export interface MappingsClient { - actionsClient: ActionsClient; - connectorId: string; - connectorType: string; -} diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 338c28bb10e04..6017fe312e063 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,7 +7,7 @@ import { curry } from 'lodash'; -import { KibanaRequest, kibanaResponseFactory } from '../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createExternalCaseClient } from '../../client'; @@ -76,7 +76,6 @@ async function executor( scopedClusterClient, // TODO: refactor this request: {} as KibanaRequest, - response: kibanaResponseFactory, caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index b9262b7720b4d..279acf9e8951d 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,13 +5,7 @@ * 2.0. */ -import { - IContextProvider, - KibanaRequest, - KibanaResponseFactory, - Logger, - PluginInitializerContext, -} from 'kibana/server'; +import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -131,8 +125,7 @@ export class CasePlugin { const getCaseClientWithRequestAndContext = async ( context: CasesRequestHandlerContext, - request: KibanaRequest, - response: KibanaResponseFactory + request: KibanaRequest ) => { return createExternalCaseClient({ scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, @@ -146,7 +139,6 @@ export class CasePlugin { }); }; - // TODO I think we need to create a return type for the start function to include this return { getCaseClientWithRequestAndContext, }; diff --git a/x-pack/plugins/case/server/plugin.ts.orig b/x-pack/plugins/case/server/plugin.ts.orig deleted file mode 100644 index b9262b7720b4d..0000000000000 --- a/x-pack/plugins/case/server/plugin.ts.orig +++ /dev/null @@ -1,192 +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 { - IContextProvider, - KibanaRequest, - KibanaResponseFactory, - Logger, - PluginInitializerContext, -} from 'kibana/server'; -import { CoreSetup, CoreStart } from 'src/core/server'; - -import { SecurityPluginSetup } from '../../security/server'; -import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server'; -import { APP_ID } from '../common/constants'; - -import { ConfigType } from './config'; -import { initCaseApi } from './routes/api'; -import { - caseCommentSavedObjectType, - caseConfigureSavedObjectType, - caseConnectorMappingsSavedObjectType, - caseSavedObjectType, - caseUserActionSavedObjectType, - subCaseSavedObjectType, -} from './saved_object_types'; -import { - CaseConfigureService, - CaseConfigureServiceSetup, - CaseService, - CaseServiceSetup, - CaseUserActionService, - CaseUserActionServiceSetup, - ConnectorMappingsService, - ConnectorMappingsServiceSetup, - AlertService, - AlertServiceContract, -} from './services'; -import { CaseClientImpl, createExternalCaseClient } from './client'; -import { registerConnectors } from './connectors'; -import type { CasesRequestHandlerContext } from './types'; - -function createConfig(context: PluginInitializerContext) { - return context.config.get(); -} - -export interface PluginsSetup { - security: SecurityPluginSetup; - actions: ActionsPluginSetup; -} - -export class CasePlugin { - private readonly log: Logger; - private caseConfigureService?: CaseConfigureServiceSetup; - private caseService?: CaseServiceSetup; - private connectorMappingsService?: ConnectorMappingsServiceSetup; - private userActionService?: CaseUserActionServiceSetup; - private alertsService?: AlertService; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.log = this.initializerContext.logger.get(); - } - - public async setup(core: CoreSetup, plugins: PluginsSetup) { - const config = createConfig(this.initializerContext); - - if (!config.enabled) { - return; - } - - core.savedObjects.registerType(caseCommentSavedObjectType); - core.savedObjects.registerType(caseConfigureSavedObjectType); - core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); - core.savedObjects.registerType(caseSavedObjectType); - core.savedObjects.registerType(subCaseSavedObjectType); - core.savedObjects.registerType(caseUserActionSavedObjectType); - - this.log.debug( - `Setting up Case Workflow with core contract [${Object.keys( - core - )}] and plugins [${Object.keys(plugins)}]` - ); - - this.caseService = new CaseService( - this.log, - plugins.security != null ? plugins.security.authc : undefined - ); - this.caseConfigureService = await new CaseConfigureService(this.log).setup(); - this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup(); - this.userActionService = await new CaseUserActionService(this.log).setup(); - this.alertsService = new AlertService(); - - core.http.registerRouteHandlerContext( - APP_ID, - this.createRouteHandlerContext({ - core, - caseService: this.caseService, - caseConfigureService: this.caseConfigureService, - connectorMappingsService: this.connectorMappingsService, - userActionService: this.userActionService, - alertsService: this.alertsService, - }) - ); - - const router = core.http.createRouter(); - initCaseApi({ - caseService: this.caseService, - caseConfigureService: this.caseConfigureService, - connectorMappingsService: this.connectorMappingsService, - userActionService: this.userActionService, - router, - }); - - registerConnectors({ - actionsRegisterType: plugins.actions.registerType, - logger: this.log, - caseService: this.caseService, - caseConfigureService: this.caseConfigureService, - connectorMappingsService: this.connectorMappingsService, - userActionService: this.userActionService, - alertsService: this.alertsService, - }); - } - - public start(core: CoreStart) { - this.log.debug(`Starting Case Workflow`); - - const getCaseClientWithRequestAndContext = async ( - context: CasesRequestHandlerContext, - request: KibanaRequest, - response: KibanaResponseFactory - ) => { - return createExternalCaseClient({ - scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, - savedObjectsClient: core.savedObjects.getScopedClient(request), - request, - caseService: this.caseService!, - caseConfigureService: this.caseConfigureService!, - connectorMappingsService: this.connectorMappingsService!, - userActionService: this.userActionService!, - alertsService: this.alertsService!, - }); - }; - - // TODO I think we need to create a return type for the start function to include this - return { - getCaseClientWithRequestAndContext, - }; - } - - public stop() { - this.log.debug(`Stopping Case Workflow`); - } - - private createRouteHandlerContext = ({ - core, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - }: { - core: CoreSetup; - caseService: CaseServiceSetup; - caseConfigureService: CaseConfigureServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; - userActionService: CaseUserActionServiceSetup; - alertsService: AlertServiceContract; - }): IContextProvider => { - return async (context, request, response) => { - const [{ savedObjects }] = await core.getStartServices(); - return { - getCaseClient: () => { - return new CaseClientImpl({ - scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, - savedObjectsClient: savedObjects.getScopedClient(request), - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - request, - }); - }, - }; - }; - }; -} diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts.orig b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts.orig deleted file mode 100644 index a33226bcde899..0000000000000 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts.orig +++ /dev/null @@ -1,305 +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 { - SavedObjectsClientContract, - SavedObjectsErrorHelpers, - SavedObjectsBulkGetObject, - SavedObjectsBulkUpdateObject, - SavedObjectsFindOptions, -} from 'src/core/server'; - -import { - CASE_COMMENT_SAVED_OBJECT, - CASE_SAVED_OBJECT, - CASE_CONFIGURE_SAVED_OBJECT, - CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, - CASE_USER_ACTION_SAVED_OBJECT, -} from '../../../saved_object_types'; - -export const createMockSavedObjectsRepository = ({ - caseSavedObject = [], - caseCommentSavedObject = [], - caseConfigureSavedObject = [], - caseMappingsSavedObject = [], - caseUserActionsSavedObject = [], -}: { - caseSavedObject?: any[]; - caseCommentSavedObject?: any[]; - caseConfigureSavedObject?: any[]; - caseMappingsSavedObject?: any[]; - caseUserActionsSavedObject?: any[]; -} = {}) => { - const mockSavedObjectsClientContract = ({ - bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { - return { - saved_objects: objects.map(({ id, type }) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - const result = caseCommentSavedObject.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result; - } - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { - return { - id, - type, - error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [cases/not-exist] not found', - }, - }; - } - return result[0]; - }), - }; - }), - bulkCreate: jest.fn(), - bulkUpdate: jest.fn((objects: Array>) => { - return { - saved_objects: objects.map(({ id, type, attributes }) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - if (!caseCommentSavedObject.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - } else if (type === CASE_SAVED_OBJECT) { - if (!caseSavedObject.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - } - - return { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, - }; - }), - }; - }), - get: jest.fn((type, id) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - const result = caseCommentSavedObject.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result[0]; - } else if (type === CASE_SAVED_OBJECT) { - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result[0]; - } else { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - }), - find: jest.fn((findArgs: SavedObjectsFindOptions) => { - // References can be an array so we need to loop through it looking for the bad-guy - const hasReferenceIncludeBadGuy = (args: SavedObjectsFindOptions) => { - const references = args.hasReference; - if (references) { - return Array.isArray(references) - ? references.some((ref) => ref.id === 'bad-guy') - : references.id === 'bad-guy'; - } else { - return false; - } - }; - if (hasReferenceIncludeBadGuy(findArgs)) { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if ( - (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT && - caseConfigureSavedObject[0] && - caseConfigureSavedObject[0].id === 'throw-error-find') || - (findArgs.type === CASE_SAVED_OBJECT && - caseSavedObject[0] && - caseSavedObject[0].id === 'throw-error-find') - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError('Error thrown for testing'); - } - if (findArgs.type === CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT && caseMappingsSavedObject[0]) { - return { - page: 1, - per_page: 5, - total: 1, - saved_objects: caseMappingsSavedObject, - }; - } - - if (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT) { - return { - page: 1, - per_page: 5, - total: caseConfigureSavedObject.length, - saved_objects: caseConfigureSavedObject, - }; - } - - if (findArgs.type === CASE_COMMENT_SAVED_OBJECT) { - return { - page: 1, - per_page: 5, - total: caseCommentSavedObject.length, - saved_objects: caseCommentSavedObject, - }; - } - - // Currently not supporting sub cases in this mock library - if (findArgs.type === SUB_CASE_SAVED_OBJECT) { - return { - page: 1, - per_page: 0, - total: 0, - saved_objects: [], - }; - } - - if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) { - return { - page: 1, - per_page: 5, - total: caseUserActionsSavedObject.length, - saved_objects: caseUserActionsSavedObject, - }; - } - - return { - page: 1, - per_page: 5, - total: caseSavedObject.length, - saved_objects: caseSavedObject, - }; - }), - create: jest.fn((type, attributes, references) => { - if (attributes.description === 'Throw an error' || attributes.comment === 'Throw an error') { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if ( - type === CASE_CONFIGURE_SAVED_OBJECT && - attributes.connector.id === 'throw-error-create' - ) { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if (type === CASE_COMMENT_SAVED_OBJECT) { - const newCommentObj = { - type, - id: 'mock-comment', - attributes, - ...references, - updated_at: '2019-12-02T22:48:08.327Z', - version: 'WzksMV0=', - }; - caseCommentSavedObject = [...caseCommentSavedObject, newCommentObj]; - return newCommentObj; - } - - if (type === CASE_CONFIGURE_SAVED_OBJECT) { - const newConfiguration = { - type, - id: 'mock-configuration', - attributes, - updated_at: '2020-04-09T09:43:51.778Z', - version: attributes.connector.id === 'no-version' ? undefined : 'WzksMV0=', - }; - - caseConfigureSavedObject = [newConfiguration]; - return newConfiguration; - } - - return { - type, - id: 'mock-it', - attributes, - references: [], - updated_at: '2019-12-02T22:48:08.327Z', - version: 'WzksMV0=', - }; - }), - update: jest.fn((type, id, attributes) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - const foundComment = caseCommentSavedObject.findIndex((s: { id: string }) => s.id === id); - if (foundComment === -1) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - const comment = caseCommentSavedObject[foundComment]; - caseCommentSavedObject.splice(foundComment, 1, { - ...comment, - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes: { - ...comment.attributes, - ...attributes, - }, - }); - } else if (type === CASE_SAVED_OBJECT) { - if (!caseSavedObject.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - } - - if (type === CASE_CONFIGURE_SAVED_OBJECT) { - return { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - attributes, - version: attributes.connector?.id === 'no-version' ? undefined : 'WzE3LDFd', - }; - } - - return { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, - }; - }), - delete: jest.fn((type: string, id: string) => { - let result = caseSavedObject.filter((s) => s.id === id); - - if (type === CASE_COMMENT_SAVED_OBJECT) { - result = caseCommentSavedObject.filter((s) => s.id === id); - } - - if (type === CASE_CONFIGURE_SAVED_OBJECT) { - result = caseConfigureSavedObject.filter((s) => s.id === id); - } - - if (type === CASE_COMMENT_SAVED_OBJECT && id === 'bad-guy') { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - if ( - type === CASE_CONFIGURE_SAVED_OBJECT && - caseConfigureSavedObject[0].id === 'throw-error-delete' - ) { - throw new Error('Error thrown for testing'); - } - return {}; - }), - deleteByNamespace: jest.fn(), - } as unknown) as jest.Mocked; - - return mockSavedObjectsClientContract; -}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts.orig b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts.orig deleted file mode 100644 index 2fe0be3e08ede..0000000000000 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts.orig +++ /dev/null @@ -1,482 +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 { SavedObject } from 'kibana/server'; -import { - AssociationType, - CaseStatuses, - CaseType, - CaseUserActionAttributes, - CommentAttributes, - CommentType, - ConnectorMappings, - ConnectorTypes, - ESCaseAttributes, - ESCasesConfigureAttributes, -} from '../../../../common/api'; -import { - CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, - CASE_USER_ACTION_SAVED_OBJECT, -} from '../../../saved_object_types'; -import { mappings } from '../../../client/configure/mock'; - -export const mockCases: Array> = [ - { - type: 'cases', - id: 'mock-id-1', - attributes: { - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: [], - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - type: CaseType.individual, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - settings: { - syncAlerts: true, - }, - }, - references: [], - updated_at: '2019-11-25T21:54:48.952Z', - version: 'WzAsMV0=', - }, - { - type: 'cases', - id: 'mock-id-2', - attributes: { - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: [], - }, - created_at: '2019-11-25T22:32:00.900Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'Oh no, a bad meanie destroying data!', - external_service: null, - title: 'Damaging Data Destruction Detected', - status: CaseStatuses.open, - tags: ['Data Destruction'], - type: CaseType.individual, - updated_at: '2019-11-25T22:32:00.900Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - settings: { - syncAlerts: true, - }, - }, - references: [], - updated_at: '2019-11-25T22:32:00.900Z', - version: 'WzQsMV0=', - }, - { - type: 'cases', - id: 'mock-id-3', - attributes: { - closed_at: null, - closed_by: null, - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - external_service: null, - title: 'Another bad one', - status: CaseStatuses.open, - tags: ['LOLBins'], - type: CaseType.individual, - updated_at: '2019-11-25T22:32:17.947Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - settings: { - syncAlerts: true, - }, - }, - references: [], - updated_at: '2019-11-25T22:32:17.947Z', - version: 'WzUsMV0=', - }, - { - type: 'cases', - id: 'mock-id-4', - attributes: { - closed_at: '2019-11-25T22:32:17.947Z', - closed_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - external_service: null, - status: CaseStatuses.closed, - title: 'Another bad one', - tags: ['LOLBins'], - type: CaseType.individual, - updated_at: '2019-11-25T22:32:17.947Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - settings: { - syncAlerts: true, - }, - }, - references: [], - updated_at: '2019-11-25T22:32:17.947Z', - version: 'WzUsMV0=', - }, -]; - -export const mockCaseNoConnectorId: SavedObject> = { - type: 'cases', - id: 'mock-no-connector_id', - attributes: { - closed_at: null, - closed_by: null, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - settings: { - syncAlerts: true, - }, - }, - references: [], - updated_at: '2019-11-25T21:54:48.952Z', - version: 'WzAsMV0=', -}; - -export const mockCasesErrorTriggerData = [ - { - id: 'valid-id', - }, - { - id: 'bad-guy', - }, -]; - -export const mockCaseComments: Array> = [ - { - type: 'cases-comment', - id: 'mock-comment-1', - attributes: { - associationType: AssociationType.case, - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2019-11-25T21:55:00.177Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - pushed_at: null, - pushed_by: null, - updated_at: '2019-11-25T21:55:00.177Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-1', - }, - ], - updated_at: '2019-11-25T21:55:00.177Z', - version: 'WzEsMV0=', - }, - { - type: 'cases-comment', - id: 'mock-comment-2', - attributes: { - associationType: AssociationType.case, - comment: 'Well I decided to update my comment. So what? Deal with it.', - type: CommentType.user, - created_at: '2019-11-25T21:55:14.633Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - pushed_at: null, - pushed_by: null, - updated_at: '2019-11-25T21:55:14.633Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-1', - }, - ], - updated_at: '2019-11-25T21:55:14.633Z', - - version: 'WzMsMV0=', - }, - { - type: 'cases-comment', - id: 'mock-comment-3', - attributes: { - associationType: AssociationType.case, - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2019-11-25T22:32:30.608Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - pushed_at: null, - pushed_by: null, - updated_at: '2019-11-25T22:32:30.608Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-3', - }, - ], - updated_at: '2019-11-25T22:32:30.608Z', - version: 'WzYsMV0=', - }, - { - type: 'cases-comment', - id: 'mock-comment-4', - attributes: { - associationType: AssociationType.case, - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - created_at: '2019-11-25T22:32:30.608Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - pushed_at: null, - pushed_by: null, - updated_at: '2019-11-25T22:32:30.608Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-4', - }, - ], - updated_at: '2019-11-25T22:32:30.608Z', - version: 'WzYsMV0=', - }, - { - type: 'cases-comment', - id: 'mock-comment-5', - attributes: { - associationType: AssociationType.case, - type: CommentType.alert, - index: 'test-index-2', - alertId: 'test-id-2', - created_at: '2019-11-25T22:32:30.608Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - pushed_at: null, - pushed_by: null, - updated_at: '2019-11-25T22:32:30.608Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-4', - }, - ], - updated_at: '2019-11-25T22:32:30.608Z', - version: 'WzYsMV0=', - }, -]; - -export const mockCaseConfigure: Array> = [ - { - type: 'cases-configure', - id: 'mock-configuration-1', - attributes: { - connector: { - id: '789', - name: 'My connector 3', - type: ConnectorTypes.jira, - fields: null, - }, - closure_type: 'close-by-user', - created_at: '2020-04-09T09:43:51.778Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - updated_at: '2020-04-09T09:43:51.778Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - }, - references: [], - updated_at: '2020-04-09T09:43:51.778Z', - version: 'WzYsMV0=', - }, -]; - -export const mockCaseMappings: Array> = [ - { - type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, - id: 'mock-mappings-1', - attributes: { - mappings: mappings[ConnectorTypes.jira], - }, - references: [], - }, -]; - -export const mockUserActions: Array> = [ - { - type: CASE_USER_ACTION_SAVED_OBJECT, - id: 'mock-user-actions-1', - attributes: { - action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], - action: 'create', - action_at: '2021-02-03T17:41:03.771Z', - action_by: { - email: 'elastic@elastic.co', - full_name: 'Elastic', - username: 'elastic', - }, - new_value: - '{"title":"A case","tags":["case"],"description":"Yeah!","connector":{"id":"connector-od","name":"My Connector","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', - old_value: null, - }, - version: 'WzYsMV0=', - references: [], - }, - { - type: CASE_USER_ACTION_SAVED_OBJECT, - id: 'mock-user-actions-2', - attributes: { - action_field: ['comment'], - action: 'create', - action_at: '2021-02-03T17:44:21.067Z', - action_by: { - email: 'elastic@elastic.co', - full_name: 'Elastic', - username: 'elastic', - }, - new_value: - '{"type":"alert","alertId":"cec3da90fb37a44407145adf1593f3b0d5ad94c4654201f773d63b5d4706128e","index":".siem-signals-default-000008"}', - old_value: null, - }, - version: 'WzYsMV0=', - references: [], - }, -]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts.orig b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts.orig deleted file mode 100644 index dbcf5226316a6..0000000000000 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts.orig +++ /dev/null @@ -1,71 +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 { KibanaRequest } from 'src/core/server'; -import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; -import { createExternalCaseClient } from '../../../client'; -import { - AlertService, - CaseService, - CaseConfigureService, - ConnectorMappingsService, - CaseUserActionService, -} from '../../../services'; -import { authenticationMock } from '../__fixtures__'; -import type { CasesRequestHandlerContext } from '../../../types'; -import { createActionsClient } from './mock_actions_client'; - -export const createRouteContext = async (client: any, badAuth = false) => { - const actionsMock = createActionsClient(); - - const log = loggingSystemMock.create().get('case'); - const esClient = elasticsearchServiceMock.createElasticsearchClient(); - - const caseService = new CaseService( - log, - badAuth ? authenticationMock.createInvalid() : authenticationMock.create() - ); - const caseConfigureServicePlugin = new CaseConfigureService(log); - const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseUserActionsServicePlugin = new CaseUserActionService(log); - - const caseConfigureService = await caseConfigureServicePlugin.setup(); - const userActionService = await caseUserActionsServicePlugin.setup(); - const alertsService = new AlertService(); - - const context = ({ - core: { - savedObjects: { - client, - }, - }, - actions: { getActionsClient: () => actionsMock }, - case: { - getCaseClient: () => caseClient, - }, - // TODO: remove - /* securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - },*/ - } as unknown) as CasesRequestHandlerContext; - - const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const caseClient = createExternalCaseClient({ - savedObjectsClient: client, - request: {} as KibanaRequest, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - scopedClusterClient: esClient, - }); - - return { context, services: { userActionService } }; -}; diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index d579d3167b551..a441a027769bf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -60,12 +60,7 @@ describe('DELETE case', () => { // so it makes a call to bulkGet first mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); @@ -88,12 +83,7 @@ describe('DELETE case', () => { // so it makes a call to bulkGet first mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }) - ); + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -116,12 +106,7 @@ describe('DELETE case', () => { // so it makes a call to bulkGet first mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }) - ); + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts.orig b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts.orig deleted file mode 100644 index d579d3167b551..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts.orig +++ /dev/null @@ -1,129 +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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, - mockCasesErrorTriggerData, - mockCaseComments, -} from '../__fixtures__'; -import { initDeleteCasesApi } from './delete_cases'; -import { CASES_URL } from '../../../../common/constants'; - -describe('DELETE case', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initDeleteCasesApi, 'delete'); - }); - it(`deletes the case. responds with 204`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['mock-id-1'], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(204); - }); - it(`returns an error when thrown from deleteCase service`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['not-real'], - }, - }); - - const mockSO = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - // Adding this because the delete API needs to get all the cases first to determine if they are removable or not - // so it makes a call to bulkGet first - mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - }); - it(`returns an error when thrown from getAllCaseComments service`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['bad-guy'], - }, - }); - - const mockSO = createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }); - - // Adding this because the delete API needs to get all the cases first to determine if they are removable or not - // so it makes a call to bulkGet first - mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - }); - it(`returns an error when thrown from deleteComment service`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['valid-id'], - }, - }); - - const mockSO = createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }); - - // Adding this because the delete API needs to get all the cases first to determine if they are removable or not - // so it makes a call to bulkGet first - mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - }); -}); 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 1cf770874d94d..98e399fa2ab4b 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 @@ -15,7 +15,6 @@ import { wrapError } from '../utils'; import { CASES_URL } from '../../../../common/constants'; import { CaseServiceSetup } from '../../../services'; -// TODO: move this to the service layer async function unremovableCases({ caseService, client, @@ -46,7 +45,6 @@ async function unremovableCases({ return parentCases.map((parentCase) => parentCase.id); } -// TODO: move this to the service layer async function deleteSubCases({ caseService, client, 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 8bd4b9d4311f7..a3311796fa5cd 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 @@ -21,6 +21,7 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro }), query: schema.object({ includeComments: schema.boolean({ defaultValue: true }), + includeSubCaseComments: schema.maybe(schema.boolean({ defaultValue: false })), }), }, }, @@ -30,7 +31,11 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro try { return response.ok({ - body: await caseClient.get({ id, includeComments: request.query.includeComments }), + body: await caseClient.get({ + id, + includeComments: request.query.includeComments, + includeSubCaseComments: request.query.includeSubCaseComments, + }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts.orig b/x-pack/plugins/case/server/routes/api/cases/get_case.ts.orig deleted file mode 100644 index 8bd4b9d4311f7..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts.orig +++ /dev/null @@ -1,40 +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 { schema } from '@kbn/config-schema'; - -import { RouteDeps } from '../types'; -import { wrapError } from '../utils'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; - -export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { - router.get( - { - path: CASE_DETAILS_URL, - validate: { - params: schema.object({ - case_id: schema.string(), - }), - query: schema.object({ - includeComments: schema.boolean({ defaultValue: true }), - }), - }, - }, - async (context, request, response) => { - const caseClient = context.case.getCaseClient(); - const id = request.params.case_id; - - try { - return response.ok({ - body: await caseClient.get({ id, includeComments: request.query.includeComments }), - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index 49801ea4e2f3e..bf398d1ffcf40 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -131,7 +131,10 @@ describe('Push case', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(caseClient.getAlerts).toHaveBeenCalledWith({ ids: ['test-id'] }); + expect(caseClient.getAlerts).toHaveBeenCalledWith({ + ids: ['test-id'], + indices: new Set(['test-index']), + }); }); it(`Calls execute with correct arguments`, async () => { 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 60dc2d2b1bad9..6d670c38bbf85 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 @@ -26,6 +26,10 @@ export function initPushCaseApi({ 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 actionsClient = context.actions?.getActionsClient(); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts.orig b/x-pack/plugins/case/server/routes/api/cases/push_case.ts.orig deleted file mode 100644 index 60dc2d2b1bad9..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts.orig +++ /dev/null @@ -1,55 +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 Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { wrapError, escapeHatch } from '../utils'; - -import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; -import { RouteDeps } from '../types'; -import { CASE_PUSH_URL } from '../../../../common/constants'; - -export function initPushCaseApi({ router }: RouteDeps) { - router.post( - { - path: CASE_PUSH_URL, - validate: { - params: escapeHatch, - body: escapeHatch, - }, - }, - async (context, request, response) => { - const caseClient = context.case.getCaseClient(); - const actionsClient = context.actions?.getActionsClient(); - - if (actionsClient == null) { - return response.badRequest({ body: 'Action client not found' }); - } - - try { - const params = pipe( - CasePushRequestParamsRt.decode(request.params), - fold(throwErrors(Boom.badRequest), identity) - ); - - return response.ok({ - body: await caseClient.push({ - caseClient, - actionsClient, - caseId: params.case_id, - connectorId: params.connector_id, - }), - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts.orig b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts.orig deleted file mode 100644 index 1c399a415e470..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts.orig +++ /dev/null @@ -1,85 +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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, -} from '../../__fixtures__'; -import { initGetCasesStatusApi } from './get_status'; -import { CASE_STATUS_URL } from '../../../../../common/constants'; -import { CaseType } from '../../../../../common/api'; - -describe('GET status', () => { - let routeHandler: RequestHandler; - const findArgs = { - fields: [], - page: 1, - perPage: 1, - type: 'cases', - sortField: 'created_at', - }; - - beforeAll(async () => { - routeHandler = await createRoute(initGetCasesStatusApi, 'get'); - }); - - it(`returns the status`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_STATUS_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { - ...findArgs, - filter: `((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, - }); - - expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { - ...findArgs, - filter: `((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, - }); - - expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { - ...findArgs, - filter: `((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, - }); - - expect(response.payload).toEqual({ - count_open_cases: 4, - count_in_progress_cases: 4, - count_closed_cases: 4, - }); - }); - - it(`returns an error when findCases throws`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_STATUS_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: [{ ...mockCases[0], id: 'throw-error-find' }], - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - }); -}); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts.orig b/x-pack/plugins/case/server/routes/api/utils.ts.orig deleted file mode 100644 index 6c7eb0318c5de..0000000000000 --- a/x-pack/plugins/case/server/routes/api/utils.ts.orig +++ /dev/null @@ -1,367 +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 { badRequest, 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'; -import { schema } from '@kbn/config-schema'; -import { - CustomHttpResponseOptions, - ResponseError, - SavedObject, - SavedObjectsFindResponse, -} from 'kibana/server'; - -import { - CaseResponse, - CasesFindResponse, - CommentResponse, - CommentsResponse, - CommentAttributes, - ESCaseConnector, - ESCaseAttributes, - CommentRequest, - ContextTypeUserRt, - ContextTypeAlertRt, - CommentRequestUserType, - CommentRequestAlertType, - CommentType, - excess, - throwErrors, - CaseStatuses, - CaseClientPostRequest, - AssociationType, - SubCaseAttributes, - SubCaseResponse, - CommentRequestGeneratedAlertType, - ContextTypeGeneratedAlertRt, - SubCasesFindResponse, - AttributesTypeAlerts, - User, -} from '../../../common/api'; -import { transformESConnectorToCaseConnector } from './cases/helpers'; - -import { SortFieldCase } from './types'; - -// TODO: refactor these functions to a common location, this is used by the caseClient too - -export const transformNewSubCase = ({ - createdAt, - createdBy, -}: { - createdAt: string; - createdBy: User; -}): SubCaseAttributes => { - return { - closed_at: null, - closed_by: null, - created_at: createdAt, - created_by: createdBy, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }; -}; - -export const transformNewCase = ({ - connector, - createdDate, - email, - // eslint-disable-next-line @typescript-eslint/naming-convention - full_name, - newCase, - username, -}: { - connector: ESCaseConnector; - createdDate: string; - email?: string | null; - full_name?: string | null; - newCase: CaseClientPostRequest; - username?: string | null; -}): ESCaseAttributes => ({ - ...newCase, - closed_at: null, - closed_by: null, - connector, - created_at: createdDate, - created_by: { email, full_name, username }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, -}); - -type NewCommentArgs = CommentRequest & { - associationType: AssociationType; - createdDate: string; - email?: string | null; - full_name?: string | null; - username?: string | null; -}; - -/** - * Return the IDs from the comment. - * - * @param comment the comment from the add comment request - */ -export const getAlertIds = (comment: CommentRequest): string[] => { - if (isGeneratedAlertContext(comment)) { - const ids: string[] = []; - if (Array.isArray(comment.alerts)) { - ids.push( - ...comment.alerts.map((alert: { _id: string }) => { - return alert._id; - }) - ); - } else { - ids.push(comment.alerts._id); - } - return ids; - } else if (isAlertContext(comment)) { - return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; - } else { - return []; - } -}; - -export const transformNewComment = ({ - associationType, - createdDate, - email, - // eslint-disable-next-line @typescript-eslint/naming-convention - full_name, - username, - ...comment -}: NewCommentArgs): CommentAttributes => { - if (isGeneratedAlertContext(comment)) { - const ids = getAlertIds(comment); - - return { - associationType, - alertId: ids, - index: comment.index, - type: comment.type, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }; - } else { - return { - associationType, - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }; - } -}; - -export function wrapError(error: any): CustomHttpResponseOptions { - const options = { statusCode: error.statusCode ?? 500 }; - const boom = isBoom(error) ? error : boomify(error, options); - return { - body: boom, - headers: boom.output.headers as { [key: string]: string }, - statusCode: boom.output.statusCode, - }; -} - -export const transformCases = ({ - casesMap, - countOpenCases, - countInProgressCases, - countClosedCases, - page, - perPage, - total, -}: { - casesMap: Map; - countOpenCases: number; - countInProgressCases: number; - countClosedCases: number; - page: number; - perPage: number; - total: number; -}): CasesFindResponse => ({ - page, - per_page: perPage, - total, - cases: Array.from(casesMap.values()), - count_open_cases: countOpenCases, - count_in_progress_cases: countInProgressCases, - count_closed_cases: countClosedCases, -}); - -export const transformSubCases = ({ - subCasesMap, - open, - inProgress, - closed, - page, - perPage, - total, -}: { - subCasesMap: Map; - open: number; - inProgress: number; - closed: number; - page: number; - perPage: number; - total: number; -}): SubCasesFindResponse => ({ - page, - per_page: perPage, - total, - // Squish all the entries in the map together as one array - subCases: Array.from(subCasesMap.values()).flat(), - count_open_cases: open, - count_in_progress_cases: inProgress, - count_closed_cases: closed, -}); - -export const flattenCaseSavedObject = ({ - savedObject, - comments = [], - totalComment = comments.length, - totalAlerts = 0, - subCases, -}: { - savedObject: SavedObject; - comments?: Array>; - totalComment?: number; - totalAlerts?: number; - subCases?: SubCaseResponse[]; -}): CaseResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - comments: flattenCommentSavedObjects(comments), - totalComment, - totalAlerts, - ...savedObject.attributes, - connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), - subCases, -}); - -export const flattenSubCaseSavedObject = ({ - savedObject, - comments = [], - totalComment = comments.length, - totalAlerts = 0, -}: { - savedObject: SavedObject; - comments?: Array>; - totalComment?: number; - totalAlerts?: number; -}): SubCaseResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - comments: flattenCommentSavedObjects(comments), - totalComment, - totalAlerts, - ...savedObject.attributes, -}); - -export const transformComments = ( - comments: SavedObjectsFindResponse -): CommentsResponse => ({ - page: comments.page, - per_page: comments.per_page, - total: comments.total, - comments: flattenCommentSavedObjects(comments.saved_objects), -}); - -export const flattenCommentSavedObjects = ( - savedObjects: Array> -): CommentResponse[] => - savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject) => { - return [...acc, flattenCommentSavedObject(savedObject)]; - }, []); - -export const flattenCommentSavedObject = ( - savedObject: SavedObject -): CommentResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - ...savedObject.attributes, -}); - -export const sortToSnake = (sortField: string | undefined): SortFieldCase => { - switch (sortField) { - case 'status': - return SortFieldCase.status; - case 'createdAt': - case 'created_at': - return SortFieldCase.createdAt; - case 'closedAt': - case 'closed_at': - return SortFieldCase.closedAt; - default: - return SortFieldCase.createdAt; - } -}; - -export const escapeHatch = schema.object({}, { unknowns: 'allow' }); - -/** - * A type narrowing function for user comments. Exporting so integration tests can use it. - */ -export const isUserContext = ( - context: CommentRequest | CommentAttributes -): context is CommentRequestUserType => { - return context.type === CommentType.user; -}; - -/** - * A type narrowing function for alert comments. Exporting so integration tests can use it. - */ -export const isAlertContext = ( - context: CommentRequest | CommentAttributes -): context is CommentRequestAlertType => { - return context.type === CommentType.alert; -}; - -/** - * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. - * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is - * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store - * both a generated and user attached alert in the same structure but this function is useful to determine which - * structure the new alert in the request has. - */ -export const isGeneratedAlertContext = ( - context: CommentRequest -): context is CommentRequestGeneratedAlertType => { - return context.type === CommentType.generatedAlert; -}; - -export const isAlertCommentSO = ( - comment: SavedObject -): comment is SavedObject => { - return ( - comment.attributes.type === CommentType.generatedAlert || - comment.attributes.type === CommentType.alert - ); -}; - -export const decodeComment = (comment: CommentRequest) => { - if (isUserContext(comment)) { - pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isAlertContext(comment)) { - pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isGeneratedAlertContext(comment)) { - pipe( - excess(ContextTypeGeneratedAlertRt).decode(comment), - fold(throwErrors(badRequest), identity) - ); - } -}; From 315a46d3650d5c61c1d05d40bac743ba33f1af94 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 9 Feb 2021 14:54:22 -0500 Subject: [PATCH 40/47] Push to connector gets all sub case comments --- x-pack/plugins/case/server/client/cases/push.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index e7f54b7fa3235..effa57bc32880 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -181,6 +181,7 @@ export const push = async ({ page: 1, perPage: theCase?.totalComment ?? 0, }, + includeSubCaseComments: true, }), ]); } catch (e) { From 886badd69af9518e9dfa2217bc9776821ac4c713 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 9 Feb 2021 17:50:11 -0500 Subject: [PATCH 41/47] Writing more tests and cleaning up --- .../plugins/case/common/api/cases/sub_case.ts | 1 - .../plugins/case/server/client/cases/mock.ts | 3 + x-pack/plugins/case/server/client/types.ts | 1 - .../api/cases/comments/delete_all_comments.ts | 3 +- .../api/cases/comments/delete_comment.ts | 1 + .../api/cases/comments/get_all_comment.ts | 7 + .../case/server/routes/api/cases/push_case.ts | 1 - .../api/cases/sub_case/patch_sub_cases.ts | 1 - .../use_post_push_to_service.tsx.orig | 226 ------------------ .../tests/cases/comments/delete_comment.ts | 72 +++++- .../tests/cases/comments/get_all_comments.ts | 109 +++++++++ .../basic/tests/cases/comments/get_comment.ts | 2 + .../tests/cases/comments/patch_comment.ts | 30 +++ .../tests/cases/sub_cases/patch_sub_cases.ts | 93 +++++++ .../case_api_integration/basic/tests/index.ts | 2 + 15 files changed, 320 insertions(+), 232 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx.orig create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts index 4a0ecdd3176f0..c46f87c547d50 100644 --- a/x-pack/plugins/case/common/api/cases/sub_case.ts +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -13,7 +13,6 @@ import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; import { CaseStatusRt } from './status'; -// TODO: comments const SubCaseBasicRt = rt.type({ status: CaseStatusRt, }); diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts index 57e2d4373a52b..2be9f41059831 100644 --- a/x-pack/plugins/case/server/client/cases/mock.ts +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -10,6 +10,7 @@ import { CommentType, ConnectorMappingsAttributes, CaseUserActionsResponse, + AssociationType, } from '../../../common/api'; import { BasicParams } from './types'; @@ -27,6 +28,7 @@ const entity = { }; export const comment: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user as const, @@ -48,6 +50,7 @@ export const comment: CommentResponse = { }; export const commentAlert: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', alertId: 'alert-id-1', index: 'alert-index-1', diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 6a7d499630543..9bc13a2cb71e0 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -47,7 +47,6 @@ export interface CaseClientGet { export interface CaseClientPush { actionsClient: ActionsClient; - caseClient: CaseClient; caseId: string; connectorId: string; } 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 2713c77f794c7..fa6548ab4b343 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 @@ -60,7 +60,8 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic action: 'delete', actionAt: deleteDate, actionBy: { username, full_name, email }, - caseId: id, + caseId: request.params.case_id, + 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 b0bb98fa25b91..f2937eb485a71 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 @@ -69,6 +69,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, + subCaseId: request.query?.subCaseID, commentId: request.params.comment_id, fields: ['comment'], }), 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 f0c40ccb862d9..730b1b92a8a07 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 @@ -12,6 +12,7 @@ import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/ import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { defaultSortField } from '../../../../common'; export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -38,12 +39,18 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { comments = await caseService.getAllSubCaseComments({ client, id: request.query.subCaseID, + options: { + sortField: defaultSortField, + }, }); } else { comments = await caseService.getAllCaseComments({ client, id: request.params.case_id, includeSubCaseComments: request.query?.includeSubCaseComments, + options: { + sortField: defaultSortField, + }, }); } 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 6d670c38bbf85..c1f0a2cb59cb1 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 @@ -45,7 +45,6 @@ export function initPushCaseApi({ router }: RouteDeps) { return response.ok({ body: await caseClient.push({ - caseClient, actionsClient, caseId: params.case_id, connectorId: params.connector_id, 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 6067fdef409a6..ed57a3d6dc389 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 @@ -316,7 +316,6 @@ async function update({ [] ); - // TODO: figure out what we need to save await userActionService.postUserActions({ client, actions: buildSubCaseUserActions({ diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx.orig b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx.orig deleted file mode 100644 index f82051582e8f6..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx.orig +++ /dev/null @@ -1,226 +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 { useReducer, useCallback, useRef, useEffect } from 'react'; -import { CaseConnector } from '../../../../case/common/api'; -import { - errorToToaster, - useStateToaster, - displaySuccessToast, -} from '../../common/components/toasters'; - -import { pushCase } from './api'; -import * as i18n from './translations'; -import { Case } from './types'; - -interface PushToServiceState { - isLoading: boolean; - isError: boolean; -} -type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' }; - -const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { - switch (action.type) { - case 'FETCH_INIT': - return { - ...state, - isLoading: true, - isError: false, - }; - case 'FETCH_SUCCESS': - return { - ...state, - isLoading: false, - isError: false, - }; - case 'FETCH_FAILURE': - return { - ...state, - isLoading: false, - isError: true, - }; - default: - return state; - } -}; - -interface PushToServiceRequest { - caseId: string; - connector: CaseConnector; -} - -export interface UsePostPushToService extends PushToServiceState { - pushCaseToExternalService: ({ - caseId, - connector, - }: PushToServiceRequest) => Promise; -} - -export const usePostPushToService = (): UsePostPushToService => { - const [state, dispatch] = useReducer(dataFetchReducer, { - isLoading: false, - isError: false, - }); - const [, dispatchToaster] = useStateToaster(); - const cancel = useRef(false); - const abortCtrl = useRef(new AbortController()); - - const pushCaseToExternalService = useCallback( - async ({ caseId, connector }: PushToServiceRequest) => { - try { - dispatch({ type: 'FETCH_INIT' }); - abortCtrl.current.abort(); - cancel.current = false; - abortCtrl.current = new AbortController(); - - const response = await pushCase(caseId, connector.id, abortCtrl.current.signal); - - if (!cancel.current) { - dispatch({ type: 'FETCH_SUCCESS' }); - displaySuccessToast( - i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connector.name), - dispatchToaster - ); - } - - return response; - } catch (error) { - if (!cancel.current) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); - dispatch({ type: 'FETCH_FAILURE' }); - } - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - useEffect(() => { - return () => { - abortCtrl.current.abort(); - cancel.current = true; - }; - }, []); - -<<<<<<< HEAD - const from = moment(alert['@timestamp'] ?? new Date()) - .subtract(ellapsedTimeRule) - .toISOString(); - const to = moment(alert['@timestamp'] ?? new Date()).toISOString(); - - return { to, from }; -}; - -const getAlertFilterUrl = (alert: Alert): string => { - const { to, from } = determineToAndFrom(alert); - return `?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:${alert._id}),type:phrase),query:(match:(_id:(query:${alert._id},type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)),timeline:(linkTo:!(global),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)))`; -}; - -const getCommentContent = ( - comment: Comment, - alerts: Record, - formatUrl: FormatUrl -): string => { - if (comment.type === CommentType.user) { - return comment.comment; - } else if (comment.type === CommentType.alert) { - // TODO: handle generated alerts here to - // TODO: clean this up - const alertId = Array.isArray(comment.alertId) - ? comment.alertId.length > 0 - ? comment.alertId[0] - : '' - : comment.alertId; - const alert = alerts[alertId]; - const ruleDetailsLink = formatUrl(getRuleDetailsUrl(alert.rule.id), { - absolute: true, - skipSearch: true, - }); - - return `[${i18n.ALERT}](${ruleDetailsLink}${getAlertFilterUrl(alert)}) ${ - i18n.ALERT_ADDED_TO_CASE - }.`; - } - - return ''; -}; - -export const formatServiceRequestData = ({ - myCase, - connector, - caseServices, - alerts, - formatUrl, -}: { - myCase: Case; - connector: CaseConnector; - caseServices: CaseServices; - alerts: Record; - formatUrl: FormatUrl; -}): ServiceConnectorCaseParams => { - const { - id: caseId, - createdAt, - createdBy, - comments, - description, - title, - updatedAt, - updatedBy, - } = myCase; - const actualExternalService = caseServices[connector.id] ?? null; - - return { - savedObjectId: caseId, - createdAt, - createdBy: { - fullName: createdBy.fullName ?? null, - username: createdBy?.username ?? '', - }, - comments: comments - .filter( - (c) => - actualExternalService == null || actualExternalService.commentsToUpdate.includes(c.id) - ) - .map((c) => ({ - commentId: c.id, - comment: getCommentContent(c, alerts, formatUrl), - createdAt: c.createdAt, - createdBy: { - fullName: c.createdBy.fullName ?? null, - username: c.createdBy.username ?? '', - }, - updatedAt: c.updatedAt, - updatedBy: - c.updatedBy != null - ? { - fullName: c.updatedBy.fullName ?? null, - username: c.updatedBy.username ?? '', - } - : null, - })), - description, - externalId: actualExternalService?.externalId ?? null, - title, - ...(connector.fields ?? {}), - updatedAt, - updatedBy: - updatedBy != null - ? { - fullName: updatedBy.fullName ?? null, - username: updatedBy.username ?? '', - } - : null, - }; -======= - return { ...state, pushCaseToExternalService }; ->>>>>>> 810e4ab8e8206949965c89c889aac2fc396c4111 -}; 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 520fe6310d5e2..f908a369b46d7 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 @@ -10,7 +10,15 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -76,5 +84,67 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(404); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('deletes a comment from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .delete( + `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}?subCaseID=${ + caseInfo.subCase!.id + }` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + const { body } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(body.length).to.eql(0); + }); + + 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}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + let { body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(allComments.length).to.eql(2); + + await supertest + .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + ({ body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + )); + + // no comments for the sub case + expect(allComments.length).to.eql(0); + + ({ body: allComments } = await supertest.get(`${CASES_URL}/${caseInfo.id}/comments`)); + + // no comments for the collection + expect(allComments.length).to.eql(0); + }); + }); }); }; 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 new file mode 100644 index 0000000000000..1af16f9e54563 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentType } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_all_comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get multiple comments for a single case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(comments.length).to.eql(2); + }); + + it('should get comments from a case and its sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?includeSubCaseComments=true`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + 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`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should not find any comments for an invalid case id', async () => { + const { body } = await supertest + .get(`${CASES_URL}/fake-id/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(0); + }); + }); +}; 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 b041002cfd940..389ec3f088f95 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 @@ -56,6 +56,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment).to.eql(patchedCase.comments[0]); }); + it('should get a sub case comment', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body: comment }: { body: CommentResponse } = await supertest @@ -63,6 +64,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); expect(comment.type).to.be(CommentType.generatedAlert); }); + it('unhappy path - 404s when comment is not there', async () => { await supertest .get(`${CASES_URL}/fake-id/comments/fake-comment`) 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 c9f2206bb022c..2250b481c3729 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 @@ -83,6 +83,36 @@ export default ({ getService }: FtrProviderContext): void => { expect(patchedSubCaseUpdatedComment.subCase.comments[1].type).to.be(CommentType.user); expect(patchedSubCaseUpdatedComment.subCase.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}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }) + .expect(400); + }); + + 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}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.generatedAlert, + alerts: [{ _id: 'id1' }], + index: 'test-index', + }) + .expect(400); + }); }); it('should patch a comment', async () => { 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 new file mode 100644 index 0000000000000..66422724b5677 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts @@ -0,0 +1,93 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../../plugins/case/common/constants'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CaseStatuses, SubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should update the status of a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await setStatus({ + supertest, + cases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + const { body: subCase }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .expect(200); + + expect(subCase.status).to.eql(CaseStatuses['in-progress']); + }); + + it('404s when sub case id is invalid', async () => { + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: 'fake-id', + version: 'blah', + status: CaseStatuses.open, + }, + ], + }) + .expect(404); + }); + + it('406s when updating invalid fields for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + type: 'blah', + }, + ], + }) + .expect(406); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index 0343f4470013b..837e6503084a7 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -16,6 +16,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./cases/comments/delete_comment')); loadTestFile(require.resolve('./cases/comments/find_comments')); loadTestFile(require.resolve('./cases/comments/get_comment')); + loadTestFile(require.resolve('./cases/comments/get_all_comments')); loadTestFile(require.resolve('./cases/comments/patch_comment')); loadTestFile(require.resolve('./cases/comments/post_comment')); loadTestFile(require.resolve('./cases/delete_cases')); @@ -33,6 +34,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); loadTestFile(require.resolve('./connectors/case')); + loadTestFile(require.resolve('./cases/sub_cases/patch_sub_cases')); loadTestFile(require.resolve('./cases/sub_cases/delete_sub_cases')); loadTestFile(require.resolve('./cases/sub_cases/get_sub_case')); loadTestFile(require.resolve('./cases/sub_cases/find_sub_cases')); From f69b0dd5b1871f2c0c79b6febc22897e0e9be520 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 9 Feb 2021 17:51:03 -0500 Subject: [PATCH 42/47] Updating push functionality for generated alerts and sub cases --- x-pack/plugins/case/server/client/cases/utils.test.ts | 4 ++-- x-pack/plugins/case/server/client/cases/utils.ts | 9 ++++++--- x-pack/test/case_api_integration/common/lib/utils.ts | 3 +++ 3 files changed, 11 insertions(+), 5 deletions(-) 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 dca2c34602678..361d0fb561afd 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -537,12 +537,12 @@ describe('utils', () => { }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-1', }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-2', }, ]); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 6974fd4ffa288..78bdc6d282c69 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -38,6 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; +import { getAlertIds } from '../../routes/api/utils'; export const getLatestPushInfo = ( connectorId: string, @@ -66,8 +67,9 @@ const isConnectorSupported = (connectorId: string): connectorId is FormatterConn const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; - } else if (comment.type === CommentType.alert) { - return `Alert with id ${comment.alertId} added to case`; + } else if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { + const ids = getAlertIds(comment); + return `Alert with ids ${ids.join(', ')} added to case`; } return ''; @@ -306,9 +308,10 @@ export const getCommentContextFromAttributes = ( type: CommentType.user, comment: attributes.comment, }; + case CommentType.generatedAlert: case CommentType.alert: return { - type: CommentType.alert, + type: attributes.type, alertId: attributes.alertId, index: attributes.index, }; 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 3cf164e062a1e..9b51f8cb2c1b3 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -103,6 +103,9 @@ export const createCaseAction = async (supertest: st.SuperTest, id: string From 0cce7233dc44d19b92adefbb3c0a0d32af06c87a Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Tue, 9 Feb 2021 18:05:15 -0500 Subject: [PATCH 43/47] Adding comment about updating collection sync --- x-pack/plugins/case/server/client/cases/update.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index bb853b5039648..a1646c7f12866 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -292,11 +292,16 @@ export const update = async ({ }, }); - // TODO: double check that this logic will get all sub case comments and include them in the updates + // TODO: if a collection's sync settings are change we need to get all the sub cases and sync their alerts according + // to the SUB CASE status not the collection's status + // I think what we can do is get all comments for a collection's sub cases in a single call, then group the alerts by + // sub case ID, then query for all those sub cases that we need, grab their status, build another map of status + // to {ids: string[], indices: Set} then iterate over the map and perform a updateAlertsStatus + // for each group of alerts for the 3 statuses. + const caseComments = (await caseService.getAllCaseComments({ client: savedObjectsClient, id: theCase.id, - includeSubCaseComments: true, options: { fields: [], filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, From b6c296e3d49ab80d3ede8e1297503c793a7ddbe5 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 10 Feb 2021 19:25:27 -0500 Subject: [PATCH 44/47] Refactoring update alert status for sub cases and removing request and cleaning up --- .../plugins/case/common/api/cases/comment.ts | 65 ++++- .../server/client/alerts/update_status.ts | 1 - .../case/server/client/cases/create.ts | 9 +- .../plugins/case/server/client/cases/push.ts | 43 +-- .../case/server/client/cases/update.test.ts | 51 +++- .../case/server/client/cases/update.ts | 262 ++++++++++++++---- .../plugins/case/server/client/cases/utils.ts | 8 +- x-pack/plugins/case/server/client/client.ts | 24 +- .../case/server/client/comments/add.ts | 33 ++- .../plugins/case/server/client/index.test.ts | 9 +- x-pack/plugins/case/server/client/index.ts | 25 +- x-pack/plugins/case/server/client/mocks.ts | 4 +- x-pack/plugins/case/server/client/types.ts | 22 +- .../plugins/case/server/common/utils.test.ts | 10 +- x-pack/plugins/case/server/common/utils.ts | 34 ++- .../case/server/connectors/case/index.ts | 8 +- .../case/server/connectors/case/schema.ts | 4 +- x-pack/plugins/case/server/plugin.ts | 6 +- .../routes/api/__fixtures__/authc_mock.ts | 7 +- .../routes/api/__fixtures__/route_contexts.ts | 16 +- .../api/cases/comments/delete_all_comments.ts | 2 +- .../api/cases/comments/delete_comment.ts | 2 +- .../api/cases/comments/patch_comment.ts | 6 +- .../api/cases/configure/patch_configure.ts | 2 +- .../api/cases/configure/post_configure.ts | 2 +- .../server/routes/api/cases/delete_cases.ts | 2 +- .../case/server/routes/api/cases/helpers.ts | 2 - .../api/cases/sub_case/delete_sub_cases.ts | 2 +- .../api/cases/sub_case/patch_sub_cases.ts | 135 ++++++--- .../plugins/case/server/routes/api/utils.ts | 140 ++++++++-- .../case/server/scripts/sub_cases/index.ts | 28 +- x-pack/plugins/case/server/services/index.ts | 58 +++- .../basic/tests/connectors/case.ts | 3 +- .../case_api_integration/common/lib/mock.ts | 17 +- 34 files changed, 717 insertions(+), 325 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index bea6419d59483..2dc6dd4fde4d9 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -33,6 +33,8 @@ export const CommentAttributesBasicRt = rt.type({ updated_by: rt.union([UserRT, rt.null]), }); +export const GeneratedAlertRequestTypeField = 'generated_alert_request'; + export enum CommentType { user = 'user', alert = 'alert', @@ -44,34 +46,50 @@ export const ContextTypeUserRt = rt.type({ type: rt.literal(CommentType.user), }); -export const ContextTypeAlertRt = rt.type({ +/** + * This defines the structure of how alerts (generated or user attached) are stored in saved objects documents. + */ +export const AlertCommentAttributesRt = rt.type({ type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, }); +/** + * This defines the structure of an alert attached by a user. + */ +export const AlertCommentRequestRt = rt.type({ + type: rt.literal(CommentType.alert), + alertId: rt.union([rt.array(rt.string), rt.string]), + index: rt.string, +}); + const AlertIDRt = rt.type({ _id: rt.string, }); -export const ContextTypeGeneratedAlertRt = rt.type({ - type: rt.literal(CommentType.generatedAlert), +/** + * This defines the structure of generated alerts attached by a detections rule. + */ +export const GeneratedAlertCommentRequestRt = rt.type({ + type: rt.literal(GeneratedAlertRequestTypeField), alerts: rt.union([rt.array(AlertIDRt), AlertIDRt]), index: rt.string, }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); -const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); +const AttributesTypeAlertsRt = rt.intersection([ + AlertCommentAttributesRt, + CommentAttributesBasicRt, +]); const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); -const ContextBasicRt = rt.union([ +export const CommentRequestRt = rt.union([ ContextTypeUserRt, - ContextTypeAlertRt, - ContextTypeGeneratedAlertRt, + AlertCommentRequestRt, + GeneratedAlertCommentRequestRt, ]); -export const CommentRequestRt = ContextBasicRt; - export const CommentResponseRt = rt.intersection([ CommentAttributesRt, rt.type({ @@ -90,12 +108,14 @@ export const CommentResponseTypeAlertsRt = rt.intersection([ export const AllCommentsResponseRT = rt.array(CommentResponseRt); +const CommentPatchRequestTypesRt = rt.union([ContextTypeUserRt, AlertCommentAttributesRt]); + export const CommentPatchRequestRt = rt.intersection([ /** * Partial updates are not allowed. * We want to prevent the user for changing the type without removing invalid fields. */ - rt.union([ContextTypeUserRt, ContextTypeAlertRt]), + CommentPatchRequestTypesRt, rt.type({ id: rt.string, version: rt.string }), ]); @@ -106,7 +126,10 @@ export const CommentPatchRequestRt = rt.intersection([ * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. */ export const CommentPatchAttributesRt = rt.intersection([ - rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(ContextTypeAlertRt.props)]), + rt.union([ + rt.partial(CommentAttributesBasicRt.props), + rt.partial(AlertCommentAttributesRt.props), + ]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -119,7 +142,6 @@ export const CommentsResponseRt = rt.type({ export const AllCommentsResponseRt = rt.array(CommentResponseRt); -export type AttributesTypeAlerts = rt.TypeOf; export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; @@ -129,11 +151,26 @@ export type CommentsResponse = rt.TypeOf; export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; -export type CommentRequestAlertType = rt.TypeOf; +export type CommentRequestAlertType = rt.TypeOf; + +/** + * This is different than a CommentRequest because a patch request only allows the alert and user types to be patched. + * A CommentRequest allows alerts, user, and generated_alerts + */ +export type CommentPatchRequestTypes = rt.TypeOf; + +/** + * This type includes the index, alertIds, and the basic attributes (who added it, when etc) + */ +export type AttributesTypeAlerts = rt.TypeOf; +/** + * This type includes only the index, alertIds, it does not include the basic attributes. + */ +export type AttributesTypeAlertsWithoutBasic = rt.TypeOf; /** * This type represents a generated alert (or alerts) from a rule. The difference between this and a user alert * is that this format expects the included alerts to have the structure { _id: string }. When it is saved the outer * object will be stripped off and the _id will be stored in the alertId field. */ -export type CommentRequestGeneratedAlertType = rt.TypeOf; +export type CommentRequestGeneratedAlertType = rt.TypeOf; 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 a142079a7ccc8..cb18bd4fc16e3 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -17,7 +17,6 @@ interface UpdateAlertsStatusArgs { scopedClusterClient: ElasticsearchClient; } -// TODO: remove this file export const updateAlertsStatus = async ({ alertsService, ids, diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index d3aa661c5fb51..ee47c59072fdd 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 { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { @@ -21,6 +21,7 @@ import { CaseClientPostRequestRt, CasePostRequest, CaseType, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -37,7 +38,7 @@ import { interface CreateCaseArgs { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; - request: KibanaRequest; + user: User; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; theCase: CasePostRequest; @@ -48,7 +49,7 @@ export const create = async ({ caseService, caseConfigureService, userActionService, - request, + user, theCase, }: CreateCaseArgs): Promise => { // default to an individual case if the type is not defined. @@ -60,7 +61,7 @@ export const create = async ({ ); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const createdDate = new Date().toISOString(); const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index effa57bc32880..881e586b5a4a3 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -7,13 +7,12 @@ import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; import { - KibanaRequest, SavedObjectsBulkUpdateResponse, SavedObjectsClientContract, SavedObjectsUpdateResponse, } from 'kibana/server'; -import { ActionsClient } from '../../../../actions/server'; -import { flattenCaseSavedObject, getAlertIds } from '../../routes/api/utils'; +import { ActionResult, ActionsClient } from '../../../../actions/server'; +import { flattenCaseSavedObject, getAlertIndicesAndIDs } from '../../routes/api/utils'; import { ActionConnector, @@ -23,16 +22,18 @@ import { ExternalServiceResponse, ESCaseAttributes, CommentAttributes, + CaseUserActionsResponse, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils'; +import { createIncident, getCommentContextFromAttributes } from './utils'; import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup, } from '../../services'; -import { CaseClientImpl } from '../client'; +import { CaseClientHandler } from '../client'; const createError = (e: Error | BoomType, message: string): Error | BoomType => { if (isBoom(e)) { @@ -44,20 +45,15 @@ const createError = (e: Error | BoomType, message: string): Error | BoomType => return Error(message); }; -interface AlertInfo { - ids: string[]; - indices: Set; -} - interface PushParams { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; - request: KibanaRequest; + user: User; caseId: string; connectorId: string; - caseClient: CaseClientImpl; + caseClient: CaseClientHandler; actionsClient: ActionsClient; } @@ -66,16 +62,16 @@ export const push = async ({ caseService, caseConfigureService, userActionService, - request, caseClient, actionsClient, connectorId, caseId, + user, }: PushParams): Promise => { /* Start of push to external service */ - let theCase; - let connector; - let userActions; + let theCase: CaseResponse; + let connector: ActionResult; + let userActions: CaseUserActionsResponse; let alerts; let connectorMappings; let externalServiceIncident; @@ -98,16 +94,7 @@ export const push = async ({ ); } - const { ids, indices }: AlertInfo = theCase?.comments - ?.filter(isCommentAlertType) - .reduce( - (acc, comment) => { - acc.ids.push(...getAlertIds(comment)); - acc.indices.add(comment.index); - return acc; - }, - { ids: [], indices: new Set() } - ) ?? { ids: [], indices: new Set() }; + const { ids, indices } = getAlertIndicesAndIDs(theCase?.comments); try { alerts = await caseClient.getAlerts({ @@ -160,14 +147,12 @@ export const push = async ({ /* End of push to external service */ /* Start of update case with push information */ - let user; let myCase; let myCaseConfigure; let comments; try { - [user, myCase, myCaseConfigure, comments] = await Promise.all([ - caseService.getUser({ request }), + [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ client: savedObjectsClient, id: caseId, 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 16c83b0ec1f61..53e233c74deb4 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -404,7 +404,18 @@ describe('update', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, - caseCommentSavedObject: [{ ...mockCaseComments[3] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); @@ -531,24 +542,50 @@ describe('update', () => { ...mockCases[1], }, ], - caseCommentSavedObject: [{ ...mockCaseComments[3] }, { ...mockCaseComments[4] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + { + ...mockCaseComments[4], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-2', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.update(patchCases); - + /** + * the update code will put each comment into a status bucket and then make at most 1 call + * to ES for each status bucket + * Now instead of doing a call per case to get the comments, it will do a single call with all the cases + * and sub cases and get all the comments in one go + */ expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(1, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id'], status: 'open', - indices: new Set(['test-index', 'test-index-2']), + indices: new Set(['test-index']), }); expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(2, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id-2'], status: 'closed', - indices: new Set(['test-index', 'test-index-2']), + indices: new Set(['test-index-2']), }); }); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index a1646c7f12866..915746e270ade 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -11,12 +11,16 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { - KibanaRequest, SavedObject, SavedObjectsClientContract, SavedObjectsFindResponse, + SavedObjectsFindResult, } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + AlertInfo, + flattenCaseSavedObject, + isGenOrAlertCommentAttributes, +} from '../../routes/api/utils'; import { throwErrors, @@ -31,6 +35,9 @@ import { ESCaseAttributes, CaseType, CasesPatchRequest, + AssociationType, + CommentAttributes, + User, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -39,8 +46,13 @@ import { } from '../../routes/api/cases/helpers'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; import { CaseClientImpl } from '..'; +import { addAlertInfoToStatusMap } from '../../common'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. @@ -130,11 +142,187 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ } } +/** + * Get the id from a reference in a comment for a specific type. + */ +function getID( + comment: SavedObject, + type: typeof CASE_SAVED_OBJECT | typeof SUB_CASE_SAVED_OBJECT +): string | undefined { + return comment.references.find((ref) => ref.type === type)?.id; +} + +/** + * Gets all the alert comments (generated or user alerts) for the requested cases. + */ +async function getAlertComments({ + casesToSync, + caseService, + client, +}: { + casesToSync: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const idsOfCasesToSync = casesToSync.map((casePatchReq) => casePatchReq.id); + + // getAllCaseComments will by default get all the comments, unless page or perPage fields are set + return caseService.getAllCaseComments({ + client, + id: idsOfCasesToSync, + includeSubCaseComments: true, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Returns a map of sub case IDs to their status. This uses a group of alert comments to determine which sub cases should + * be retrieved. This is based on whether the comment is associated to a sub case. + */ +async function getSubCasesToStatus({ + totalAlerts, + caseService, + client, +}: { + totalAlerts: SavedObjectsFindResponse; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const subCasesToRetrieve = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if ( + isGenOrAlertCommentAttributes(alertComment.attributes) && + alertComment.attributes.associationType === AssociationType.subCase + ) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + if (id !== undefined) { + acc.add(id); + } + } + return acc; + }, new Set()); + + const subCases = await caseService.getSubCases({ + ids: Array.from(subCasesToRetrieve.values()), + client, + }); + + return subCases.saved_objects.reduce((acc, subCase) => { + // log about the sub cases that we couldn't find + if (!subCase.error) { + acc.set(subCase.id, subCase.attributes.status); + } + return acc; + }, new Map()); +} + +/** + * Returns what status the alert comment should have based on whether it is associated to a case or sub case. + */ +function getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, +}: { + alertComment: SavedObjectsFindResult; + casesToSyncToStatus: Map; + subCasesToStatus: Map; +}): CaseStatuses { + let status: CaseStatuses = CaseStatuses.open; + if (alertComment.attributes.associationType === AssociationType.case) { + const id = getID(alertComment, CASE_SAVED_OBJECT); + // We should log if we can't find the status + // attempt to get the case status from our cases to sync map if we found the ID otherwise default to open + status = + id !== undefined ? casesToSyncToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } else if (alertComment.attributes.associationType === AssociationType.subCase) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + status = id !== undefined ? subCasesToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } + return status; +} + +/** + * Updates the alert ID's status field based on the patch requests + */ +async function updateAlerts({ + casesWithSyncSettingChangedToOn, + casesWithStatusChangedAndSynced, + casesMap, + caseService, + client, + caseClient, +}: { + casesWithSyncSettingChangedToOn: ESCasePatchRequest[]; + casesWithStatusChangedAndSynced: ESCasePatchRequest[]; + casesMap: Map>; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClientImpl; +}) { + /** + * It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes + * last so when the map is built we will use the last status change as the source of truth. + */ + const casesToSync = [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]; + + // build a map of case id to the status it has + // this will have collections in it but the alerts should be associated to sub cases and not collections so it shouldn't + // matter. + const casesToSyncToStatus = casesToSync.reduce((acc, caseInfo) => { + acc.set( + caseInfo.id, + caseInfo.status ?? casesMap.get(caseInfo.id)?.attributes.status ?? CaseStatuses.open + ); + return acc; + }, new Map()); + + // get all the alerts for all the alert comments for all cases and collections. Collections themselves won't have any + // but their sub cases could + const totalAlerts = await getAlertComments({ + casesToSync, + caseService, + client, + }); + + // get a map of sub case id to the sub case status + const subCasesToStatus = await getSubCasesToStatus({ totalAlerts, client, caseService }); + + // create a map of the case statuses to the alert information that we need to update for that status + // This allows us to make at most 3 calls to ES, one for each status type that we need to update + // One potential improvement here is to do a tick (set timeout) to reduce the memory footprint if that becomes an issue + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isGenOrAlertCommentAttributes(alertComment.attributes)) { + const status = getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, + }); + + 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, + }); + } + } +} + interface UpdateArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; - request: KibanaRequest; + user: User; caseClient: CaseClientImpl; cases: CasesPatchRequest; } @@ -143,7 +331,7 @@ export const update = async ({ savedObjectsClient, caseService, userActionService, - request, + user, caseClient, cases, }: UpdateArgs): Promise => { @@ -220,7 +408,7 @@ export const update = async ({ }); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client: savedObjectsClient, @@ -279,59 +467,15 @@ export const update = async ({ ); }); - for (const theCase of [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]) { - const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); - const totalComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - id: theCase.id, - options: { - fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - page: 1, - perPage: 1, - }, - }); - - // TODO: if a collection's sync settings are change we need to get all the sub cases and sync their alerts according - // to the SUB CASE status not the collection's status - // I think what we can do is get all comments for a collection's sub cases in a single call, then group the alerts by - // sub case ID, then query for all those sub cases that we need, grab their status, build another map of status - // to {ids: string[], indices: Set} then iterate over the map and perform a updateAlertsStatus - // for each group of alerts for the 3 statuses. - - const caseComments = (await caseService.getAllCaseComments({ - client: savedObjectsClient, - id: theCase.id, - options: { - fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - page: 1, - perPage: totalComments.total, - }, - // The filter guarantees that the comments will be of type alert - })) as SavedObjectsFindResponse<{ alertId: string | string[]; index: string }>; - - // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) - const idsAndIndices = caseComments.saved_objects.reduce( - (acc: { ids: string[]; indices: Set }, comment) => { - const alertId = comment.attributes.alertId; - const ids = Array.isArray(alertId) ? alertId : [alertId]; - acc.ids.push(...ids); - acc.indices.add(comment.attributes.index); - return acc; - }, - { ids: [], indices: new Set() } - ); - - if (idsAndIndices.ids.length > 0) { - caseClient.updateAlertsStatus({ - ids: idsAndIndices.ids, - // Either there is a status update or the syncAlerts got turned on. - status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, - indices: idsAndIndices.indices, - }); - } - } + // 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) => diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 78bdc6d282c69..2b0dcebf143d8 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -19,7 +19,7 @@ import { ConnectorTypes, CommentAttributes, CommentRequestUserType, - CommentRequestAlertType, + AttributesTypeAlertsWithoutBasic, } from '../../../common/api'; import { ActionsClient } from '../../../../actions/server'; import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors'; @@ -38,7 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; -import { getAlertIds } from '../../routes/api/utils'; +import { getAlertIdsFromAttributes } from '../../routes/api/utils'; export const getLatestPushInfo = ( connectorId: string, @@ -68,7 +68,7 @@ const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; } else if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { - const ids = getAlertIds(comment); + const ids = getAlertIdsFromAttributes(comment); return `Alert with ids ${ids.join(', ')} added to case`; } @@ -301,7 +301,7 @@ export const isCommentAlertType = ( export const getCommentContextFromAttributes = ( attributes: CommentAttributes -): CommentRequestUserType | CommentRequestAlertType => { +): CommentRequestUserType | AttributesTypeAlertsWithoutBasic => { switch (attributes.type) { case CommentType.user: return { diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index 88c854ae89245..c684548decbe6 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, KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { CaseClientFactoryArguments, CaseClient, @@ -31,31 +31,31 @@ import { CaseUserActionServiceSetup, AlertServiceContract, } from '../services'; -import { CasesPatchRequest, CasePostRequest } from '../../common/api'; +import { CasesPatchRequest, CasePostRequest, User } from '../../common/api'; 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'; -// TODO: rename -export class CaseClientImpl implements CaseClient { +/** + * This class is a pass through for common case functionality (like creating, get a case). + */ +export class CaseClientHandler implements CaseClient { private readonly _scopedClusterClient: ElasticsearchClient; private readonly _caseConfigureService: CaseConfigureServiceSetup; private readonly _caseService: CaseServiceSetup; private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; - private readonly request: KibanaRequest; + private readonly user: User; private readonly _savedObjectsClient: SavedObjectsClientContract; private readonly _userActionService: CaseUserActionServiceSetup; private readonly _alertsService: AlertServiceContract; - // TODO: refactor so these are created in the constructor instead of passed in constructor(clientArgs: CaseClientFactoryArguments) { this._scopedClusterClient = clientArgs.scopedClusterClient; this._caseConfigureService = clientArgs.caseConfigureService; this._caseService = clientArgs.caseService; this._connectorMappingsService = clientArgs.connectorMappingsService; - // TODO: extract this out so we just pass in the user information - this.request = clientArgs.request; + this.user = clientArgs.user; this._savedObjectsClient = clientArgs.savedObjectsClient; this._userActionService = clientArgs.userActionService; this._alertsService = clientArgs.alertsService; @@ -67,7 +67,7 @@ export class CaseClientImpl implements CaseClient { caseService: this._caseService, caseConfigureService: this._caseConfigureService, userActionService: this._userActionService, - request: this.request, + user: this.user, theCase: caseInfo, }); } @@ -77,7 +77,7 @@ export class CaseClientImpl implements CaseClient { savedObjectsClient: this._savedObjectsClient, caseService: this._caseService, userActionService: this._userActionService, - request: this.request, + user: this.user, cases, caseClient: this, }); @@ -91,7 +91,7 @@ export class CaseClientImpl implements CaseClient { caseClient: this, caseId, comment, - request: this.request, + user: this.user, }); } @@ -146,7 +146,7 @@ export class CaseClientImpl implements CaseClient { savedObjectsClient: this._savedObjectsClient, caseService: this._caseService, userActionService: this._userActionService, - request: this.request, + user: this.user, caseClient: this, caseConfigureService: this._caseConfigureService, }); diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 5b7c409ea51e5..7fb7193d8a114 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,8 +10,12 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server'; -import { decodeComment, getAlertIds, isGeneratedAlertContext } from '../../routes/api/utils'; +import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { + decodeCommentRequest, + getAlertIdsFromRequest, + isCommentRequestTypeGenAlert, +} from '../../routes/api/utils'; import { throwErrors, @@ -22,9 +26,10 @@ import { SubCaseAttributes, CommentRequest, CollectionWithSubCaseResponse, - ContextTypeGeneratedAlertRt, + GeneratedAlertCommentRequestRt, CommentRequestGeneratedAlertType, User, + GeneratedAlertRequestTypeField, } from '../../../common/api'; import { buildCaseUserActionItem, @@ -96,11 +101,11 @@ const addGeneratedAlerts = async ({ comment, }: AddCommentFromRuleArgs): Promise => { const query = pipe( - ContextTypeGeneratedAlertRt.decode(comment), + GeneratedAlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - decodeComment(comment); + decodeCommentRequest(comment); const createdDate = new Date().toISOString(); const caseInfo = await caseService.getCase({ @@ -109,7 +114,7 @@ const addGeneratedAlerts = async ({ }); if ( - query.type === CommentType.generatedAlert && + query.type === GeneratedAlertRequestTypeField && caseInfo.attributes.type !== CaseType.collection ) { throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); @@ -147,10 +152,10 @@ const addGeneratedAlerts = async ({ newComment.attributes.type === CommentType.generatedAlert) && caseInfo.attributes.settings.syncAlerts ) { - const ids = getAlertIds(query); + const ids = getAlertIdsFromRequest(query); await caseClient.updateAlertsStatus({ ids, - status: caseInfo.attributes.status, + status: subCase.attributes.status, indices: new Set([newComment.attributes.index]), }); } @@ -221,24 +226,24 @@ interface AddCommentArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; - request: KibanaRequest; + user: User; } export const addComment = async ({ savedObjectsClient, caseService, userActionService, - request, caseClient, caseId, comment, + user, }: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - if (isGeneratedAlertContext(comment)) { + if (isCommentRequestTypeGenAlert(comment)) { return addGeneratedAlerts({ caseId, comment, @@ -249,13 +254,13 @@ export const addComment = async ({ }); } - decodeComment(comment); + 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 } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const userInfo: User = { username, full_name, @@ -269,7 +274,7 @@ export const addComment = async ({ }); if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { - const ids = getAlertIds(query); + const ids = getAlertIdsFromRequest(query); await caseClient.updateAlertsStatus({ ids, status: updatedCase.status, diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 1545bc6b1249f..8a085bf29f214 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; import { elasticsearchServiceMock, savedObjectsClientMock, } from '../../../../../src/core/server/mocks'; +import { nullUser } from '../common'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -19,7 +19,7 @@ import { } from '../services/mocks'; jest.mock('./client'); -import { CaseClientImpl } from './client'; +import { CaseClientHandler } from './client'; import { createExternalCaseClient } from './index'; const esClient = elasticsearchServiceMock.createElasticsearchClient(); @@ -27,7 +27,6 @@ const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); const connectorMappingsService = connectorMappingsServiceMock(); -const request = {} as KibanaRequest; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); @@ -39,10 +38,10 @@ describe('createExternalCaseClient()', () => { caseConfigureService, caseService, connectorMappingsService, - request, + user: nullUser, savedObjectsClient, userActionService, }); - expect(CaseClientImpl).toHaveBeenCalledTimes(1); + expect(CaseClientHandler).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 06f0dfd40ee7d..6c8cf53b2ae27 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -6,28 +6,15 @@ */ import { CaseClientFactoryArguments, CaseClient } from './types'; -import { CaseClientImpl } from './client'; +import { CaseClientHandler } from './client'; -export { CaseClientImpl } from './client'; +export { CaseClientHandler as CaseClientImpl } from './client'; export { CaseClient } from './types'; -// TODO: this screws up the mocking because it won't mock out CaseClientImpl's methods -/* export const createExternalCaseClient = ( - clientArgs: CaseClientFactoryArguments -): CaseClientPluginContract => { - const client = new CaseClientImpl(clientArgs); - return { - create: async (args: CaseClientPostRequest) => client.create(args), - addComment: async (args: CaseClientAddComment) => client.addComment(args), - getFields: async (args: ConfigureFields) => client.getFields(args), - getMappings: async (args: MappingsClient) => client.getMappings(args), - update: async (args: CasesPatchRequest) => client.update(args), - updateAlertsStatus: async (args: CaseClientUpdateAlertsStatus) => - client.updateAlertsStatus(args), - }; -};*/ - +/** + * Create a CaseClientHandler to external services (other plugins). + */ export const createExternalCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { - const client = new CaseClientImpl(clientArgs); + const client = new CaseClientHandler(clientArgs); return client; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index f55a7f690a53c..302745913babb 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { AlertServiceContract, @@ -50,7 +49,6 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const esClient = elasticsearchServiceMock.createElasticsearchClient(); // const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const request = {} as KibanaRequest; const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); const caseService = new CaseService(log, auth); @@ -73,7 +71,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const caseClient = createExternalCaseClient({ savedObjectsClient, - request, + user: auth.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 9bc13a2cb71e0..a8f64227daf83 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { ElasticsearchClient, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { - CaseClientPostRequest, CasePostRequest, CaseResponse, CasesPatchRequest, @@ -19,6 +18,7 @@ import { ConnectorMappingsAttributes, GetFieldsResponse, CaseUserActionsResponse, + User, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -29,16 +29,6 @@ import { import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; import { CaseClientGetAlertsResponse } from './alerts/types'; -// TODO: Remove unused types - -export interface CaseClientCreate { - theCase: CaseClientPostRequest; -} - -export interface CaseClientUpdate { - cases: CasesPatchRequest; -} - export interface CaseClientGet { id: string; includeComments?: boolean; @@ -56,11 +46,6 @@ export interface CaseClientAddComment { comment: CommentRequest; } -export interface CaseClientAddInternalComment { - caseId: string; - comment: CommentRequest; -} - export interface CaseClientUpdateAlertsStatus { ids: string[]; status: CaseStatuses; @@ -87,8 +72,7 @@ export interface CaseClientFactoryArguments { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; - request: KibanaRequest; - // response: KibanaResponseFactory; + user: User; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts index cf1d6e3c91762..d383c09d9a7f9 100644 --- a/x-pack/plugins/case/server/common/utils.test.ts +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -6,7 +6,13 @@ */ import { SavedObjectsFindResponse } from 'kibana/server'; -import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { + AssociationType, + CommentAttributes, + CommentRequest, + CommentType, + GeneratedAlertRequestTypeField, +} from '../../common/api'; import { transformNewComment } from '../routes/api/utils'; import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; @@ -98,7 +104,7 @@ describe('common utils', () => { { alerts: [{ _id: 'a' }, { _id: 'b' }, { _id: 'c' }], index: '', - type: CommentType.generatedAlert, + type: GeneratedAlertRequestTypeField, }, ], }, diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index a05278d67d885..f259d5e87736d 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -6,13 +6,45 @@ */ import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; -import { CommentAttributes, CommentType } from '../../common/api'; +import { CaseStatuses, CommentAttributes, CommentType, User } from '../../common/api'; +import { AlertInfo, getAlertIndicesAndIDs } from '../routes/api/utils'; /** * Default sort field for querying saved objects. */ export const defaultSortField = 'created_at'; +/** + * Default unknown user + */ +export const nullUser: User = { username: null, full_name: null, email: null }; + +/** + * Adds the ids and indices to a map of statuses + */ +export function addAlertInfoToStatusMap({ + comment, + statusMap, + status, +}: { + comment: CommentAttributes; + statusMap: Map; + status: CaseStatuses; +}) { + const newAlertInfo = getAlertIndicesAndIDs([comment]); + + // combine the already accumulated ids and indices with the new ones from this alert comment + if (newAlertInfo.ids.length > 0 && newAlertInfo.indices.size > 0) { + const accAlertInfo = statusMap.get(status) ?? { ids: [], indices: new Set() }; + accAlertInfo.ids.push(...newAlertInfo.ids); + accAlertInfo.indices = new Set([ + ...accAlertInfo.indices.values(), + ...newAlertInfo.indices.values(), + ]); + statusMap.set(status, accAlertInfo); + } +} + /** * Combines multiple filter expressions using the specified operator and parenthesis if multiple expressions exist. * This will ignore empty string filters. If a single valid filter is found it will not wrap in parenthesis. diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 6017fe312e063..18fac90624554 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,7 +7,6 @@ import { curry } from 'lodash'; -import { KibanaRequest } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createExternalCaseClient } from '../../client'; @@ -21,6 +20,7 @@ import { import * as i18n from './translations'; import { GetActionTypeParams } from '..'; +import { nullUser } from '../../common'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -69,13 +69,11 @@ async function executor( let data: CaseExecutorResponse | null = null; const { savedObjectsClient, scopedClusterClient } = services; - // TODO: ??? calling a constructor in a curry generates this error, TypeError: _client.CaseClientImpl is not a constructor - // const caseClient = new CaseClientImpl({ const caseClient = createExternalCaseClient({ savedObjectsClient, scopedClusterClient, - // TODO: refactor this - request: {} as KibanaRequest, + // we might want the user information to be passed as part of the action request + user: nullUser, caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 1010eee652d90..1022f06bb61fc 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentType } from '../../../common/api'; +import { CommentType, GeneratedAlertRequestTypeField } from '../../../common/api'; import { validateConnector } from './validators'; // Reserved for future implementation @@ -25,7 +25,7 @@ const AlertIDSchema = schema.object( ); const ContextTypeAlertGroupSchema = schema.object({ - type: schema.literal(CommentType.generatedAlert), + type: schema.literal(GeneratedAlertRequestTypeField), alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), index: schema.string(), }); diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 279acf9e8951d..9bf7ca097a561 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -127,10 +127,11 @@ export class CasePlugin { context: CasesRequestHandlerContext, request: KibanaRequest ) => { + const user = await this.caseService!.getUser({ request }); return createExternalCaseClient({ scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: core.savedObjects.getScopedClient(request), - request, + user, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, connectorMappingsService: this.connectorMappingsService!, @@ -165,6 +166,7 @@ export class CasePlugin { }): IContextProvider => { return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); + const user = await caseService.getUser({ request }); return { getCaseClient: () => { return new CaseClientImpl({ @@ -175,7 +177,7 @@ export class CasePlugin { connectorMappingsService, userActionService, alertsService, - request, + user, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 51ba684bf7a7b..66d3ffe5f23d1 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -7,6 +7,7 @@ import { AuthenticatedUser } from '../../../../../security/server'; import { securityMock } from '../../../../../security/server/mocks'; +import { nullUser } from '../../../common'; function createAuthenticationMock({ currentUser, @@ -14,7 +15,11 @@ function createAuthenticationMock({ const { authc } = securityMock.createSetup(); authc.getCurrentUser.mockReturnValue( currentUser !== undefined - ? currentUser + ? // if we pass in null then use the null user (has null for each field) this is the default behavior + // for the CaseService getUser method + currentUser !== null + ? currentUser + : nullUser : ({ email: 'd00d@awesome.com', username: 'awesome', 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 dbcf5226316a6..492be96fb4aa9 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 @@ -5,7 +5,6 @@ * 2.0. */ -import { KibanaRequest } from 'src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; import { createExternalCaseClient } from '../../../client'; import { @@ -25,10 +24,9 @@ export const createRouteContext = async (client: any, badAuth = false) => { const log = loggingSystemMock.create().get('case'); const esClient = elasticsearchServiceMock.createElasticsearchClient(); - const caseService = new CaseService( - log, - badAuth ? authenticationMock.createInvalid() : authenticationMock.create() - ); + const authc = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + + const caseService = new CaseService(log, authc); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseUserActionsServicePlugin = new CaseUserActionService(log); @@ -47,18 +45,12 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, - // TODO: remove - /* securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - },*/ } as unknown) as CasesRequestHandlerContext; const connectorMappingsService = await connectorMappingsServicePlugin.setup(); const caseClient = createExternalCaseClient({ savedObjectsClient: client, - request: {} as KibanaRequest, + user: authc.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, 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 fa6548ab4b343..bcbf1828e1fde 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 @@ -32,7 +32,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); const id = request.query?.subCaseID ?? request.params.case_id; 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 f2937eb485a71..73307753a550d 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 @@ -34,7 +34,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); const myComment = await caseService.getComment({ 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 f7e2f580b5928..5a13b766cf4b7 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 @@ -18,7 +18,7 @@ import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, decodeComment } from '../../utils'; +import { escapeHatch, wrapError, decodeCommentPatch } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { CaseServiceSetup } from '../../../../services'; @@ -81,7 +81,7 @@ export function initPatchCommentApi({ ); const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeComment(queryRestAttributes); + decodeCommentPatch(queryRestAttributes); const commentableCase = await getCommentableCase({ service: caseService, @@ -119,7 +119,7 @@ export function initPatchCommentApi({ } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const userInfo: User = { username, full_name, 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 df43e56bd82f3..02d39465373f9 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 @@ -56,7 +56,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const updateDate = new Date().toISOString(); 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 b46e03ed5332e..db3d5cd6a2e56 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 @@ -58,7 +58,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { email, full_name, username } = await caseService.getUser({ request, response }); + const { email, full_name, username } = await caseService.getUser({ request }); const creationDate = new Date().toISOString(); let mappings: ConnectorMappingsAttributes[] = []; 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 98e399fa2ab4b..263b814df4146 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 @@ -137,7 +137,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R await deleteSubCases({ caseService, client, caseIds: request.query.ids }); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); await userActionService.postUserActions({ diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index ad43d6d33c072..a1a7f4f9da8f5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -23,8 +23,6 @@ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_ import { sortToSnake } from '../utils'; import { combineFilters } from '../../../common'; -// TODO: write unit tests for these functions - export const addStatusFilter = ({ status, appendFilter, 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 c8df012acc66a..db701dd0fc82b 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 @@ -59,7 +59,7 @@ export function initDeleteSubCasesApi({ caseService, router, userActionService } await Promise.all(request.query.ids.map((id) => caseService.deleteSubCase(client, id))); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); await userActionService.postUserActions({ 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 ed57a3d6dc389..10211c1cb2471 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 @@ -9,10 +9,15 @@ import Boom from '@hapi/boom'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsClientContract, KibanaRequest, SavedObject } from 'kibana/server'; +import { + SavedObjectsClientContract, + KibanaRequest, + SavedObject, + SavedObjectsFindResponse, +} from 'kibana/server'; import { CaseClient } from '../../../../client'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; import { CaseStatuses, @@ -28,12 +33,20 @@ import { SubCaseResponse, SubCasesResponseRt, User, + CommentAttributes, } from '../../../../../common/api'; import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; -import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; +import { + AlertInfo, + escapeHatch, + flattenSubCaseSavedObject, + isGenOrAlertCommentAttributes, + wrapError, +} from '../../utils'; import { getCaseToUpdate } from '../helpers'; import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; +import { addAlertInfoToStatusMap } from '../../../../common'; interface UpdateArgs { client: SavedObjectsClientContract; @@ -166,6 +179,79 @@ function getValidUpdateRequests( }); } +/** + * Get the id from a reference in a comment for a sub case + */ +function getID(comment: SavedObject): string | undefined { + return comment.references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id; +} + +/** + * Get all the alert comments for a set of sub cases + */ +async function getAlertComments({ + subCasesToSync, + caseService, + client, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const ids = subCasesToSync.map((subCase) => subCase.id); + return caseService.getAllSubCaseComments({ + client, + id: ids, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Updates the status of alerts for the specified sub cases. + */ +async function updateAlerts({ + subCasesToSync, + caseService, + client, + caseClient, + subCasesMap, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClient; + subCasesMap: 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 (isGenOrAlertCommentAttributes(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesMap.get(id)?.attributes.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, + }); + } + } +} + async function update({ client, caseService, @@ -204,7 +290,6 @@ async function update({ subCasesMap, }); - // TODO: extract to new function // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); const updatedAt = new Date().toISOString(); @@ -259,41 +344,13 @@ async function update({ ); }); - // TODO: extra to new function - for (const subCaseToSync of subCasesToSyncAlertsFor) { - const currentSubCase = subCasesMap.get(subCaseToSync.id); - const alertComments = await caseService.getAllSubCaseComments({ - client, - id: subCaseToSync.id, - options: { - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - }, - }); - - // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) - const idsAndIndices = alertComments.saved_objects.reduce( - (acc: { ids: string[]; indices: Set }, comment) => { - if (isAlertCommentSO(comment)) { - const alertId = comment.attributes.alertId; - const ids = Array.isArray(alertId) ? alertId : [alertId]; - acc.ids.push(...ids); - acc.indices.add(comment.attributes.index); - } - return acc; - }, - { ids: [], indices: new Set() } - ); - - if (idsAndIndices.ids.length > 0) { - caseClient.updateAlertsStatus({ - ids: idsAndIndices.ids, - // We shouldn't really get in a case where the sub cases' status is undefined, but there wouldn't be anything to - // update in that case - status: subCaseToSync.status ?? currentSubCase?.attributes.status ?? CaseStatuses.open, - indices: idsAndIndices.indices, - }); - } - } + await updateAlerts({ + caseService, + client, + caseClient, + subCasesToSync: subCasesToSyncAlertsFor, + subCasesMap, + }); const returnUpdatedSubCases = updatedCases.saved_objects.reduce( (acc, updatedSO) => { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 6c7eb0318c5de..7a0e270821378 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -27,7 +27,6 @@ import { ESCaseAttributes, CommentRequest, ContextTypeUserRt, - ContextTypeAlertRt, CommentRequestUserType, CommentRequestAlertType, CommentType, @@ -39,17 +38,20 @@ import { SubCaseAttributes, SubCaseResponse, CommentRequestGeneratedAlertType, - ContextTypeGeneratedAlertRt, + GeneratedAlertCommentRequestRt, SubCasesFindResponse, AttributesTypeAlerts, User, + GeneratedAlertRequestTypeField, + AlertCommentRequestRt, + CommentPatchRequestTypes, + AlertCommentAttributesRt, + AttributesTypeAlertsWithoutBasic, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase } from './types'; -// TODO: refactor these functions to a common location, this is used by the caseClient too - export const transformNewSubCase = ({ createdAt, createdBy, @@ -104,13 +106,73 @@ type NewCommentArgs = CommentRequest & { username?: string | null; }; +/** + * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. + */ +export const getAlertIdsFromAttributes = (comment: CommentAttributes): string[] => { + if (isGenOrAlertCommentAttributes(comment)) { + return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + } + return []; +}; + +/** + * This structure holds the alert IDs and indices found from multiple alert comments + */ +export interface AlertInfo { + ids: string[]; + indices: Set; +} + +const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => { + if (isGenOrAlertCommentAttributes(comment)) { + acc.ids.push(...getAlertIdsFromAttributes(comment)); + acc.indices.add(comment.index); + } + return acc; +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. + */ +export const getAlertIndicesAndIDs = (comments: CommentAttributes[] | undefined): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment, acc); + }, + { ids: [], indices: new Set() } + ); +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alert saved objects. + */ +export const getAlertIndicesAndIDsFromSO = ( + comments: SavedObjectsFindResponse | undefined +): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.saved_objects.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment.attributes, acc); + }, + { ids: [], indices: new Set() } + ); +}; + /** * Return the IDs from the comment. * - * @param comment the comment from the add comment request + * @param comment the comment from the add comment request or stored within a case */ -export const getAlertIds = (comment: CommentRequest): string[] => { - if (isGeneratedAlertContext(comment)) { +export const getAlertIdsFromRequest = (comment: CommentRequest): string[] => { + if (isCommentRequestTypeGenAlert(comment)) { const ids: string[] = []; if (Array.isArray(comment.alerts)) { ids.push( @@ -122,7 +184,7 @@ export const getAlertIds = (comment: CommentRequest): string[] => { ids.push(comment.alerts._id); } return ids; - } else if (isAlertContext(comment)) { + } else if (isCommentRequestTypeAlert(comment)) { return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; } else { return []; @@ -138,14 +200,14 @@ export const transformNewComment = ({ username, ...comment }: NewCommentArgs): CommentAttributes => { - if (isGeneratedAlertContext(comment)) { - const ids = getAlertIds(comment); + if (isCommentRequestTypeGenAlert(comment)) { + const ids = getAlertIdsFromRequest(comment); return { associationType, alertId: ids, index: comment.index, - type: comment.type, + type: CommentType.generatedAlert, created_at: createdDate, created_by: { email, full_name, username }, pushed_at: null, @@ -316,8 +378,8 @@ export const escapeHatch = schema.object({}, { unknowns: 'allow' }); /** * A type narrowing function for user comments. Exporting so integration tests can use it. */ -export const isUserContext = ( - context: CommentRequest | CommentAttributes +export const isCommentRequestTypeUser = ( + context: CommentRequest | CommentPatchRequestTypes ): context is CommentRequestUserType => { return context.type === CommentType.user; }; @@ -325,12 +387,18 @@ export const isUserContext = ( /** * A type narrowing function for alert comments. Exporting so integration tests can use it. */ -export const isAlertContext = ( - context: CommentRequest | CommentAttributes +export const isCommentRequestTypeAlert = ( + context: CommentRequest | CommentPatchRequestTypes ): context is CommentRequestAlertType => { return context.type === CommentType.alert; }; +const isPatchRequestTypeAlertOrGenAlert = ( + context: CommentPatchRequestTypes +): context is AttributesTypeAlertsWithoutBasic => { + return context.type === CommentType.alert || context.type === CommentType.generatedAlert; +}; + /** * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is @@ -338,30 +406,42 @@ export const isAlertContext = ( * both a generated and user attached alert in the same structure but this function is useful to determine which * structure the new alert in the request has. */ -export const isGeneratedAlertContext = ( +export const isCommentRequestTypeGenAlert = ( context: CommentRequest ): context is CommentRequestGeneratedAlertType => { - return context.type === CommentType.generatedAlert; + return context.type === GeneratedAlertRequestTypeField; }; -export const isAlertCommentSO = ( - comment: SavedObject -): comment is SavedObject => { - return ( - comment.attributes.type === CommentType.generatedAlert || - comment.attributes.type === CommentType.alert - ); +/** + * Returns true if the comment attribute is of type generated alert or alert. + */ +export const isGenOrAlertCommentAttributes = ( + comment: CommentAttributes +): comment is AttributesTypeAlerts => { + return comment.type === CommentType.generatedAlert || comment.type === CommentType.alert; }; -export const decodeComment = (comment: CommentRequest) => { - if (isUserContext(comment)) { +export const decodeCommentRequest = (comment: CommentRequest) => { + if (isCommentRequestTypeUser(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isAlertContext(comment)) { - pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isGeneratedAlertContext(comment)) { + } else if (isCommentRequestTypeAlert(comment)) { + pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isCommentRequestTypeGenAlert(comment)) { pipe( - excess(ContextTypeGeneratedAlertRt).decode(comment), + excess(GeneratedAlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity) ); } }; + +/** + * This is used to decode a patch request. The patch comment is different from a comment request because it only allows + * a user, alert, or generated alert to be patched. It does not allow a generated alert using the {_id: string} format. + */ +export const decodeCommentPatch = (comment: CommentPatchRequestTypes) => { + if (isCommentRequestTypeUser(comment)) { + pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isPatchRequestTypeAlertOrGenAlert(comment)) { + pipe(excess(AlertCommentAttributesRt).decode(comment), fold(throwErrors(badRequest), identity)); + } +}; 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 8e613a248a694..2ea9718d18487 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -7,21 +7,18 @@ /* eslint-disable no-console */ import yargs from 'yargs'; import { KbnClient, ToolingLog } from '@kbn/dev-utils'; -import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api'; +import { + CaseResponse, + CaseType, + CollectionWithSubCaseResponse, + ConnectorTypes, +} from '../../../common/api'; import { CommentType } from '../../../common/api/cases/comment'; import { CASES_URL } from '../../../common/constants'; +import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; main(); -// TODO: find actual type -interface ActionResp { - id: string; -} - -interface ExecuteResp { - status: string; -} - function createClient(argv: any): KbnClient { return new KbnClient({ log: new ToolingLog({ @@ -71,7 +68,7 @@ async function handleGenGroupAlerts(argv: any) { const client = createClient(argv); try { - const createdAction = await client.request({ + const createdAction = await client.request({ path: '/api/actions/action', method: 'POST', body: { @@ -108,7 +105,9 @@ async function handleGenGroupAlerts(argv: any) { } console.log('Case id: ', caseID); - const executeResp = await client.request({ + const executeResp = await client.request< + ActionTypeExecutorResult + >({ path: `/api/actions/action/${createdAction.data.id}/_execute`, method: 'POST', body: { @@ -142,8 +141,9 @@ async function handleGenGroupAlerts(argv: any) { } async function main() { - // TODO: this isn't waiting for some reason - await yargs(process.argv.slice(2)) + // This returns before the async handlers do + // We need to convert this to commander instead I think + yargs(process.argv.slice(2)) .help() .options({ kibana: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index b6f9f0ce019e2..a9e5c26960830 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -7,7 +7,6 @@ import { KibanaRequest, - KibanaResponseFactory, Logger, SavedObject, SavedObjectsClientContract, @@ -35,6 +34,7 @@ import { caseTypeField, } from '../../common/api'; import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; +import { defaultPage, defaultPerPage } from '../routes/api'; import { flattenCaseSavedObject, flattenSubCaseSavedObject, @@ -163,7 +163,6 @@ interface PatchSubCases { interface GetUserArgs { request: KibanaRequest; - response?: KibanaResponseFactory; } interface SubCasesMapWithPageInfo { @@ -284,9 +283,10 @@ export class CaseService implements CaseServiceSetup { client, options: subCaseOptions, ids: cases.saved_objects - .filter((caseInfo) => caseInfo.type === CaseType.collection) + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) .map((caseInfo) => caseInfo.id), }); + const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); @@ -445,6 +445,13 @@ export class CaseService implements CaseServiceSetup { ids: string[]; associationType: AssociationType; }): Promise { + if (ids.length <= 0) { + return { + commentTotals: new Map(), + alertTotals: new Map(), + }; + } + const refType = associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; @@ -502,8 +509,18 @@ export class CaseService implements CaseServiceSetup { return subCase.references.length > 0 ? subCase.references[0].id : undefined; }; + const emptyResponse = { + subCasesMap: new Map(), + page: 0, + perPage: 0, + }; + if (!options) { - return { subCasesMap: new Map(), page: 0, perPage: 0 }; + return emptyResponse; + } + + if (ids.length <= 0) { + return emptyResponse; } const subCases = await this.findSubCases({ @@ -563,6 +580,10 @@ export class CaseService implements CaseServiceSetup { options, ids, }: FindSubCasesStatusStats): Promise { + if (ids.length <= 0) { + return 0; + } + const subCases = await this.findSubCases({ client, options: { @@ -781,6 +802,15 @@ export class CaseService implements CaseServiceSetup { ids, options, }: FindSubCasesByIDArgs): Promise> { + if (ids.length <= 0) { + return { + total: 0, + saved_objects: [], + page: options?.page ?? defaultPage, + per_page: options?.perPage ?? defaultPerPage, + }; + } + try { this.log.debug(`Attempting to GET sub cases for case collection id ${ids.join(', ')}`); return this.findSubCases({ @@ -864,6 +894,14 @@ export class CaseService implements CaseServiceSetup { }: FindCaseCommentsArgs): Promise> { try { const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; + } let filter: string | undefined; if (!includeSubCaseComments) { @@ -901,6 +939,14 @@ export class CaseService implements CaseServiceSetup { }: FindSubCaseCommentsArgs): Promise> { try { const refs = this.asArray(id).map((caseID) => ({ type: SUB_CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; + } this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); return this.getAllComments({ @@ -936,8 +982,8 @@ export class CaseService implements CaseServiceSetup { throw error; } } - // TODO: remove response - public async getUser({ request, response }: GetUserArgs) { + + public async getUser({ request }: GetUserArgs) { try { this.log.debug(`Attempting to authenticate a user`); if (this.authentication != null) { diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 9997ee0f7b2b9..3a524fd365c30 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -847,11 +847,10 @@ export default ({ getService }: FtrProviderContext): void => { }, }) .expect(200); - expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert]`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert_request]`, retry: false, }); } 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 a4320c28526b3..a6b5d98f942d8 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -23,11 +23,12 @@ import { AssociationType, CollectionWithSubCaseResponse, SubCasesFindResponse, + GeneratedAlertRequestTypeField, } from '../../../../plugins/case/common/api'; import { - getAlertIds, - isGeneratedAlertContext, - isAlertContext, + getAlertIdsFromRequest, + isCommentRequestTypeGenAlert, + isCommentRequestTypeAlert, } from '../../../../plugins/case/server/routes/api/utils'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { @@ -75,7 +76,7 @@ export const postCommentAlertReq: CommentRequestAlertType = { export const postCommentGenAlertReq: CommentRequestGeneratedAlertType = { alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], index: 'test-index', - type: CommentType.generatedAlert, + type: GeneratedAlertRequestTypeField, }; export const postCaseResp = ( @@ -115,15 +116,15 @@ export const commentsResp = ({ pushed_by: null, updated_by: null, }; - if (isGeneratedAlertContext(comment)) { + if (isCommentRequestTypeGenAlert(comment)) { return { associationType, - alertId: getAlertIds(comment), + alertId: getAlertIdsFromRequest(comment), index: comment.index, - type: comment.type, + type: CommentType.generatedAlert, ...baseFields, }; - } else if (isAlertContext(comment)) { + } else if (isCommentRequestTypeAlert(comment)) { return { associationType, ...comment, From 575d849ab00f6a27b4e7a394745527fe0bf93775 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Wed, 10 Feb 2021 23:22:55 -0500 Subject: [PATCH 45/47] Addressing alert service feedback --- .../plugins/case/server/client/alerts/get.ts | 4 +++ .../case/server/services/alerts/index.test.ts | 8 ++--- .../case/server/services/alerts/index.ts | 32 ++++++++++++------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts index 8202c0112e7a8..a7ca5d9742c6b 100644 --- a/x-pack/plugins/case/server/client/alerts/get.ts +++ b/x-pack/plugins/case/server/client/alerts/get.ts @@ -27,6 +27,10 @@ export const get = async ({ } const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient }); + if (!alerts) { + return []; + } + return alerts.hits.hits.map((alert) => ({ id: alert._id, index: alert._index, 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 e898345cb1b14..35aa3ff80efc1 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -43,15 +43,15 @@ describe('updateAlertsStatus', () => { }); describe('unhappy path', () => { - it('throws an error if no valid indices are provided', async () => { - expect(async () => { + it('ignores empty indices', async () => { + expect( await alertService.updateAlertsStatus({ ids: ['alert-id-1'], status: CaseStatuses.closed, indices: new Set(['']), scopedClusterClient: esClient, - }); - }).rejects.toThrow(); + }) + ).toBeUndefined(); }); }); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 68ac4ac87317f..320d32ac0d788 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +import _ from 'lodash'; + import type { PublicMethodsOf } from '@kbn/utility-types'; import { ElasticsearchClient } from 'kibana/server'; @@ -37,6 +39,15 @@ interface AlertsResponse { }; } +/** + * remove empty strings from the indices, I'm not sure how likely this is but in the case that + * the document doesn't have _index set the security_solution code sets the value to an empty string + * instead + */ +function getValidIndices(indices: Set): string[] { + return [...indices].filter((index) => !_.isEmpty(index)); +} + export class AlertService { constructor() {} @@ -46,17 +57,12 @@ export class AlertService { indices, scopedClusterClient, }: UpdateAlertsStatusArgs) { - /** - * remove empty strings from the indices, I'm not sure how likely this is but in the case that - * the document doesn't have _index set the security_solution code sets the value to an empty string - * instead - */ - const sanitizedIndices = [...indices].filter((index) => index !== ''); + const sanitizedIndices = getValidIndices(indices); if (sanitizedIndices.length <= 0) { - throw new Error('No valid indices found to update the alerts status'); + // log that we only had invalid indices + return; } - // The above check makes sure that esClient is defined. const result = await scopedClusterClient.updateByQuery({ index: sanitizedIndices, conflicts: 'abort', @@ -77,10 +83,14 @@ export class AlertService { scopedClusterClient, ids, indices, - }: GetAlertsArgs): Promise { - // The above check makes sure that esClient is defined. + }: GetAlertsArgs): Promise { + const index = getValidIndices(indices); + if (index.length <= 0) { + return; + } + const result = await scopedClusterClient.search({ - index: [...indices].filter((index) => index !== ''), + index, body: { query: { bool: { From fe3d04370dde14a05afe63780c8adf2bf2b01507 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 11 Feb 2021 13:09:05 -0500 Subject: [PATCH 46/47] Fixing sub case sync bug and cleaning up comment types --- .../plugins/case/common/api/cases/comment.ts | 72 ++--------- .../plugins/case/server/client/cases/push.ts | 2 +- .../case/server/client/cases/update.ts | 6 +- .../plugins/case/server/client/cases/utils.ts | 8 +- .../case/server/client/comments/add.ts | 22 ++-- .../plugins/case/server/common/utils.test.ts | 16 +-- .../case/server/connectors/case/index.ts | 39 +++++- .../case/server/connectors/case/schema.ts | 10 +- .../plugins/case/server/connectors/index.ts | 26 +++- .../plugins/case/server/connectors/types.ts | 6 + .../api/cases/comments/patch_comment.ts | 4 +- .../api/cases/sub_case/patch_sub_cases.ts | 15 ++- .../plugins/case/server/routes/api/utils.ts | 121 +++--------------- .../connectors/case/alert_fields.tsx | 2 +- .../basic/tests/connectors/case.ts | 2 +- .../case_api_integration/common/lib/mock.ts | 35 ++--- .../case_api_integration/common/lib/utils.ts | 6 +- 17 files changed, 157 insertions(+), 235 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 2dc6dd4fde4d9..cfc6099fa4bb5 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -33,8 +33,6 @@ export const CommentAttributesBasicRt = rt.type({ updated_by: rt.union([UserRT, rt.null]), }); -export const GeneratedAlertRequestTypeField = 'generated_alert_request'; - export enum CommentType { user = 'user', alert = 'alert', @@ -47,48 +45,21 @@ export const ContextTypeUserRt = rt.type({ }); /** - * This defines the structure of how alerts (generated or user attached) are stored in saved objects documents. - */ -export const AlertCommentAttributesRt = rt.type({ - type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), - alertId: rt.union([rt.array(rt.string), rt.string]), - index: rt.string, -}); - -/** - * This defines the structure of an alert attached by a user. + * This defines the structure of how alerts (generated or user attached) are stored in saved objects documents. It also + * represents of an alert after it has been transformed. A generated alert will be transformed by the connector so that + * it matches this structure. User attached alerts do not need to be transformed. */ export const AlertCommentRequestRt = rt.type({ - type: rt.literal(CommentType.alert), + type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, }); -const AlertIDRt = rt.type({ - _id: rt.string, -}); - -/** - * This defines the structure of generated alerts attached by a detections rule. - */ -export const GeneratedAlertCommentRequestRt = rt.type({ - type: rt.literal(GeneratedAlertRequestTypeField), - alerts: rt.union([rt.array(AlertIDRt), AlertIDRt]), - index: rt.string, -}); - const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); -const AttributesTypeAlertsRt = rt.intersection([ - AlertCommentAttributesRt, - CommentAttributesBasicRt, -]); +const AttributesTypeAlertsRt = rt.intersection([AlertCommentRequestRt, CommentAttributesBasicRt]); const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); -export const CommentRequestRt = rt.union([ - ContextTypeUserRt, - AlertCommentRequestRt, - GeneratedAlertCommentRequestRt, -]); +export const CommentRequestRt = rt.union([ContextTypeUserRt, AlertCommentRequestRt]); export const CommentResponseRt = rt.intersection([ CommentAttributesRt, @@ -108,14 +79,12 @@ export const CommentResponseTypeAlertsRt = rt.intersection([ export const AllCommentsResponseRT = rt.array(CommentResponseRt); -const CommentPatchRequestTypesRt = rt.union([ContextTypeUserRt, AlertCommentAttributesRt]); - export const CommentPatchRequestRt = rt.intersection([ /** * Partial updates are not allowed. * We want to prevent the user for changing the type without removing invalid fields. */ - CommentPatchRequestTypesRt, + CommentRequestRt, rt.type({ id: rt.string, version: rt.string }), ]); @@ -126,10 +95,7 @@ export const CommentPatchRequestRt = rt.intersection([ * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. */ export const CommentPatchAttributesRt = rt.intersection([ - rt.union([ - rt.partial(CommentAttributesBasicRt.props), - rt.partial(AlertCommentAttributesRt.props), - ]), + rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(AlertCommentRequestRt.props)]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -152,25 +118,3 @@ export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; export type CommentRequestAlertType = rt.TypeOf; - -/** - * This is different than a CommentRequest because a patch request only allows the alert and user types to be patched. - * A CommentRequest allows alerts, user, and generated_alerts - */ -export type CommentPatchRequestTypes = rt.TypeOf; - -/** - * This type includes the index, alertIds, and the basic attributes (who added it, when etc) - */ -export type AttributesTypeAlerts = rt.TypeOf; -/** - * This type includes only the index, alertIds, it does not include the basic attributes. - */ -export type AttributesTypeAlertsWithoutBasic = rt.TypeOf; - -/** - * This type represents a generated alert (or alerts) from a rule. The difference between this and a user alert - * is that this format expects the included alerts to have the structure { _id: string }. When it is saved the outer - * object will be stripped off and the _id will be stored in the alertId field. - */ -export type CommentRequestGeneratedAlertType = rt.TypeOf; diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index 881e586b5a4a3..1e0c246855d88 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -78,7 +78,7 @@ export const push = async ({ try { [theCase, connector, userActions] = await Promise.all([ - caseClient.get({ id: caseId, includeComments: true }), + caseClient.get({ id: caseId, includeComments: true, includeSubCaseComments: true }), actionsClient.get({ id: connectorId }), caseClient.getUserActions({ caseId }), ]); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 915746e270ade..460bed7510c30 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -19,7 +19,7 @@ import { import { AlertInfo, flattenCaseSavedObject, - isGenOrAlertCommentAttributes, + isCommentRequestTypeAlertOrGenAlert, } from '../../routes/api/utils'; import { @@ -192,7 +192,7 @@ async function getSubCasesToStatus({ }): Promise> { const subCasesToRetrieve = totalAlerts.saved_objects.reduce((acc, alertComment) => { if ( - isGenOrAlertCommentAttributes(alertComment.attributes) && + isCommentRequestTypeAlertOrGenAlert(alertComment.attributes) && alertComment.attributes.associationType === AssociationType.subCase ) { const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); @@ -293,7 +293,7 @@ async function updateAlerts({ // This allows us to make at most 3 calls to ES, one for each status type that we need to update // One potential improvement here is to do a tick (set timeout) to reduce the memory footprint if that becomes an issue const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { - if (isGenOrAlertCommentAttributes(alertComment.attributes)) { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { const status = getSyncStatusForComment({ alertComment, casesToSyncToStatus, diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 2b0dcebf143d8..78bdc6d282c69 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -19,7 +19,7 @@ import { ConnectorTypes, CommentAttributes, CommentRequestUserType, - AttributesTypeAlertsWithoutBasic, + CommentRequestAlertType, } from '../../../common/api'; import { ActionsClient } from '../../../../actions/server'; import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors'; @@ -38,7 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; -import { getAlertIdsFromAttributes } from '../../routes/api/utils'; +import { getAlertIds } from '../../routes/api/utils'; export const getLatestPushInfo = ( connectorId: string, @@ -68,7 +68,7 @@ const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; } else if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { - const ids = getAlertIdsFromAttributes(comment); + const ids = getAlertIds(comment); return `Alert with ids ${ids.join(', ')} added to case`; } @@ -301,7 +301,7 @@ export const isCommentAlertType = ( export const getCommentContextFromAttributes = ( attributes: CommentAttributes -): CommentRequestUserType | AttributesTypeAlertsWithoutBasic => { +): CommentRequestUserType | CommentRequestAlertType => { switch (attributes.type) { case CommentType.user: return { diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 7fb7193d8a114..a1748f9132b74 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -13,7 +13,7 @@ import { identity } from 'fp-ts/lib/function'; import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import { decodeCommentRequest, - getAlertIdsFromRequest, + getAlertIds, isCommentRequestTypeGenAlert, } from '../../routes/api/utils'; @@ -26,10 +26,9 @@ import { SubCaseAttributes, CommentRequest, CollectionWithSubCaseResponse, - GeneratedAlertCommentRequestRt, - CommentRequestGeneratedAlertType, User, - GeneratedAlertRequestTypeField, + CommentRequestAlertType, + AlertCommentRequestRt, } from '../../../common/api'; import { buildCaseUserActionItem, @@ -86,7 +85,7 @@ async function getSubCase({ interface AddCommentFromRuleArgs { caseClient: CaseClientImpl; caseId: string; - comment: CommentRequestGeneratedAlertType; + comment: CommentRequestAlertType; savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; @@ -101,11 +100,16 @@ const addGeneratedAlerts = async ({ comment, }: AddCommentFromRuleArgs): Promise => { const query = pipe( - GeneratedAlertCommentRequestRt.decode(comment), + AlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); decodeCommentRequest(comment); + + // This function only supports adding generated alerts + 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({ @@ -114,7 +118,7 @@ const addGeneratedAlerts = async ({ }); if ( - query.type === GeneratedAlertRequestTypeField && + query.type === CommentType.generatedAlert && caseInfo.attributes.type !== CaseType.collection ) { throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); @@ -152,7 +156,7 @@ const addGeneratedAlerts = async ({ newComment.attributes.type === CommentType.generatedAlert) && caseInfo.attributes.settings.syncAlerts ) { - const ids = getAlertIdsFromRequest(query); + const ids = getAlertIds(query); await caseClient.updateAlertsStatus({ ids, status: subCase.attributes.status, @@ -274,7 +278,7 @@ export const addComment = async ({ }); if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { - const ids = getAlertIdsFromRequest(query); + const ids = getAlertIds(query); await caseClient.updateAlertsStatus({ ids, status: updatedCase.status, diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts index d383c09d9a7f9..d89feb009f806 100644 --- a/x-pack/plugins/case/server/common/utils.test.ts +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -6,13 +6,7 @@ */ import { SavedObjectsFindResponse } from 'kibana/server'; -import { - AssociationType, - CommentAttributes, - CommentRequest, - CommentType, - GeneratedAlertRequestTypeField, -} from '../../common/api'; +import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; import { transformNewComment } from '../routes/api/utils'; import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; @@ -102,15 +96,15 @@ describe('common utils', () => { ids: ['1'], comments: [ { - alerts: [{ _id: 'a' }, { _id: 'b' }, { _id: 'c' }], + alertId: ['a', 'b', 'c'], index: '', - type: GeneratedAlertRequestTypeField, + type: CommentType.generatedAlert, }, ], }, ]).saved_objects[0] ) - ); + ).toBe(3); }); it('returns 3 alerts for a single alert comment', () => { @@ -129,7 +123,7 @@ describe('common utils', () => { }, ]).saved_objects[0] ) - ); + ).toBe(3); }); }); diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 18fac90624554..34b407616cfe4 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -8,9 +8,14 @@ import { curry } from 'lodash'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { CasePatchRequest, CasePostRequest } from '../../../common/api'; +import { + CasePatchRequest, + CasePostRequest, + CommentRequest, + CommentType, +} from '../../../common/api'; import { createExternalCaseClient } from '../../client'; -import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; +import { CaseExecutorParamsSchema, CaseConfigurationSchema, CommentSchemaType } from './schema'; import { CaseExecutorResponse, ExecutorSubActionAddCommentParams, @@ -19,7 +24,7 @@ import { } from './types'; import * as i18n from './translations'; -import { GetActionTypeParams } from '..'; +import { GetActionTypeParams, isCommentGeneratedAlert } from '..'; import { nullUser } from '../../common'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -107,8 +112,34 @@ async function executor( if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseId, comment }); + const formattedComment = transformConnectorComment(comment); + data = await caseClient.addComment({ caseId, comment: formattedComment }); } return { status: 'ok', data: data ?? {}, actionId }; } + +/** + * This converts a connector style generated alert ({_id: string} | {_id: string}[]) to the expected format of addComment. + */ +export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => { + if (isCommentGeneratedAlert(comment)) { + const alertId: string[] = []; + if (Array.isArray(comment.alerts)) { + alertId.push( + ...comment.alerts.map((alert: { _id: string }) => { + return alert._id; + }) + ); + } else { + alertId.push(comment.alerts._id); + } + return { + type: CommentType.generatedAlert, + alertId, + index: comment.index, + }; + } else { + return comment; + } +}; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 1022f06bb61fc..ce21b8d8ca8d7 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentType, GeneratedAlertRequestTypeField } from '../../../common/api'; +import { CommentType } from '../../../common/api'; import { validateConnector } from './validators'; // Reserved for future implementation @@ -25,11 +25,13 @@ const AlertIDSchema = schema.object( ); const ContextTypeAlertGroupSchema = schema.object({ - type: schema.literal(GeneratedAlertRequestTypeField), + type: schema.literal(CommentType.generatedAlert), alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), index: schema.string(), }); +export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type; + const ContextTypeAlertSchema = schema.object({ type: schema.literal(CommentType.alert), // allowing either an array or a single value to preserve the previous API of attaching a single alert ID @@ -37,12 +39,16 @@ const ContextTypeAlertSchema = schema.object({ index: schema.string(), }); +export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type; + export const CommentSchema = schema.oneOf([ ContextTypeUserSchema, ContextTypeAlertSchema, ContextTypeAlertGroupSchema, ]); +export type CommentSchemaType = typeof CommentSchema.type; + const JiraFieldsSchema = schema.object({ issueType: schema.string(), priority: schema.nullable(schema.string()), diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index 00809d81ca5f2..056ccff2733a7 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -5,14 +5,22 @@ * 2.0. */ -import { RegisterConnectorsArgs, ExternalServiceFormatterMapper } from './types'; +import { + RegisterConnectorsArgs, + ExternalServiceFormatterMapper, + CommentSchemaType, + ContextTypeGeneratedAlertType, + ContextTypeAlertSchemaType, +} from './types'; import { getActionType as getCaseConnector } from './case'; import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_formatter'; import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; +import { CommentRequest, CommentType } from '../../common/api'; export * from './types'; +export { transformConnectorComment } from './case'; export const registerConnectors = ({ actionsRegisterType, @@ -41,3 +49,19 @@ export const externalServiceFormatters: ExternalServiceFormatterMapper = { '.jira': jiraExternalServiceFormatter, '.resilient': resilientExternalServiceFormatter, }; + +export const isCommentGeneratedAlert = ( + comment: CommentSchemaType | CommentRequest +): comment is ContextTypeGeneratedAlertType => { + return ( + comment.type === CommentType.generatedAlert && + 'alerts' in comment && + comment.alerts !== undefined + ); +}; + +export const isCommentAlert = ( + comment: CommentSchemaType +): comment is ContextTypeAlertSchemaType => { + return comment.type === CommentType.alert; +}; diff --git a/x-pack/plugins/case/server/connectors/types.ts b/x-pack/plugins/case/server/connectors/types.ts index 8e7eb91ad2dc6..ffda6f96ae3ba 100644 --- a/x-pack/plugins/case/server/connectors/types.ts +++ b/x-pack/plugins/case/server/connectors/types.ts @@ -23,6 +23,12 @@ import { AlertServiceContract, } from '../services'; +export { + ContextTypeGeneratedAlertType, + CommentSchemaType, + ContextTypeAlertSchemaType, +} from './case/schema'; + export interface GetActionTypeParams { logger: Logger; caseService: CaseServiceSetup; 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 5a13b766cf4b7..e8b6f7bc957eb 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 @@ -18,7 +18,7 @@ import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, decodeCommentPatch } from '../../utils'; +import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { CaseServiceSetup } from '../../../../services'; @@ -81,7 +81,7 @@ export function initPatchCommentApi({ ); const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeCommentPatch(queryRestAttributes); + decodeCommentRequest(queryRestAttributes); const commentableCase = await getCommentableCase({ service: caseService, 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 10211c1cb2471..ca5cd657a39f3 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 @@ -41,7 +41,7 @@ import { AlertInfo, escapeHatch, flattenSubCaseSavedObject, - isGenOrAlertCommentAttributes, + isCommentRequestTypeAlertOrGenAlert, wrapError, } from '../../utils'; import { getCaseToUpdate } from '../helpers'; @@ -85,7 +85,7 @@ function checkNonExistingOrConflict( if (conflictedSubCases.length > 0) { throw Boom.conflict( - `These cases ${conflictedSubCases + `These sub cases ${conflictedSubCases .map((c) => c.id) .join(', ')} has been updated. Please refresh before saving additional updates.` ); @@ -216,23 +216,25 @@ async function updateAlerts({ caseService, client, caseClient, - subCasesMap, }: { subCasesToSync: SubCasePatchRequest[]; caseService: CaseServiceSetup; client: SavedObjectsClientContract; caseClient: CaseClient; - subCasesMap: Map>; }) { + 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 (isGenOrAlertCommentAttributes(alertComment.attributes)) { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { const id = getID(alertComment); const status = id !== undefined - ? subCasesMap.get(id)?.attributes.status ?? CaseStatuses.open + ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open : CaseStatuses.open; addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); @@ -349,7 +351,6 @@ async function update({ client, caseClient, subCasesToSync: subCasesToSyncAlertsFor, - subCasesMap, }); const returnUpdatedSubCases = updatedCases.saved_objects.reduce( diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 7a0e270821378..bc82f656f477b 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -37,16 +37,9 @@ import { AssociationType, SubCaseAttributes, SubCaseResponse, - CommentRequestGeneratedAlertType, - GeneratedAlertCommentRequestRt, SubCasesFindResponse, - AttributesTypeAlerts, User, - GeneratedAlertRequestTypeField, AlertCommentRequestRt, - CommentPatchRequestTypes, - AlertCommentAttributesRt, - AttributesTypeAlertsWithoutBasic, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -109,8 +102,8 @@ type NewCommentArgs = CommentRequest & { /** * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. */ -export const getAlertIdsFromAttributes = (comment: CommentAttributes): string[] => { - if (isGenOrAlertCommentAttributes(comment)) { +export const getAlertIds = (comment: CommentRequest): string[] => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; } return []; @@ -125,8 +118,8 @@ export interface AlertInfo { } const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => { - if (isGenOrAlertCommentAttributes(comment)) { - acc.ids.push(...getAlertIdsFromAttributes(comment)); + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + acc.ids.push(...getAlertIds(comment)); acc.indices.add(comment.index); } return acc; @@ -166,31 +159,6 @@ export const getAlertIndicesAndIDsFromSO = ( ); }; -/** - * Return the IDs from the comment. - * - * @param comment the comment from the add comment request or stored within a case - */ -export const getAlertIdsFromRequest = (comment: CommentRequest): string[] => { - if (isCommentRequestTypeGenAlert(comment)) { - const ids: string[] = []; - if (Array.isArray(comment.alerts)) { - ids.push( - ...comment.alerts.map((alert: { _id: string }) => { - return alert._id; - }) - ); - } else { - ids.push(comment.alerts._id); - } - return ids; - } else if (isCommentRequestTypeAlert(comment)) { - return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; - } else { - return []; - } -}; - export const transformNewComment = ({ associationType, createdDate, @@ -200,33 +168,16 @@ export const transformNewComment = ({ username, ...comment }: NewCommentArgs): CommentAttributes => { - if (isCommentRequestTypeGenAlert(comment)) { - const ids = getAlertIdsFromRequest(comment); - - return { - associationType, - alertId: ids, - index: comment.index, - type: CommentType.generatedAlert, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }; - } else { - return { - associationType, - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }; - } + return { + associationType, + ...comment, + created_at: createdDate, + created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; }; export function wrapError(error: any): CustomHttpResponseOptions { @@ -379,7 +330,7 @@ export const escapeHatch = schema.object({}, { unknowns: 'allow' }); * A type narrowing function for user comments. Exporting so integration tests can use it. */ export const isCommentRequestTypeUser = ( - context: CommentRequest | CommentPatchRequestTypes + context: CommentRequest ): context is CommentRequestUserType => { return context.type === CommentType.user; }; @@ -387,15 +338,9 @@ export const isCommentRequestTypeUser = ( /** * A type narrowing function for alert comments. Exporting so integration tests can use it. */ -export const isCommentRequestTypeAlert = ( - context: CommentRequest | CommentPatchRequestTypes +export const isCommentRequestTypeAlertOrGenAlert = ( + context: CommentRequest ): context is CommentRequestAlertType => { - return context.type === CommentType.alert; -}; - -const isPatchRequestTypeAlertOrGenAlert = ( - context: CommentPatchRequestTypes -): context is AttributesTypeAlertsWithoutBasic => { return context.type === CommentType.alert || context.type === CommentType.generatedAlert; }; @@ -408,40 +353,14 @@ const isPatchRequestTypeAlertOrGenAlert = ( */ export const isCommentRequestTypeGenAlert = ( context: CommentRequest -): context is CommentRequestGeneratedAlertType => { - return context.type === GeneratedAlertRequestTypeField; -}; - -/** - * Returns true if the comment attribute is of type generated alert or alert. - */ -export const isGenOrAlertCommentAttributes = ( - comment: CommentAttributes -): comment is AttributesTypeAlerts => { - return comment.type === CommentType.generatedAlert || comment.type === CommentType.alert; +): context is CommentRequestAlertType => { + return context.type === CommentType.generatedAlert; }; export const decodeCommentRequest = (comment: CommentRequest) => { if (isCommentRequestTypeUser(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isCommentRequestTypeAlert(comment)) { + } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isCommentRequestTypeGenAlert(comment)) { - pipe( - excess(GeneratedAlertCommentRequestRt).decode(comment), - fold(throwErrors(badRequest), identity) - ); - } -}; - -/** - * This is used to decode a patch request. The patch comment is different from a comment request because it only allows - * a user, alert, or generated alert to be patched. It does not allow a generated alert using the {_id: string} format. - */ -export const decodeCommentPatch = (comment: CommentPatchRequestTypes) => { - if (isCommentRequestTypeUser(comment)) { - pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isPatchRequestTypeAlertOrGenAlert(comment)) { - pipe(excess(AlertCommentAttributesRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; 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 be7cef5a07142..656257f2b36c4 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 @@ -26,7 +26,7 @@ const Container = styled.div` `; const defaultAlertComment = { - type: CommentType.alert, + type: CommentType.generatedAlert, alerts: '{{context.alerts}}', index: '{{context.rule.output_index}}', ruleId: '{{context.rule.id}}', diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 3a524fd365c30..0884d0a197983 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -850,7 +850,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert_request]`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert]`, retry: false, }); } 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 a6b5d98f942d8..2f4fa1b30f564 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -5,6 +5,12 @@ * 2.0. */ +import { + CommentSchemaType, + ContextTypeGeneratedAlertType, + isCommentGeneratedAlert, + transformConnectorComment, +} from '../../../../plugins/case/server/connectors'; import { CasePostRequest, CaseResponse, @@ -17,19 +23,13 @@ import { CaseStatuses, CaseType, CaseClientPostRequest, - CommentRequestGeneratedAlertType, SubCaseResponse, - CommentRequest, AssociationType, CollectionWithSubCaseResponse, SubCasesFindResponse, - GeneratedAlertRequestTypeField, + CommentRequest, } from '../../../../plugins/case/common/api'; -import { - getAlertIdsFromRequest, - isCommentRequestTypeGenAlert, - isCommentRequestTypeAlert, -} from '../../../../plugins/case/server/routes/api/utils'; + export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', @@ -73,10 +73,10 @@ export const postCommentAlertReq: CommentRequestAlertType = { type: CommentType.alert, }; -export const postCommentGenAlertReq: CommentRequestGeneratedAlertType = { +export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = { alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], index: 'test-index', - type: GeneratedAlertRequestTypeField, + type: CommentType.generatedAlert, }; export const postCaseResp = ( @@ -98,7 +98,7 @@ export const postCaseResp = ( interface CommentRequestWithID { id: string; - comment: CommentRequest; + comment: CommentSchemaType | CommentRequest; } export const commentsResp = ({ @@ -116,18 +116,11 @@ export const commentsResp = ({ pushed_by: null, updated_by: null, }; - if (isCommentRequestTypeGenAlert(comment)) { - return { - associationType, - alertId: getAlertIdsFromRequest(comment), - index: comment.index, - type: CommentType.generatedAlert, - ...baseFields, - }; - } else if (isCommentRequestTypeAlert(comment)) { + + if (isCommentGeneratedAlert(comment)) { return { associationType, - ...comment, + ...transformConnectorComment(comment), ...baseFields, }; } else { 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 9b51f8cb2c1b3..048c5c5d84098 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,6 @@ import { ConnectorTypes, CasePostRequest, CollectionWithSubCaseResponse, - CommentRequestGeneratedAlertType, SubCasesFindResponse, CaseStatuses, SubCasesResponse, @@ -25,6 +24,7 @@ import { } from '../../../../plugins/case/common/api'; import { postCollectionReq, postCommentGenAlertReq } from './mock'; import { getSubCasesUrl } from '../../../../plugins/case/common/api/helpers'; +import { ContextTypeGeneratedAlertType } from '../../../../plugins/case/server/connectors'; interface SetStatusCasesParams { id: string; @@ -79,7 +79,7 @@ export interface CreateSubCaseResp { */ export const createSubCase = async (args: { supertest: st.SuperTest; - comment?: CommentRequestGeneratedAlertType; + comment?: ContextTypeGeneratedAlertType; caseID?: string; caseInfo?: CasePostRequest; actionID?: string; @@ -127,7 +127,7 @@ export const createSubCaseComment = async ({ actionID, }: { supertest: st.SuperTest; - comment?: CommentRequestGeneratedAlertType; + comment?: ContextTypeGeneratedAlertType; caseID?: string; caseInfo?: CasePostRequest; forceNewSubCase?: boolean; From 99d37ae4c8f60679c5d6387e21aec2f995d9828d Mon Sep 17 00:00:00 2001 From: Jonathan Buttner Date: Thu, 11 Feb 2021 18:50:24 -0500 Subject: [PATCH 47/47] Addressing more feedback --- x-pack/plugins/case/common/api/cases/case.ts | 2 +- x-pack/plugins/case/server/client/cases/update.ts | 6 +++--- x-pack/plugins/case/server/client/comments/add.ts | 6 +++--- x-pack/plugins/case/server/client/configure/get_mappings.ts | 4 ++-- x-pack/plugins/case/server/client/index.ts | 2 +- x-pack/plugins/case/server/common/utils.ts | 6 ++++-- x-pack/plugins/case/server/plugin.ts | 4 ++-- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 8e80da382b588..49643ca1f4d0c 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -85,7 +85,7 @@ const CasePostRequestNoTypeRt = rt.type({ */ export const CaseClientPostRequestRt = rt.type({ ...CasePostRequestNoTypeRt.props, - type: CaseTypeRt, + [caseTypeField]: CaseTypeRt, }); /** diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 460bed7510c30..a4ca2b4cbdef9 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -51,7 +51,7 @@ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, } from '../../saved_object_types'; -import { CaseClientImpl } from '..'; +import { CaseClientHandler } from '..'; import { addAlertInfoToStatusMap } from '../../common'; /** @@ -259,7 +259,7 @@ async function updateAlerts({ casesMap: Map>; caseService: CaseServiceSetup; client: SavedObjectsClientContract; - caseClient: CaseClientImpl; + caseClient: CaseClientHandler; }) { /** * It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes @@ -323,7 +323,7 @@ interface UpdateArgs { caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; user: User; - caseClient: CaseClientImpl; + caseClient: CaseClientHandler; cases: CasesPatchRequest; } diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index a1748f9132b74..7dd1b4a8f6c5c 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -37,7 +37,7 @@ import { import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CommentableCase } from '../../common'; -import { CaseClientImpl } from '..'; +import { CaseClientHandler } from '..'; async function getSubCase({ caseService, @@ -83,7 +83,7 @@ async function getSubCase({ } interface AddCommentFromRuleArgs { - caseClient: CaseClientImpl; + caseClient: CaseClientHandler; caseId: string; comment: CommentRequestAlertType; savedObjectsClient: SavedObjectsClientContract; @@ -224,7 +224,7 @@ async function getCombinedCase( } interface AddCommentArgs { - caseClient: CaseClientImpl; + caseClient: CaseClientHandler; caseId: string; comment: CommentRequest; savedObjectsClient: SavedObjectsClientContract; 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 56f77373061a9..5dd90efd8a2d7 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -11,13 +11,13 @@ 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 { CaseClientImpl } from '..'; +import { CaseClientHandler } from '..'; interface GetMappingsArgs { savedObjectsClient: SavedObjectsClientContract; connectorMappingsService: ConnectorMappingsServiceSetup; actionsClient: ActionsClient; - caseClient: CaseClientImpl; + caseClient: CaseClientHandler; connectorType: string; connectorId: string; } diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 6c8cf53b2ae27..900b5a92ebf92 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -8,7 +8,7 @@ import { CaseClientFactoryArguments, CaseClient } from './types'; import { CaseClientHandler } from './client'; -export { CaseClientHandler as CaseClientImpl } from './client'; +export { CaseClientHandler } from './client'; export { CaseClient } from './types'; /** diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index f259d5e87736d..a3ac0361569d5 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -96,13 +96,15 @@ export const groupTotalAlertsByID = ({ comments: SavedObjectsFindResponse; }): Map => { return comments.saved_objects.reduce((acc, alertsInfo) => { + const alertTotalForComment = countAlerts(alertsInfo); for (const alert of alertsInfo.references) { if (alert.id) { const totalAlerts = acc.get(alert.id); + if (totalAlerts !== undefined) { - acc.set(alert.id, totalAlerts + countAlerts(alertsInfo)); + acc.set(alert.id, totalAlerts + alertTotalForComment); } else { - acc.set(alert.id, countAlerts(alertsInfo)); + acc.set(alert.id, alertTotalForComment); } } } diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 9bf7ca097a561..1c00c26a7c0b0 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -34,7 +34,7 @@ import { AlertService, AlertServiceContract, } from './services'; -import { CaseClientImpl, createExternalCaseClient } from './client'; +import { CaseClientHandler, createExternalCaseClient } from './client'; import { registerConnectors } from './connectors'; import type { CasesRequestHandlerContext } from './types'; @@ -169,7 +169,7 @@ export class CasePlugin { const user = await caseService.getUser({ request }); return { getCaseClient: () => { - return new CaseClientImpl({ + return new CaseClientHandler({ scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), caseService,