From 7abb1e3033404d8f69ba78cf746e0cd45e3215c2 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 4 Nov 2020 12:07:17 +0200 Subject: [PATCH] [Security Solution][Case] Case action type (#80870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Init connector * Add test * Improve comment type * Add integration tests * Fix i18n * Improve tests * Show unknown when username is null * Improve comment type * Pass connector to case client * Improve type after PR #82125 * Add comment migration test * Fix integration tests * Fix reporter on table * Create case connector ui * Add connector to README * Improve casting on executor * Translate name * Improve test * Create comment type enum * Fix type * Fix i18n * Move README to cases * Filter out case connector from alerting Co-authored-by: Mike Côté Co-authored-by: Mike Côté --- x-pack/.i18nrc.json | 1 + x-pack/plugins/actions/README.md | 2 +- x-pack/plugins/case/README.md | 88 ++ .../plugins/case/common/api/cases/comment.ts | 8 +- .../case/server/client/cases/create.test.ts | 54 +- .../case/server/client/cases/update.test.ts | 58 +- .../case/server/client/comments/add.test.ts | 36 +- .../case/server/connectors/case/index.test.ts | 891 ++++++++++++++++++ .../case/server/connectors/case/index.ts | 91 ++ .../case/server/connectors/case/schema.ts | 109 +++ .../server/connectors/case/translations.ts | 11 + .../case/server/connectors/case/types.ts | 42 + .../case/server/connectors/case/validators.ts | 13 + .../plugins/case/server/connectors/index.ts | 56 ++ x-pack/plugins/case/server/plugin.ts | 11 + .../api/__fixtures__/mock_saved_objects.ts | 4 + .../api/cases/comments/post_comment.test.ts | 5 + .../case/server/routes/api/utils.test.ts | 8 +- .../plugins/case/server/routes/api/utils.ts | 7 +- .../server/saved_object_types/comments.ts | 5 + .../server/saved_object_types/migrations.ts | 26 +- .../components/add_comment/index.test.tsx | 4 +- .../cases/components/add_comment/index.tsx | 5 +- .../cases/components/all_cases/columns.tsx | 4 +- .../components/case_view/translations.ts | 4 - .../components/user_action_tree/helpers.tsx | 4 +- .../components/user_action_tree/index.tsx | 18 +- .../user_action_avatar.test.tsx | 14 +- .../user_action_tree/user_action_avatar.tsx | 16 +- .../user_action_tree/user_action_username.tsx | 11 +- .../user_action_username_with_avatar.tsx | 7 +- .../public/cases/containers/api.test.tsx | 3 +- .../public/cases/containers/mock.ts | 4 +- .../public/cases/containers/types.ts | 9 +- .../containers/use_post_comment.test.tsx | 3 + .../public/cases/translations.ts | 4 + .../common/lib/connectors/case/index.ts | 22 + .../lib/connectors/case/translations.ts | 21 + .../public/common/lib/connectors/index.ts | 7 + .../security_solution/public/plugin.tsx | 3 + .../action_connector_form/action_form.tsx | 7 +- .../public/common/constants/index.ts | 2 + .../tests/cases/comments/delete_comment.ts | 8 +- .../tests/cases/comments/find_comments.ts | 25 +- .../basic/tests/cases/comments/get_comment.ts | 6 +- .../basic/tests/cases/comments/migrations.ts | 36 + .../tests/cases/comments/patch_comment.ts | 20 +- .../tests/cases/comments/post_comment.ts | 4 +- .../basic/tests/cases/delete_cases.ts | 12 +- .../basic/tests/cases/find_cases.ts | 41 +- .../basic/tests/cases/patch_cases.ts | 4 + .../basic/tests/cases/push_case.ts | 4 +- .../basic/tests/cases/status/get_status.ts | 4 +- .../basic/tests/cases/tags/get_tags.ts | 3 +- .../user_actions/get_all_user_actions.ts | 49 +- .../basic/tests/connectors/case.ts | 763 +++++++++++++++ .../case_api_integration/basic/tests/index.ts | 1 + .../case_api_integration/common/config.ts | 1 + .../case_api_integration/common/lib/mock.ts | 21 +- .../functional/es_archives/cases/data.json | 72 ++ 60 files changed, 2621 insertions(+), 151 deletions(-) create mode 100644 x-pack/plugins/case/server/connectors/case/index.test.ts create mode 100644 x-pack/plugins/case/server/connectors/case/index.ts create mode 100644 x-pack/plugins/case/server/connectors/case/schema.ts create mode 100644 x-pack/plugins/case/server/connectors/case/translations.ts create mode 100644 x-pack/plugins/case/server/connectors/case/types.ts create mode 100644 x-pack/plugins/case/server/connectors/case/validators.ts create mode 100644 x-pack/plugins/case/server/connectors/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/lib/connectors/index.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/connectors/case.ts diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index eb44ad4d4eafa..3b1e4faf80bce 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -9,6 +9,7 @@ "xpack.apm": "plugins/apm", "xpack.beatsManagement": "plugins/beats_management", "xpack.canvas": "plugins/canvas", + "xpack.case": "plugins/case", "xpack.cloud": "plugins/cloud", "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.discover": "plugins/discover_enhanced", diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 02e8e91c987d8..4fef9bc582d08 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -724,4 +724,4 @@ Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `sche ## user interface -In order to make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). +In order to make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). \ No newline at end of file diff --git a/x-pack/plugins/case/README.md b/x-pack/plugins/case/README.md index c0acb87835207..002fbfb8b53f7 100644 --- a/x-pack/plugins/case/README.md +++ b/x-pack/plugins/case/README.md @@ -7,3 +7,91 @@ Elastic is developing a Case Management Workflow. Follow our progress: - [Case API Documentation](https://documenter.getpostman.com/view/172706/SW7c2SuF?version=latest) - [Github Meta](https://github.com/elastic/kibana/issues/50103) + +# Action types + + +See [Kibana Actions](https://github.com/elastic/kibana/tree/master/x-pack/plugins/actions) for more information. + +## Case + +ID: `.case` + +The params properties are modelled after the arguments to the [Cases API](https://www.elastic.co/guide/en/security/master/cases-api-overview.html). + +### `config` + +This action has no `config` properties. + +### `secrets` + +This action type has no `secrets` properties. + +### `params` + +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------- | ------ | +| subAction | The sub action to perform. It can be `create`, `update`, and `addComment` | string | +| subActionParams | The parameters of the sub action | object | + +#### `subActionParams (create)` + +| Property | Description | Type | +| ----------- | --------------------------------------------------------------------- | ----------------------- | +| tile | The case’s title. | string | +| description | The case’s description. | string | +| tags | String array containing words and phrases that help categorize cases. | string[] | +| connector | Object containing the connector’s configuration. | [connector](#connector) | + +#### `subActionParams (update)` + +| Property | Description | Type | +| ----------- | ---------------------------------------------------------- | ----------------------- | +| id | The ID of the case being updated. | string | +| tile | The updated case title. | string | +| description | The updated case description. | string | +| tags | The updated case tags. | string | +| connector | Object containing the connector’s configuration. | [connector](#connector) | +| status | The updated case status, which can be: `open` or `closed`. | string | +| version | The current case version. | string | + +#### `subActionParams (addComment)` + +| Property | Description | Type | +| -------- | --------------------------------------------------------- | ------ | +| comment | The case’s new comment. | string | +| type | The type of the comment, which can be: `user` or `alert`. | string | + +#### `connector` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------- | ----------------- | +| id | ID of the connector used for pushing case updates to external systems. | string | +| name | The connector name. | string | +| type | The type of the connector. Must be one of these: `.servicenow`, `jira`, `.resilient`, and `.none` | string | +| fields | Object containing the connector’s fields. | [fields](#fields) | + +#### `fields` + +For ServiceNow connectors: + +| Property | Description | Type | +| -------- | ----------------------------- | ------ | +| urgency | The urgency of the incident. | string | +| severity | The severity of the incident. | string | +| impact | The impact of the incident. | string | + +For Jira connectors: + +| Property | Description | Type | +| --------- | -------------------------------------------------------------------- | ------ | +| issueType | The issue type of the issue. | string | +| priority | The priority of the issue. | string | +| parent | The key of the parent issue (Valid when the issue type is Sub-task). | string | + +For IBM Resilient connectors: + +| Property | Description | Type | +| ------------ | ------------------------------- | -------- | +| issueTypes | The issue types of the issue. | string[] | +| severityCode | The severity code of the issue. | string | diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 4549b1c31a7cf..b4daac93940d8 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -10,6 +10,7 @@ import { UserRT } from '../user'; const CommentBasicRt = rt.type({ comment: rt.string, + type: rt.union([rt.literal('alert'), rt.literal('user')]), }); export const CommentAttributesRt = rt.intersection([ @@ -37,7 +38,7 @@ export const CommentResponseRt = rt.intersection([ export const AllCommentsResponseRT = rt.array(CommentResponseRt); export const CommentPatchRequestRt = rt.intersection([ - rt.partial(CommentRequestRt.props), + rt.partial(CommentBasicRt.props), rt.type({ id: rt.string, version: rt.string }), ]); @@ -48,6 +49,11 @@ 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/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index f253dd9f4feb4..d82979de2cb44 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -180,7 +180,7 @@ describe('create', () => { describe('unhappy path', () => { test('it throws when missing title', async () => { - expect.assertions(1); + expect.assertions(3); const postCase = { description: 'This is a brand new case of a bad meanie defacing data', tags: ['defacement'], @@ -199,11 +199,15 @@ describe('create', () => { caseClient.client // @ts-expect-error .create({ theCase: postCase }) - .catch((e) => expect(e).not.toBeNull()); + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); test('it throws when missing description', async () => { - expect.assertions(1); + expect.assertions(3); const postCase = { title: 'a title', tags: ['defacement'], @@ -222,11 +226,15 @@ describe('create', () => { caseClient.client // @ts-expect-error .create({ theCase: postCase }) - .catch((e) => expect(e).not.toBeNull()); + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); test('it throws when missing tags', async () => { - expect.assertions(1); + expect.assertions(3); const postCase = { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', @@ -245,11 +253,15 @@ describe('create', () => { caseClient.client // @ts-expect-error .create({ theCase: postCase }) - .catch((e) => expect(e).not.toBeNull()); + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); test('it throws when missing connector ', async () => { - expect.assertions(1); + expect.assertions(3); const postCase = { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', @@ -263,11 +275,15 @@ describe('create', () => { caseClient.client // @ts-expect-error .create({ theCase: postCase }) - .catch((e) => expect(e).not.toBeNull()); + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); test('it throws when connector missing the right fields', async () => { - expect.assertions(1); + expect.assertions(3); const postCase = { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', @@ -287,11 +303,15 @@ describe('create', () => { caseClient.client // @ts-expect-error .create({ theCase: postCase }) - .catch((e) => expect(e).not.toBeNull()); + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); test('it throws if you passing status for a new case', async () => { - expect.assertions(1); + expect.assertions(3); const postCase = { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', @@ -309,7 +329,11 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.create({ theCase: postCase }).catch((e) => expect(e).not.toBeNull()); + caseClient.client.create({ theCase: postCase }).catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); it(`Returns an error if postNewCase throws`, async () => { @@ -329,7 +353,11 @@ describe('create', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.create({ theCase: postCase }).catch((e) => expect(e).not.toBeNull()); + caseClient.client.create({ theCase: 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 62d897999c11a..10eebd1210a9e 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -247,7 +247,7 @@ describe('update', () => { describe('unhappy path', () => { test('it throws when missing id', async () => { - expect.assertions(1); + expect.assertions(3); const patchCases = { cases: [ { @@ -270,11 +270,15 @@ describe('update', () => { caseClient.client // @ts-expect-error .update({ cases: patchCases }) - .catch((e) => expect(e).not.toBeNull()); + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); test('it throws when missing version', async () => { - expect.assertions(1); + expect.assertions(3); const patchCases = { cases: [ { @@ -297,11 +301,15 @@ describe('update', () => { caseClient.client // @ts-expect-error .update({ cases: patchCases }) - .catch((e) => expect(e).not.toBeNull()); + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); }); test('it throws when fields are identical', async () => { - expect.assertions(1); + expect.assertions(4); const patchCases = { cases: [ { @@ -317,14 +325,16 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client - .update({ cases: patchCases }) - .catch((e) => - expect(e.message).toBe('All update fields are identical to current version.') - ); + caseClient.client.update({ cases: patchCases }).catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(406); + expect(e.message).toBe('All update fields are identical to current version.'); + }); }); test('it throws when case does not exist', async () => { + expect.assertions(4); const patchCases = { cases: [ { @@ -345,17 +355,18 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client - .update({ cases: patchCases }) - .catch((e) => - expect(e.message).toBe( - 'These cases not-exists do not exist. Please check you have the correct ids.' - ) + caseClient.client.update({ cases: patchCases }).catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(404); + expect(e.message).toBe( + 'These cases not-exists do not exist. Please check you have the correct ids.' ); + }); }); test('it throws when cases conflicts', async () => { - expect.assertions(1); + expect.assertions(4); const patchCases = { cases: [ { @@ -371,13 +382,14 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client - .update({ cases: patchCases }) - .catch((e) => - expect(e.message).toBe( - 'These cases mock-id-1 has been updated. Please refresh before saving additional updates.' - ) + caseClient.client.update({ cases: patchCases }).catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(409); + expect(e.message).toBe( + 'These cases mock-id-1 has been updated. Please refresh before saving additional updates.' ); + }); }); }); }); 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 8a316740e41e0..50e104b30178a 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { CommentType } from '../../../common/api'; import { createMockSavedObjectsRepository, mockCaseComments, @@ -30,13 +31,14 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!' }, + comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, }); 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', @@ -61,7 +63,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!' }, + comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, }); expect(res.updated_at).toEqual('2020-10-23T21:54:48.952Z'); @@ -81,7 +83,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!' }, + comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, }); expect( @@ -125,12 +127,13 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true); const res = await caseClient.client.addComment({ caseId: 'mock-id-1', - comment: { comment: 'Wow, good luck catching that bad meanie!' }, + comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, }); 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, @@ -169,6 +172,27 @@ describe('addComment', () => { }); }); + test('it throws when missing comment type', async () => { + expect.assertions(3); + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + caseClient.client + .addComment({ + caseId: 'mock-id-1', + // @ts-expect-error + comment: { comment: 'a comment' }, + }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); + }); + test('it throws when the case does not exists', async () => { expect.assertions(3); @@ -180,7 +204,7 @@ describe('addComment', () => { caseClient.client .addComment({ caseId: 'not-exists', - comment: { comment: 'Wow, good luck catching that bad meanie!' }, + comment: { comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user }, }) .catch((e) => { expect(e).not.toBeNull(); @@ -200,7 +224,7 @@ describe('addComment', () => { caseClient.client .addComment({ caseId: 'mock-id-1', - comment: { comment: 'Throw an error' }, + comment: { comment: 'Throw an error', type: CommentType.user }, }) .catch((e) => { expect(e).not.toBeNull(); diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts new file mode 100644 index 0000000000000..e14281e047915 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -0,0 +1,891 @@ +/* + * 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 { 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 } from '../../../common/api'; +import { + createCaseServiceMock, + createConfigureServiceMock, + createUserActionServiceMock, +} from '../../services/mocks'; +import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; +import { getActionType } from '.'; +import { createCaseClientMock } from '../../client/mocks'; + +const mockCaseClient = createCaseClientMock(); + +jest.mock('../../client', () => ({ + createCaseClient: () => mockCaseClient, +})); + +const services = actionsMock.createServices(); +let caseActionType: CaseActionType; + +describe('case connector', () => { + beforeEach(() => { + jest.resetAllMocks(); + const logger = loggingSystemMock.create().get() as jest.Mocked; + const caseService = createCaseServiceMock(); + const caseConfigureService = createConfigureServiceMock(); + const userActionService = createUserActionServiceMock(); + caseActionType = getActionType({ + logger, + caseService, + caseConfigureService, + userActionService, + }); + }); + + describe('params validation', () => { + describe('create', () => { + it('succeeds when params is valid', () => { + const params: Record = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual(params); + }); + + it('fails when params is not valid', () => { + const params: Record = { + subAction: 'create', + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + + describe('connector', () => { + const connectorTests = [ + { + test: 'jira', + params: { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }, + }, + { + test: 'resilient', + params: { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'resilient', + name: 'Resilient', + type: '.resilient', + fields: { + incidentTypes: ['13'], + severityCode: '3', + }, + }, + }, + }, + }, + { + test: 'servicenow', + params: { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { + impact: 'Medium', + severity: 'Medium', + urgency: 'Medium', + }, + }, + }, + }, + }, + { + test: 'none', + params: { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'none', + name: 'None', + type: '.none', + fields: null, + }, + }, + }, + }, + ]; + + connectorTests.forEach(({ params, test }) => { + it(`succeeds when ${test} fields are valid`, () => { + expect(validateParams(caseActionType, params)).toEqual(params); + }); + }); + + it('set fields to null if they are missing', () => { + const params: Record = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: {}, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { impact: null, severity: null, urgency: null }, + }, + }, + }); + }); + + it('succeeds when none fields are valid', () => { + const params: Record = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'none', + name: 'None', + type: '.none', + fields: null, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual(params); + }); + + it('fails when issueType is not provided', () => { + const params: Record = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + priority: 'High', + parent: null, + }, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[0.subActionParams.connector.fields.issueType]: expected value of type [string] but got [undefined]' + ); + }); + + it('fails with excess fields', () => { + const params: Record = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { + impact: 'Medium', + severity: 'Medium', + urgency: 'Medium', + excess: null, + }, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[0.subActionParams.connector.fields.excess]: definition for this key is missing' + ); + }); + + it('fails with valid fields but wrong type', () => { + const params: Record = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'resilient', + name: 'Resilient', + type: '.resilient', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[0.subActionParams.connector.fields.issueType]: definition for this key is missing' + ); + }); + + it('fails when fields are not null and the type is none', () => { + const params: Record = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'none', + name: 'None', + type: '.none', + fields: {}, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[0.subActionParams.connector]: Fields must be set to null for connectors of type .none' + ); + }); + }); + }); + + describe('update', () => { + it('succeeds when params is valid', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + title: 'Update title', + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + description: null, + tags: null, + title: null, + status: null, + connector: null, + ...(params.subActionParams as Record), + }, + }); + }); + + describe('connector', () => { + it('succeeds when jira fields are valid', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + description: null, + tags: null, + title: null, + status: null, + ...(params.subActionParams as Record), + }, + }); + }); + + it('succeeds when resilient fields are valid', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'resilient', + name: 'Resilient', + type: '.resilient', + fields: { + incidentTypes: ['13'], + severityCode: '3', + }, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + description: null, + tags: null, + title: null, + status: null, + ...(params.subActionParams as Record), + }, + }); + }); + + it('succeeds when servicenow fields are valid', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { + impact: 'Medium', + severity: 'Medium', + urgency: 'Medium', + }, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + description: null, + tags: null, + title: null, + status: null, + ...(params.subActionParams as Record), + }, + }); + }); + + it('set fields to null if they are missing', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: {}, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + id: 'case-id', + version: '123', + description: null, + tags: null, + title: null, + status: null, + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { impact: null, severity: null, urgency: null }, + }, + }, + }); + }); + + it('succeeds when none fields are valid', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'none', + name: 'None', + type: '.none', + fields: null, + }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual({ + ...params, + subActionParams: { + description: null, + tags: null, + title: null, + status: null, + ...(params.subActionParams as Record), + }, + }); + }); + + it('fails when issueType is not provided', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + priority: 'High', + parent: null, + }, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[subActionParams.connector.0.fields.issueType]: expected value of type [string] but got [undefined]' + ); + }); + + it('fails with excess fields', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { + impact: 'Medium', + severity: 'Medium', + urgency: 'Medium', + excess: null, + }, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[subActionParams.connector.0.fields.excess]: definition for this key is missing' + ); + }); + + it('fails with valid fields but wrong type', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'resilient', + name: 'Resilient', + type: '.resilient', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[subActionParams.connector.0.fields.issueType]: definition for this key is missing' + ); + }); + + it('fails when fields are not null and the type is none', () => { + const params: Record = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + connector: { + id: 'none', + name: 'None', + type: '.none', + fields: {}, + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow( + '[subActionParams.connector.0]: Fields must be set to null for connectors of type .none' + ); + }); + }); + + it('fails when params is not valid', () => { + const params: Record = { + subAction: 'update', + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + }); + + describe('add comment', () => { + it('succeeds when params is valid', () => { + const params: Record = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment: { comment: 'a comment', type: CommentType.user }, + }, + }; + + expect(validateParams(caseActionType, params)).toEqual(params); + }); + + it('fails when params is not valid', () => { + const params: Record = { + subAction: 'addComment', + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + }); + }); + + describe('execute', () => { + it('allows only supported sub-actions', async () => { + expect.assertions(2); + const actionId = 'some-id'; + const params: CaseExecutorParams = { + // @ts-expect-error + subAction: 'not-supported', + // @ts-expect-error + subActionParams: {}, + }; + + const executorOptions: CaseActionTypeExecutorOptions = { + actionId, + config: {}, + params, + secrets: {}, + services, + }; + + caseActionType.executor(executorOptions).catch((e) => { + expect(e).not.toBeNull(); + expect(e.message).toBe('[Action][Case] subAction not-supported not implemented.'); + }); + }); + + describe('create', () => { + it('executes correctly', async () => { + const createReturn = { + 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', + }, + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + external_service: null, + status: 'open' as const, + updated_at: null, + updated_by: null, + version: 'WzksMV0=', + }; + + mockCaseClient.create.mockReturnValue(Promise.resolve(createReturn)); + + const actionId = 'some-id'; + const params: CaseExecutorParams = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'Yo fields!!', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + const executorOptions: CaseActionTypeExecutorOptions = { + actionId, + config: {}, + params, + secrets: {}, + services, + }; + + const result = await caseActionType.executor(executorOptions); + + 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, + }, + }, + }, + }); + }); + }); + + describe('update', () => { + it('executes correctly', async () => { + const updateReturn = [ + { + 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: 'open' as const, + tags: ['defacement'], + title: 'Update title', + totalComment: 0, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }, + version: 'WzE3LDFd', + }, + ]; + + mockCaseClient.update.mockReturnValue(Promise.resolve(updateReturn)); + + const actionId = 'some-id'; + const params: CaseExecutorParams = { + subAction: 'update', + subActionParams: { + id: 'case-id', + version: '123', + title: 'Update title', + description: null, + tags: null, + status: null, + connector: null, + }, + }; + + const executorOptions: CaseActionTypeExecutorOptions = { + actionId, + config: {}, + params, + secrets: {}, + services, + }; + + const result = await caseActionType.executor(executorOptions); + + expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); + expect(mockCaseClient.update).toHaveBeenCalledWith({ + // Null values have been striped out. + cases: { + cases: [ + { + id: 'case-id', + version: '123', + title: 'Update title', + }, + ], + }, + }); + }); + }); + + describe('addComment', () => { + it('executes correctly', async () => { + const commentReturn = { + id: 'mock-it', + 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: 'open' as const, + tags: ['defacement'], + updated_at: null, + updated_by: null, + version: 'WzksMV0=', + comments: [ + { + comment: 'a comment', + type: CommentType.user as const, + 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=', + }, + ], + }; + + mockCaseClient.addComment.mockReturnValue(Promise.resolve(commentReturn)); + + const actionId = 'some-id'; + const params: CaseExecutorParams = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment: { comment: 'a comment', type: CommentType.user }, + }, + }; + + const executorOptions: CaseActionTypeExecutorOptions = { + actionId, + config: {}, + params, + secrets: {}, + services, + }; + + const result = await caseActionType.executor(executorOptions); + + expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); + expect(mockCaseClient.addComment).toHaveBeenCalledWith({ + caseId: 'case-id', + comment: { comment: 'a comment', type: CommentType.user }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts new file mode 100644 index 0000000000000..f284f0ed9668c --- /dev/null +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -0,0 +1,91 @@ +/* + * 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 { curry } from 'lodash'; + +import { KibanaRequest } from 'kibana/server'; +import { ActionTypeExecutorResult } from '../../../../actions/common'; +import { CasePatchRequest, CasePostRequest } from '../../../common/api'; +import { createCaseClient } from '../../client'; +import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; +import { + CaseExecutorResponse, + ExecutorSubActionAddCommentParams, + CaseActionType, + CaseActionTypeExecutorOptions, +} from './types'; +import * as i18n from './translations'; + +import { GetActionTypeParams } from '..'; + +const supportedSubActions: string[] = ['create', 'update', 'addComment']; + +// action type definition +export function getActionType({ + logger, + caseService, + caseConfigureService, + userActionService, +}: GetActionTypeParams): CaseActionType { + return { + id: '.case', + minimumLicenseRequired: 'gold', + name: i18n.NAME, + validate: { + config: CaseConfigurationSchema, + params: CaseExecutorParamsSchema, + }, + executor: curry(executor)({ logger, caseService, caseConfigureService, userActionService }), + }; +} + +// action executor +async function executor( + { logger, caseService, caseConfigureService, userActionService }: GetActionTypeParams, + execOptions: CaseActionTypeExecutorOptions +): Promise> { + const { actionId, params, services } = execOptions; + const { subAction, subActionParams } = params; + let data: CaseExecutorResponse | null = null; + + const { savedObjectsClient } = services; + const caseClient = createCaseClient({ + savedObjectsClient, + request: {} as KibanaRequest, + caseService, + caseConfigureService, + userActionService, + }); + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][Case] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (subAction === 'create') { + data = await caseClient.create({ theCase: subActionParams as CasePostRequest }); + } + + if (subAction === 'update') { + const updateParamsWithoutNullValues = Object.entries(subActionParams).reduce( + (acc, [key, value]) => ({ + ...acc, + ...(value != null ? { [key]: value } : {}), + }), + {} as CasePatchRequest + ); + + data = await caseClient.update({ cases: { cases: [updateParamsWithoutNullValues] } }); + } + + if (subAction === 'addComment') { + const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; + 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 new file mode 100644 index 0000000000000..aa503e96be30d --- /dev/null +++ b/x-pack/plugins/case/server/connectors/case/schema.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { validateConnector } from './validators'; + +// Reserved for future implementation +export const CaseConfigurationSchema = schema.object({}); + +const CommentProps = { + comment: schema.string(), + type: schema.oneOf([schema.literal('alert'), schema.literal('user')]), +}; + +const JiraFieldsSchema = schema.object({ + issueType: schema.string(), + priority: schema.nullable(schema.string()), + parent: schema.nullable(schema.string()), +}); + +const ResilientFieldsSchema = schema.object({ + incidentTypes: schema.nullable(schema.arrayOf(schema.string())), + severityCode: schema.nullable(schema.string()), +}); + +const ServiceNowFieldsSchema = schema.object({ + impact: schema.nullable(schema.string()), + severity: schema.nullable(schema.string()), + urgency: schema.nullable(schema.string()), +}); + +const NoneFieldsSchema = schema.nullable(schema.object({})); + +const ReducedConnectorFieldsSchema: { [x: string]: any } = { + '.jira': JiraFieldsSchema, + '.resilient': ResilientFieldsSchema, +}; + +export const ConnectorProps = { + id: schema.string(), + name: schema.string(), + type: schema.oneOf([ + schema.literal('.servicenow'), + schema.literal('.jira'), + schema.literal('.resilient'), + schema.literal('.none'), + ]), + // Chain of conditional schemes + fields: Object.keys(ReducedConnectorFieldsSchema).reduce( + (conditionalSchema, key) => + schema.conditional( + schema.siblingRef('type'), + key, + ReducedConnectorFieldsSchema[key], + conditionalSchema + ), + schema.conditional( + schema.siblingRef('type'), + '.servicenow', + ServiceNowFieldsSchema, + NoneFieldsSchema + ) + ), +}; + +export const ConnectorSchema = schema.object(ConnectorProps); + +const CaseBasicProps = { + description: schema.string(), + title: schema.string(), + tags: schema.arrayOf(schema.string()), + connector: schema.object(ConnectorProps, { validate: validateConnector }), +}; + +const CaseUpdateRequestProps = { + id: schema.string(), + version: schema.string(), + description: schema.nullable(CaseBasicProps.description), + title: schema.nullable(CaseBasicProps.title), + tags: schema.nullable(CaseBasicProps.tags), + connector: schema.nullable(CaseBasicProps.connector), + status: schema.nullable(schema.string()), +}; + +const CaseAddCommentRequestProps = { + caseId: schema.string(), + comment: schema.object(CommentProps), +}; + +export const ExecutorSubActionCreateParamsSchema = schema.object(CaseBasicProps); +export const ExecutorSubActionUpdateParamsSchema = schema.object(CaseUpdateRequestProps); +export const ExecutorSubActionAddCommentParamsSchema = schema.object(CaseAddCommentRequestProps); + +export const CaseExecutorParamsSchema = schema.oneOf([ + schema.object({ + subAction: schema.literal('create'), + subActionParams: ExecutorSubActionCreateParamsSchema, + }), + schema.object({ + subAction: schema.literal('update'), + subActionParams: ExecutorSubActionUpdateParamsSchema, + }), + schema.object({ + subAction: schema.literal('addComment'), + subActionParams: ExecutorSubActionAddCommentParamsSchema, + }), +]); diff --git a/x-pack/plugins/case/server/connectors/case/translations.ts b/x-pack/plugins/case/server/connectors/case/translations.ts new file mode 100644 index 0000000000000..9356ea8a31797 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/case/translations.ts @@ -0,0 +1,11 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.case.connectors.case.title', { + defaultMessage: 'Case', +}); diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts new file mode 100644 index 0000000000000..b3a05163fa6f4 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -0,0 +1,42 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { ActionType, ActionTypeExecutorOptions } from '../../../../actions/server'; +import { + CaseExecutorParamsSchema, + ExecutorSubActionCreateParamsSchema, + ExecutorSubActionUpdateParamsSchema, + CaseConfigurationSchema, + ExecutorSubActionAddCommentParamsSchema, + ConnectorSchema, +} from './schema'; +import { CaseResponse, CasesResponse } from '../../../common/api'; + +export type CaseConfiguration = TypeOf; +export type Connector = TypeOf; + +export type ExecutorSubActionCreateParams = TypeOf; +export type ExecutorSubActionUpdateParams = TypeOf; +export type ExecutorSubActionAddCommentParams = TypeOf< + typeof ExecutorSubActionAddCommentParamsSchema +>; + +export type CaseExecutorParams = TypeOf; +export type CaseExecutorResponse = CaseResponse | CasesResponse; + +export type CaseActionType = ActionType< + CaseConfiguration, + {}, + CaseExecutorParams, + CaseExecutorResponse | {} +>; + +export type CaseActionTypeExecutorOptions = ActionTypeExecutorOptions< + CaseConfiguration, + {}, + CaseExecutorParams +>; diff --git a/x-pack/plugins/case/server/connectors/case/validators.ts b/x-pack/plugins/case/server/connectors/case/validators.ts new file mode 100644 index 0000000000000..f8330492d4366 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/case/validators.ts @@ -0,0 +1,13 @@ +/* + * 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 { Connector } from './types'; + +export const validateConnector = (connector: Connector) => { + if (connector.type === '.none' && connector.fields !== null) { + return 'Fields must be set to null for connectors of type .none'; + } +}; diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts new file mode 100644 index 0000000000000..6a97a9e6e8a8a --- /dev/null +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -0,0 +1,56 @@ +/* + * 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 { Logger } from 'kibana/server'; +import { + ActionTypeConfig, + ActionTypeSecrets, + ActionTypeParams, + ActionType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../actions/server/types'; +import { + CaseServiceSetup, + CaseConfigureServiceSetup, + CaseUserActionServiceSetup, +} from '../services'; + +import { getActionType as getCaseConnector } from './case'; + +export interface GetActionTypeParams { + logger: Logger; + caseService: CaseServiceSetup; + caseConfigureService: CaseConfigureServiceSetup; + userActionService: CaseUserActionServiceSetup; +} + +export interface RegisterConnectorsArgs extends GetActionTypeParams { + actionsRegisterType< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets, + Params extends ActionTypeParams = ActionTypeParams, + ExecutorResultData = void + >( + actionType: ActionType + ): void; +} + +export const registerConnectors = ({ + actionsRegisterType, + logger, + caseService, + caseConfigureService, + userActionService, +}: RegisterConnectorsArgs) => { + actionsRegisterType( + getCaseConnector({ + logger, + caseService, + caseConfigureService, + userActionService, + }) + ); +}; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 5398f8ed0ae83..64c4b422d1cf7 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -15,6 +15,7 @@ import { 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'; @@ -34,6 +35,7 @@ import { CaseUserActionServiceSetup, } from './services'; import { createCaseClient } from './client'; +import { registerConnectors } from './connectors'; function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map((config) => config)); @@ -41,6 +43,7 @@ function createConfig$(context: PluginInitializerContext) { export interface PluginsSetup { security: SecurityPluginSetup; + actions: ActionsPluginSetup; } export class CasePlugin { @@ -94,6 +97,14 @@ export class CasePlugin { userActionService: this.userActionService, router, }); + + registerConnectors({ + actionsRegisterType: plugins.actions.registerType, + logger: this.log, + caseService: this.caseService, + caseConfigureService: this.caseConfigureService, + userActionService: this.userActionService, + }); } public async start(core: CoreStart) { 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 e7ea381da9955..9314ebb445820 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 @@ -10,6 +10,7 @@ import { CommentAttributes, ESCaseAttributes, ConnectorTypes, + CommentType, } from '../../../../common/api'; export const mockCases: Array> = [ @@ -207,6 +208,7 @@ export const mockCaseComments: Array> = [ id: 'mock-comment-1', attributes: { comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, created_at: '2019-11-25T21:55:00.177Z', created_by: { full_name: 'elastic', @@ -237,6 +239,7 @@ export const mockCaseComments: Array> = [ id: 'mock-comment-2', attributes: { 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', @@ -268,6 +271,7 @@ export const mockCaseComments: Array> = [ id: 'mock-comment-3', attributes: { comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, created_at: '2019-11-25T22:32:30.608Z', created_by: { full_name: 'elastic', 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 acc23815e3a39..0b733bb034f8c 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 @@ -16,6 +16,7 @@ import { } from '../../__fixtures__'; import { initPostCommentApi } from './post_comment'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CommentType } from '../../../../../common/api'; describe('POST comment', () => { let routeHandler: RequestHandler; @@ -36,6 +37,7 @@ describe('POST comment', () => { }, body: { comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, }, }); @@ -62,6 +64,7 @@ describe('POST comment', () => { }, body: { comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, }, }); @@ -112,6 +115,7 @@ describe('POST comment', () => { }, body: { comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user, }, }); @@ -127,6 +131,7 @@ describe('POST comment', () => { 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, 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 00584a9d7431f..fc1086b03814b 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,7 @@ import { mockCaseComments, mockCaseNoConnectorId, } from './__fixtures__/mock_saved_objects'; -import { ConnectorTypes, ESCaseConnector } from '../../../common/api'; +import { ConnectorTypes, ESCaseConnector, CommentType } from '../../../common/api'; describe('Utils', () => { describe('transformNewCase', () => { @@ -117,6 +117,7 @@ describe('Utils', () => { it('transforms correctly', () => { const comment = { comment: 'A comment', + type: CommentType.user, createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', full_name: 'Elastic', @@ -126,6 +127,7 @@ describe('Utils', () => { 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, @@ -138,6 +140,7 @@ describe('Utils', () => { it('transform correctly without optional fields', () => { const comment = { comment: 'A comment', + type: CommentType.user, createdDate: '2020-04-09T09:43:51.778Z', }; @@ -145,6 +148,7 @@ describe('Utils', () => { 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, @@ -157,6 +161,7 @@ describe('Utils', () => { it('transform correctly with optional fields as null', () => { const comment = { comment: 'A comment', + type: CommentType.user, createdDate: '2020-04-09T09:43:51.778Z', email: null, full_name: null, @@ -167,6 +172,7 @@ describe('Utils', () => { 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, diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 3f82dac96a70e..f8fe149c2ff2f 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -22,6 +22,7 @@ import { CommentAttributes, ESCaseConnector, ESCaseAttributes, + CommentRequest, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; @@ -55,15 +56,16 @@ export const transformNewCase = ({ updated_by: null, }); -interface NewCommentArgs { - comment: string; +interface NewCommentArgs extends CommentRequest { createdDate: string; email?: string | null; full_name?: string | null; username?: string | null; } + export const transformNewComment = ({ comment, + type, createdDate, email, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -71,6 +73,7 @@ export const transformNewComment = ({ username, }: NewCommentArgs): CommentAttributes => ({ comment, + type, created_at: createdDate, created_by: { email, full_name, username }, pushed_at: null, 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 8b69f272d5b0d..87478eb23641f 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -5,6 +5,7 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { commentsMigrations } from './migrations'; export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; @@ -17,6 +18,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { comment: { type: 'text', }, + type: { + type: 'keyword', + }, created_at: { type: 'date', }, @@ -67,4 +71,5 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, + migrations: commentsMigrations, }; 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 c3dd88799b5fb..27c363a40af37 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 } from '../../common/api/connectors'; +import { ConnectorTypes, CommentType } from '../../common/api'; interface UnsanitizedCase { connector_id: string; @@ -126,3 +126,27 @@ export const userActionsMigrations = { }; }, }; + +interface UnsanitizedComment { + comment: string; +} + +interface SanitizedComment { + comment: string; + type: CommentType; +} + +export const commentsMigrations = { + '7.11.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + type: CommentType.user, + }, + references: doc.references || [], + }; + }, +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx index a85d7a310bc06..2c8051f902b17 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx @@ -12,6 +12,7 @@ import { TestProviders } from '../../../common/mock'; import { getFormMock } from '../__mock__/form'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; +import { CommentRequest, CommentType } from '../../../../../case/common/api'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { usePostComment } from '../../containers/use_post_comment'; import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; @@ -66,8 +67,9 @@ const defaultPostCommment = { postComment, }; -const sampleData = { +const sampleData: CommentRequest = { comment: 'what a cool comment', + type: CommentType.user, }; describe('AddComment ', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index 5b77c4d99a951..c54bd8b621d83 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -8,7 +8,7 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, forwardRef, useImperativeHandle } from 'react'; import styled from 'styled-components'; -import { CommentRequest } from '../../../../../case/common/api'; +import { CommentRequest, CommentType } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; @@ -27,6 +27,7 @@ const MySpinner = styled(EuiLoadingSpinner)` const initialCommentValue: CommentRequest = { comment: '', + type: CommentType.user, }; export interface AddCommentRefObject { @@ -81,7 +82,7 @@ export const AddComment = React.memo( if (onCommentSaving != null) { onCommentSaving(); } - postComment(data, onCommentPosted); + postComment({ ...data, type: CommentType.user }, onCommentPosted); reset(); } }, [onCommentPosted, onCommentSaving, postComment, reset, submit]); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx index 5c6c72477bf1f..42b97d5f6130f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx @@ -82,11 +82,11 @@ export const getCasesColumns = ( <> - {createdBy.fullName ? createdBy.fullName : createdBy.username ?? ''} + {createdBy.fullName ? createdBy.fullName : createdBy.username ?? i18n.UNKNOWN} ); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts index 04bb8801c9f00..ac518a9cc2fb0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts @@ -152,10 +152,6 @@ export const EMAIL_BODY = (caseUrl: string) => defaultMessage: 'Case reference: {caseUrl}', }); -export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', { - defaultMessage: 'Unknown', -}); - export const CHANGED_CONNECTOR_FIELD = i18n.translate( 'xpack.securitySolution.case.caseView.fieldChanged', { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index 0ced285f9dcd9..2abcb70d676ef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -131,8 +131,8 @@ export const getUpdateAction = ({ }): EuiCommentProps => ({ username: ( ), type: 'update', 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 1967402fd81e0..de3e9c07ae8a3 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 @@ -217,8 +217,8 @@ export const UserActionTree = React.memo( () => ({ username: ( ), event: i18n.ADDED_DESCRIPTION, @@ -270,8 +270,8 @@ export const UserActionTree = React.memo( { username: ( ), 'data-test-subj': `comment-create-action-${comment.id}`, @@ -418,17 +418,11 @@ export const UserActionTree = React.memo( const bottomActions = [ { username: ( - + ), 'data-test-subj': 'add-comment', timelineIcon: ( - + ), className: 'isEdit', children: MarkdownNewComment, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx index df5c51394b88a..fbebea6f1148f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx @@ -22,26 +22,18 @@ describe('UserActionAvatar ', () => { it('it renders', async () => { expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); - expect( - wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() - ).toBeFalsy(); expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('E'); }); it('it shows the username if the fullName is undefined', async () => { wrapper = mount(); expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); - expect( - wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() - ).toBeFalsy(); expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('e'); }); - it('shows the loading spinner when the username AND the fullName are undefined', async () => { + it('shows unknown when the username AND the fullName are undefined', async () => { wrapper = mount(); - expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('U'); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx index 8339d9bedd123..025cbcb2e2710 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx @@ -5,7 +5,9 @@ */ import React, { memo } from 'react'; -import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiAvatar } from '@elastic/eui'; + +import * as i18n from './translations'; interface UserActionAvatarProps { username?: string | null; @@ -13,17 +15,9 @@ interface UserActionAvatarProps { } const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => { - const avatarName = fullName && fullName.length > 0 ? fullName : username ?? null; + const avatarName = fullName && fullName.length > 0 ? fullName : username ?? i18n.UNKNOWN; - return ( - <> - {avatarName ? ( - - ) : ( - - )} - - ); + return ; }; export const UserActionAvatar = memo(UserActionAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx index dbc153ddbe577..8730de39ba39c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx @@ -8,19 +8,22 @@ import React, { memo } from 'react'; import { EuiToolTip } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; +import * as i18n from './translations'; + interface UserActionUsernameProps { - username: string; - fullName?: string; + username?: string | null; + fullName?: string | null; } const UserActionUsernameComponent = ({ username, fullName }: UserActionUsernameProps) => { + const tooltipContent = (isEmpty(fullName) ? username : fullName) ?? i18n.UNKNOWN; return ( {isEmpty(fullName) ? username : fullName}

} + content={

{tooltipContent}

} data-test-subj="user-action-username-tooltip" > - {username} + {username ?? i18n.UNKNOWN.toLowerCase()}
); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx index e2326a3580e6f..9d5ab2d7ae6ef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx @@ -9,10 +9,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import { UserActionUsername } from './user_action_username'; +import * as i18n from './translations'; interface UserActionUsernameWithAvatarProps { - username: string; - fullName?: string; + username?: string | null; + fullName?: string | null; } const UserActionUsernameWithAvatarComponent = ({ @@ -29,7 +30,7 @@ const UserActionUsernameWithAvatarComponent = ({ diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index 373202968f79b..0d5bf13cd6261 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -51,7 +51,7 @@ import { import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import * as i18n from './translations'; -import { ConnectorTypes } from '../../../../case/common/api/connectors'; +import { ConnectorTypes, CommentType } from '../../../../case/common/api'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -404,6 +404,7 @@ describe('Case Configuration API', () => { }); const data = { comment: 'comment', + type: CommentType.user, }; test('check url, method, signal', async () => { 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 218ed77399df0..c5b60041f5cac 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -17,7 +17,8 @@ import { CaseUserActionsResponse, CasesResponse, CasesFindResponse, -} from '../../../../case/common/api/cases'; + CommentType, +} from '../../../../case/common/api'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { ConnectorTypes } from '../../../../case/common/api/connectors'; export { connectorsMock } from './configure/mock'; @@ -42,6 +43,7 @@ export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { comment: 'Solve this fast!', + type: CommentType.user, id: basicCommentId, createdAt: basicCreatedAt, createdBy: elasticUser, 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 df3e75449b627..c2ddcce8b1d3c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { User, UserActionField, UserAction, CaseConnector } from '../../../../case/common/api'; +import { + User, + UserActionField, + UserAction, + CaseConnector, + CommentType, +} from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; @@ -13,6 +19,7 @@ export interface Comment { createdAt: string; createdBy: ElasticUser; comment: string; + type: CommentType; pushedAt: string | null; pushedBy: string | null; updatedAt: string | null; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx index d7d9cf9c557c9..773d4b8d1fe56 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx @@ -5,6 +5,8 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; + +import { CommentType } from '../../../../case/common/api'; import { usePostComment, UsePostComment } from './use_post_comment'; import { basicCaseId } from './mock'; import * as api from './api'; @@ -15,6 +17,7 @@ describe('usePostComment', () => { const abortCtrl = new AbortController(); const samplePost = { comment: 'a comment', + type: CommentType.user, }; const updateCaseCallback = jest.fn(); beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index a0b5f71db7df0..1d60310731d5e 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -234,3 +234,7 @@ export const EDIT_CONNECTOR = i18n.translate('xpack.securitySolution.case.caseVi export const NO_CONNECTOR = i18n.translate('xpack.securitySolution.case.common.noConnector', { defaultMessage: 'No connector selected', }); + +export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', { + defaultMessage: 'Unknown', +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts new file mode 100644 index 0000000000000..271b1bfd2e3de --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/case/index.ts @@ -0,0 +1,22 @@ +/* + * 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-next-line @kbn/eslint/no-restricted-paths +import { ActionTypeModel } from '../../../../../../triggers_actions_ui/public/types'; +import * as i18n from './translations'; + +export function getActionType(): ActionTypeModel { + return { + id: '.case', + iconClass: 'securityAnalyticsApp', + selectMessage: i18n.CASE_CONNECTOR_DESC, + actionTypeTitle: i18n.CASE_CONNECTOR_TITLE, + validateConnector: () => ({ errors: {} }), + validateParams: () => ({ errors: {} }), + actionConnectorFields: null, + actionParamsFields: null, + }; +} diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts new file mode 100644 index 0000000000000..a39e04acc1bf3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/case/translations.ts @@ -0,0 +1,21 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const CASE_CONNECTOR_DESC = i18n.translate( + 'xpack.securitySolution.case.components.case.selectMessageText', + { + defaultMessage: 'Create or update a case.', + } +); + +export const CASE_CONNECTOR_TITLE = i18n.translate( + 'xpack.securitySolution.case.components.case.actionTypeTitle', + { + defaultMessage: 'Cases', + } +); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts new file mode 100644 index 0000000000000..58d7e89e080e7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/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 { getActionType as getCaseConnectorUI } from './case'; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index b81bea1d12b27..08c780d4a7203 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -62,6 +62,7 @@ import { IndexFieldsStrategyResponse, } from '../common/search_strategy/index_fields'; import { SecurityAppStore } from './common/store/store'; +import { getCaseConnectorUI } from './common/lib/connectors'; export class Plugin implements IPlugin { private kibanaVersion: string; @@ -312,6 +313,8 @@ export class Plugin implements IPlugin { /** diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 51d3b0074ca54..74432157f5659 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -45,7 +45,7 @@ import { SectionLoading } from '../../components/section_loading'; import { ConnectorAddModal } from './connector_add_modal'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; -import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; +import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; import { hasSaveActionsCapability } from '../../lib/capabilities'; interface ActionAccordionFormProps { @@ -579,6 +579,11 @@ export const ActionForm = ({ const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured); actionTypeNodes = actionTypeRegistry .list() + /** + * TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. + * If actionTypes are set, hidden connectors are filtered out. Otherwise, they are not. + */ + .filter(({ id }) => actionTypes ?? !DEFAULT_HIDDEN_ACTION_TYPES.includes(id)) .filter((item) => actionTypesIndex[item.id]) .filter((item) => !!item.actionParamsFields) .sort((a, b) => diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index a2a1657a1f4cc..833ed915fad59 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -9,3 +9,5 @@ export { AGGREGATION_TYPES, builtInAggregationTypes } from './aggregation_types' export { builtInGroupByTypes } from './group_by_types'; export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions'; +// TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. +export const DEFAULT_HIDDEN_ACTION_TYPES = ['.case']; 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 afae04ae9cf5b..5fb6f21c51c95 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 @@ -33,11 +33,13 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body: comment } = await supertest .delete(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) .set('kbn-xsrf', 'true') + .expect(204) .send(); expect(comment).to.eql({}); @@ -53,13 +55,15 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body } = await supertest .delete(`${CASES_URL}/fake-id/comments/${patchedCase.comments[0].id}`) .set('kbn-xsrf', 'true') .send() .expect(404); + expect(body.message).to.eql( `This comment ${patchedCase.comments[0].id} does not exist in fake-id).` ); 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 e5c44de90b5a1..c67eda1d3a16b 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 @@ -29,21 +29,25 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + // post 2 comments await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body: caseComments } = await supertest .get(`${CASES_URL}/${postedCase.id}/comments/_find`) .set('kbn-xsrf', 'true') - .send(); + .send() + .expect(200); expect(caseComments.comments).to.eql(patchedCase.comments); }); @@ -54,21 +58,25 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + // post 2 comments await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send({ comment: 'unique' }); + .send({ comment: 'unique', type: 'user' }) + .expect(200); const { body: caseComments } = await supertest .get(`${CASES_URL}/${postedCase.id}/comments/_find?search=unique`) .set('kbn-xsrf', 'true') - .send(); + .send() + .expect(200); expect(caseComments.comments).to.eql([patchedCase.comments[1]]); }); @@ -79,10 +87,13 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + await supertest .get(`${CASES_URL}/${postedCase.id}/comments/_find?perPage=true`) .set('kbn-xsrf', 'true') 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 53da0ef1d2b16..9c3a85e99c29d 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 @@ -27,12 +27,14 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body: comment } = await supertest .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts new file mode 100644 index 0000000000000..a96197cee5f3b --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts @@ -0,0 +1,36 @@ +/* + * 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 } from '../../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default function createGetTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('migrations', () => { + before(async () => { + await esArchiver.load('cases'); + }); + + after(async () => { + await esArchiver.unload('cases'); + }); + + it('7.11.0 migrates cases comments', async () => { + const { body: comment } = await supertest + .get( + `${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509/comments/da677740-1ac7-11eb-b5a3-25ee88122510` + ) + .set('kbn-xsrf', 'true') + .send(); + + expect(comment.type).to.eql('user'); + }); + }); +} 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 73aeeb0fb989a..3176841b009d4 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 @@ -33,7 +33,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; const { body } = await supertest .patch(`${CASES_URL}/${postedCase.id}/comments`) @@ -42,7 +44,9 @@ export default ({ getService }: FtrProviderContext): void => { id: patchedCase.comments[0].id, version: patchedCase.comments[0].version, comment: newComment, - }); + }) + .expect(200); + expect(body.comments[0].comment).to.eql(newComment); expect(body.updated_by).to.eql(defaultUser); }); @@ -51,7 +55,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + await supertest .patch(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') @@ -85,7 +91,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + await supertest .patch(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') @@ -107,7 +115,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; await supertest .patch(`${CASES_URL}/${postedCase.id}/comments`) 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 6e8353f8ea86a..0c7ab52abf8c8 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 @@ -33,7 +33,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + expect(patchedCase.comments[0].comment).to.eql(postCommentReq.comment); expect(patchedCase.updated_by).to.eql(defaultUser); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts index aa2465e44c5c1..73d17b985216a 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts @@ -27,7 +27,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); const { body } = await supertest .delete(`${CASES_URL}?ids=["${postedCase.id}"]`) @@ -42,29 +43,34 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); await supertest .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) .set('kbn-xsrf', 'true') .send() .expect(200); + await supertest .delete(`${CASES_URL}?ids=["${postedCase.id}"]`) .set('kbn-xsrf', 'true') .send() .expect(204); + await supertest .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) .set('kbn-xsrf', 'true') .send() .expect(404); }); + it('unhappy path - 404s when case is not there', async () => { await supertest .delete(`${CASES_URL}?ids=["fake-id"]`) 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 39762866ac506..17814868fecc0 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 @@ -33,9 +33,24 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should return cases', async () => { - const { body: a } = await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq); - const { body: b } = await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq); - const { body: c } = await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq); + const { body: a } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: b } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: c } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + const { body } = await supertest .get(`${CASES_URL}/_find?sortOrder=asc`) .set('kbn-xsrf', 'true') @@ -55,7 +70,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, tags: ['unique'] }); + .send({ ...postCaseReq, tags: ['unique'] }) + .expect(200); + const { body } = await supertest .get(`${CASES_URL}/_find?sortOrder=asc&tags=unique`) .set('kbn-xsrf', 'true') @@ -74,17 +91,22 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); // post 2 comments await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + const { body } = await supertest .get(`${CASES_URL}/_find?sortOrder=asc`) .set('kbn-xsrf', 'true') @@ -110,7 +132,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -124,6 +148,7 @@ export default ({ getService }: FtrProviderContext): void => { ], }) .expect(200); + const { body } = await supertest .get(`${CASES_URL}/_find?sortOrder=asc`) .set('kbn-xsrf', '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 861a1ce78cf7c..08e80bef34555 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 @@ -118,6 +118,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -139,6 +140,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -160,6 +162,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -181,6 +184,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index 0d3d3df5bbd17..80cf2c8199807 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -130,7 +130,8 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body } = await supertest .post(`${CASES_URL}/${postedCase.id}/_push`) @@ -143,6 +144,7 @@ export default ({ getService }: FtrProviderContext): void => { external_url: 'external_url', }) .expect(200); + expect(body.comments[0].pushed_by).to.eql(defaultUser); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts b/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts index 737f90abf512b..d3cd69384b93d 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts @@ -26,7 +26,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts b/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts index 515cb72424e2a..71e370809c3c7 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts @@ -26,7 +26,8 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, tags: ['unique'] }); + .send({ ...postCaseReq, tags: ['unique'] }) + .expect(200); const { body } = await supertest .get(CASE_TAGS_URL) 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 e53013348c66b..92ef544ee9b37 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 @@ -39,13 +39,15 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); const { body } = await supertest .get(`${CASES_URL}/${postedCase.id}/user_actions`) .set('kbn-xsrf', 'true') .send() .expect(200); + expect(body.length).to.eql(1); expect(body[0].action_field).to.eql(['description', 'status', 'tags', 'title', 'connector']); @@ -58,7 +60,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -78,6 +82,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); + expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['status']); expect(body[1].action).to.eql('update'); @@ -89,7 +94,8 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); const newConnector = { id: '123', @@ -117,6 +123,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); + expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['connector']); expect(body[1].action).to.eql('update'); @@ -130,7 +137,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -150,6 +159,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); + expect(body.length).to.eql(3); expect(body[1].action_field).to.eql(['tags']); expect(body[1].action).to.eql('add'); @@ -165,7 +175,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + const newTitle = 'Such a great title'; await supertest .patch(CASES_URL) @@ -186,6 +198,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); + expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['title']); expect(body[1].action).to.eql('update'); @@ -197,7 +210,9 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + const newDesc = 'Such a great description'; await supertest .patch(CASES_URL) @@ -218,6 +233,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); + expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['description']); expect(body[1].action).to.eql('update'); @@ -229,19 +245,22 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); const { body } = await supertest .get(`${CASES_URL}/${postedCase.id}/user_actions`) .set('kbn-xsrf', 'true') .send() .expect(200); - expect(body.length).to.eql(2); + expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['comment']); expect(body[1].action).to.eql('create'); expect(body[1].old_value).to.eql(null); @@ -252,11 +271,15 @@ export default ({ getService }: FtrProviderContext): void => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq); + .send(postCaseReq) + .expect(200); + const { body: patchedCase } = await supertest .post(`${CASES_URL}/${postedCase.id}/comments`) .set('kbn-xsrf', 'true') - .send(postCommentReq); + .send(postCommentReq) + .expect(200); + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; await supertest.patch(`${CASES_URL}/${postedCase.id}/comments`).set('kbn-xsrf', 'true').send({ id: patchedCase.comments[0].id, @@ -269,8 +292,8 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); - expect(body.length).to.eql(3); + 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(postCommentReq.comment); @@ -329,8 +352,8 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); - expect(body.length).to.eql(2); + expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['pushed']); expect(body[1].action).to.eql('push-to-service'); expect(body[1].old_value).to.eql(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 new file mode 100644 index 0000000000000..7a351d09b5b9f --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -0,0 +1,763 @@ +/* + * 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 } from '../../../../../plugins/case/common/constants'; +import { + postCaseReq, + postCaseResp, + removeServerGeneratedPropertiesFromCase, + removeServerGeneratedPropertiesFromComments, +} from '../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + + describe('case_connector', () => { + let createdActionId = ''; + + it('should return 200 when creating a case action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + + expect(createdAction).to.eql({ + id: createdActionId, + isPreconfigured: false, + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/action/${createdActionId}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + isPreconfigured: false, + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }); + }); + + describe('create', () => { + it('should respond with a 400 Bad Request when creating a case without title', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + tags: ['case', 'connector'], + description: 'case description', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subActionParams.title]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when creating a case without description', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subActionParams.description]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when creating a case without tags', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + description: 'case description', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subActionParams.tags]: expected value of type [array] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when creating a case without connector', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + description: 'case description', + tags: ['case', 'connector'], + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subActionParams.connector.id]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when creating jira without issueType', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + description: 'case description', + tags: ['case', 'connector'], + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + priority: 'High', + parent: null, + }, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subActionParams.connector.fields.issueType]: expected value of type [string] but got [undefined]\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when creating a connector with wrong fields', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + description: 'case description', + tags: ['case', 'connector'], + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { + impact: 'Medium', + severity: 'Medium', + notExists: 'not-exists', + }, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subActionParams.connector.fields.notExists]: definition for this key is missing\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when creating a none without fields as null', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + description: 'case description', + tags: ['case', 'connector'], + connector: { + id: 'none', + name: 'None', + type: '.none', + fields: {}, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subActionParams.connector]: Fields must be set to null for connectors of type .none\n- [1.subAction]: expected value to equal [update]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should create a case', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'case description', + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, + }, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${caseConnector.body.data.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + expect(data).to.eql({ + ...postCaseResp(caseConnector.body.data.id), + ...params.subActionParams, + created_by: { + email: null, + full_name: null, + username: null, + }, + }); + }); + + it('should create a case with connector with field as null if not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'create', + subActionParams: { + title: 'Case from case connector!!', + tags: ['case', 'connector'], + description: 'case description', + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: {}, + }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${caseConnector.body.data.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + expect(data).to.eql({ + ...postCaseResp(caseConnector.body.data.id), + ...params.subActionParams, + connector: { + id: 'servicenow', + name: 'Servicenow', + type: '.servicenow', + fields: { + impact: null, + severity: null, + urgency: null, + }, + }, + created_by: { + email: null, + full_name: null, + username: null, + }, + }); + }); + }); + + describe('update', () => { + it('should respond with a 400 Bad Request when updating a case without id', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'update', + subActionParams: { + version: '123', + title: 'Case from case connector!!', + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .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.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when updating a case without version', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'update', + subActionParams: { + id: '123', + title: 'Case from case connector!!', + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .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.subActionParams.version]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should update a case', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const params = { + subAction: 'update', + subActionParams: { + id: caseRes.body.id, + version: caseRes.body.version, + title: 'Case from case connector!!', + }, + }; + + await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${caseRes.body.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + expect(data).to.eql({ + ...postCaseResp(caseRes.body.id), + title: 'Case from case connector!!', + updated_by: { + email: null, + full_name: null, + username: null, + }, + }); + }); + }); + + describe('addComment', () => { + it('should respond with a 400 Bad Request when adding a comment to a case without caseId', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'update', + subActionParams: { + comment: { comment: 'a comment', type: 'user' }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .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.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when adding a comment to a case without comment', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'update', + subActionParams: { + caseId: '123', + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .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.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should respond with a 400 Bad Request when adding a comment to a case without comment type', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + const params = { + subAction: 'update', + subActionParams: { + caseId: '123', + comment: { comment: 'a comment' }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .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.subActionParams.id]: expected value of type [string] but got [undefined]\n- [2.subAction]: expected value to equal [addComment]', + retry: false, + }); + }); + + it('should add a comment', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { comment: 'a comment', type: 'user' }, + }, + }; + + await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + 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, + }, + }); + }); + }); + }); +}; 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 aaf2338cde2f0..2f7af95e264f8 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -31,6 +31,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/get_connectors')); loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); + loadTestFile(require.resolve('./connectors/case')); // Migrations loadTestFile(require.resolve('./cases/migrations')); diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts index 72d1bc4ec9a37..86d69266c6ec6 100644 --- a/x-pack/test/case_api_integration/common/config.ts +++ b/x-pack/test/case_api_integration/common/config.ts @@ -26,6 +26,7 @@ const enabledActionTypes = [ '.servicenow', '.slack', '.webhook', + '.case', 'test.authorization', 'test.failing', 'test.index-record', 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 18c57ad3b0b69..d2262c684dc6d 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -8,6 +8,7 @@ import { CasePostRequest, CaseResponse, CasesFindResponse, + CommentResponse, ConnectorTypes, } from '../../../../plugins/case/common/api'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; @@ -23,12 +24,16 @@ export const postCaseReq: CasePostRequest = { }, }; -export const postCommentReq: { comment: string } = { +export const postCommentReq: { comment: string; type: string } = { comment: 'This is a cool comment', + type: 'user', }; -export const postCaseResp = (id: string): Partial => ({ - ...postCaseReq, +export const postCaseResp = ( + id: string, + req: CasePostRequest = postCaseReq +): Partial => ({ + ...req, id, comments: [], totalComment: 0, @@ -47,6 +52,16 @@ export const removeServerGeneratedPropertiesFromCase = ( return rest; }; +export const removeServerGeneratedPropertiesFromComments = ( + comments: CommentResponse[] +): Array> => { + return comments.map((comment) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { created_at, updated_at, version, ...rest } = comment; + return rest; + }); +}; + export const findCasesResp: CasesFindResponse = { page: 1, per_page: 20, diff --git a/x-pack/test/functional/es_archives/cases/data.json b/x-pack/test/functional/es_archives/cases/data.json index 2ca805259e318..9af1ac47b61a7 100644 --- a/x-pack/test/functional/es_archives/cases/data.json +++ b/x-pack/test/functional/es_archives/cases/data.json @@ -137,3 +137,75 @@ "type": "_doc" } } + +{ + "type": "doc", + "value": { + "id": "cases-comments:da677740-1ac7-11eb-b5a3-25ee88122510", + "index": ".kibana_1", + "source": { + "cases-comments": { + "comment": "This is a cool comment", + "created_at": "2020-10-30T15:52:02.984Z", + "created_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "pushed_at": null, + "pushed_by": null, + "updated_at": null, + "updated_by": null + }, + "references": [ + { + "id": "e1900ac0-017f-11eb-93f8-d161651bf509", + "name": "associated-cases", + "type": "cases" + } + ], + "type": "cases-comments", + "updated_at": "2020-10-30T15:52:02.996Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "cases-user-actions:db027ec0-1ac7-11eb-b5a3-25ee88122510", + "index": ".kibana_1", + "source": { + "cases-user-actions": { + "action": "create", + "action_at": "2020-10-30T15:52:02.984Z", + "action_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "action_field": [ + "comment" + ], + "new_value": "This is a cool comment", + "old_value": null + }, + "references": [ + { + "id": "e1900ac0-017f-11eb-93f8-d161651bf509", + "name": "associated-cases", + "type": "cases" + }, + { + "id": "da677740-1ac7-11eb-b5a3-25ee88122510", + "name": "associated-cases-comments", + "type": "cases-comments" + } + ], + "type": "cases-user-actions", + "updated_at": "2020-10-30T15:52:04.012Z" + }, + "type": "_doc" + } +}