[Security Solution][Case] Case action type (#80870)

* 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é <mikecote@users.noreply.github.com>

Co-authored-by: Mike Côté <mikecote@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2020-11-04 12:07:17 +02:00 committed by GitHub
parent 57f74012b1
commit 7abb1e3033
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 2621 additions and 151 deletions

View file

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

View file

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

View file

@ -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 cases title. | string |
| description | The cases description. | string |
| tags | String array containing words and phrases that help categorize cases. | string[] |
| connector | Object containing the connectors 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 connectors 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 cases 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 connectors 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 |

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Logger>;
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown>),
},
});
});
describe('connector', () => {
it('succeeds when jira fields are valid', () => {
const params: Record<string, unknown> = {
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<string, unknown>),
},
});
});
it('succeeds when resilient fields are valid', () => {
const params: Record<string, unknown> = {
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<string, unknown>),
},
});
});
it('succeeds when servicenow fields are valid', () => {
const params: Record<string, unknown> = {
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<string, unknown>),
},
});
});
it('set fields to null if they are missing', () => {
const params: Record<string, unknown> = {
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<string, unknown> = {
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<string, unknown>),
},
});
});
it('fails when issueType is not provided', () => {
const params: Record<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
subAction: 'update',
};
expect(() => {
validateParams(caseActionType, params);
}).toThrow();
});
});
describe('add comment', () => {
it('succeeds when params is valid', () => {
const params: Record<string, unknown> = {
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<string, unknown> = {
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 },
});
});
});
});
});

View file

@ -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<ActionTypeExecutorResult<CaseExecutorResponse | {}>> {
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 };
}

View file

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

View file

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

View file

@ -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<typeof CaseConfigurationSchema>;
export type Connector = TypeOf<typeof ConnectorSchema>;
export type ExecutorSubActionCreateParams = TypeOf<typeof ExecutorSubActionCreateParamsSchema>;
export type ExecutorSubActionUpdateParams = TypeOf<typeof ExecutorSubActionUpdateParamsSchema>;
export type ExecutorSubActionAddCommentParams = TypeOf<
typeof ExecutorSubActionAddCommentParamsSchema
>;
export type CaseExecutorParams = TypeOf<typeof CaseExecutorParamsSchema>;
export type CaseExecutorResponse = CaseResponse | CasesResponse;
export type CaseActionType = ActionType<
CaseConfiguration,
{},
CaseExecutorParams,
CaseExecutorResponse | {}
>;
export type CaseActionTypeExecutorOptions = ActionTypeExecutorOptions<
CaseConfiguration,
{},
CaseExecutorParams
>;

View file

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

View file

@ -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<Config, Secrets, Params, ExecutorResultData>
): void;
}
export const registerConnectors = ({
actionsRegisterType,
logger,
caseService,
caseConfigureService,
userActionService,
}: RegisterConnectorsArgs) => {
actionsRegisterType(
getCaseConnector({
logger,
caseService,
caseConfigureService,
userActionService,
})
);
};

View file

@ -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<ConfigType>().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) {

View file

@ -10,6 +10,7 @@ import {
CommentAttributes,
ESCaseAttributes,
ConnectorTypes,
CommentType,
} from '../../../../common/api';
export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
@ -207,6 +208,7 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
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<SavedObject<CommentAttributes>> = [
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<SavedObject<CommentAttributes>> = [
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',

View file

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

View file

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

View file

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

View file

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

View file

@ -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<UnsanitizedComment>
): SavedObjectSanitizedDoc<SanitizedComment> => {
return {
...doc,
attributes: {
...doc.attributes,
type: CommentType.user,
},
references: doc.references || [],
};
},
};

View file

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

View file

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

View file

@ -82,11 +82,11 @@ export const getCasesColumns = (
<>
<EuiAvatar
className="userAction__circle"
name={createdBy.fullName ? createdBy.fullName : createdBy.username ?? ''}
name={createdBy.fullName ? createdBy.fullName : createdBy.username ?? i18n.UNKNOWN}
size="s"
/>
<Spacer data-test-subj="case-table-column-createdBy">
{createdBy.fullName ? createdBy.fullName : createdBy.username ?? ''}
{createdBy.fullName ? createdBy.fullName : createdBy.username ?? i18n.UNKNOWN}
</Spacer>
</>
);

View file

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

View file

@ -131,8 +131,8 @@ export const getUpdateAction = ({
}): EuiCommentProps => ({
username: (
<UserActionUsernameWithAvatar
username={action.actionBy.username ?? ''}
fullName={action.actionBy.fullName ?? action.actionBy.username ?? ''}
username={action.actionBy.username}
fullName={action.actionBy.fullName}
/>
),
type: 'update',

View file

@ -217,8 +217,8 @@ export const UserActionTree = React.memo(
() => ({
username: (
<UserActionUsername
username={caseData.createdBy.username ?? i18n.UNKNOWN}
fullName={caseData.createdBy.fullName ?? caseData.createdBy.username ?? ''}
username={caseData.createdBy.username}
fullName={caseData.createdBy.fullName}
/>
),
event: i18n.ADDED_DESCRIPTION,
@ -270,8 +270,8 @@ export const UserActionTree = React.memo(
{
username: (
<UserActionUsername
username={comment.createdBy.username ?? ''}
fullName={comment.createdBy.fullName ?? comment.createdBy.username ?? ''}
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
'data-test-subj': `comment-create-action-${comment.id}`,
@ -418,17 +418,11 @@ export const UserActionTree = React.memo(
const bottomActions = [
{
username: (
<UserActionUsername
username={currentUser != null ? currentUser.username ?? '' : ''}
fullName={currentUser != null ? currentUser.fullName ?? '' : ''}
/>
<UserActionUsername username={currentUser?.username} fullName={currentUser?.fullName} />
),
'data-test-subj': 'add-comment',
timelineIcon: (
<UserActionAvatar
username={currentUser != null ? currentUser.username ?? '' : ''}
fullName={currentUser != null ? currentUser.fullName ?? '' : ''}
/>
<UserActionAvatar username={currentUser?.username} fullName={currentUser?.fullName} />
),
className: 'isEdit',
children: MarkdownNewComment,

View file

@ -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(<UserActionAvatar username={'elastic'} />);
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(<UserActionAvatar />);
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');
});
});

View file

@ -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 ? (
<EuiAvatar name={avatarName} data-test-subj={`user-action-avatar`} />
) : (
<EuiLoadingSpinner data-test-subj={`user-action-avatar-loading-spinner`} />
)}
</>
);
return <EuiAvatar name={avatarName} data-test-subj={`user-action-avatar`} />;
};
export const UserActionAvatar = memo(UserActionAvatarComponent);

View file

@ -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 (
<EuiToolTip
position="top"
content={<p>{isEmpty(fullName) ? username : fullName}</p>}
content={<p>{tooltipContent}</p>}
data-test-subj="user-action-username-tooltip"
>
<strong>{username}</strong>
<strong>{username ?? i18n.UNKNOWN.toLowerCase()}</strong>
</EuiToolTip>
);
};

View file

@ -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 = ({
<EuiFlexItem grow={false}>
<EuiAvatar
size="s"
name={isEmpty(fullName) ? username : fullName ?? ''}
name={(isEmpty(fullName) ? username : fullName) ?? i18n.UNKNOWN}
data-test-subj="user-action-username-avatar"
/>
</EuiFlexItem>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
private kibanaVersion: string;
@ -312,6 +313,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
},
});
plugins.triggersActionsUi.actionTypeRegistry.register(getCaseConnectorUI());
return {
resolver: async () => {
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"]`)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@ const enabledActionTypes = [
'.servicenow',
'.slack',
'.webhook',
'.case',
'test.authorization',
'test.failing',
'test.index-record',

View file

@ -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<CaseResponse> => ({
...postCaseReq,
export const postCaseResp = (
id: string,
req: CasePostRequest = postCaseReq
): Partial<CaseResponse> => ({
...req,
id,
comments: [],
totalComment: 0,
@ -47,6 +52,16 @@ export const removeServerGeneratedPropertiesFromCase = (
return rest;
};
export const removeServerGeneratedPropertiesFromComments = (
comments: CommentResponse[]
): Array<Partial<CommentResponse>> => {
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,

View file

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