diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index c5f3f5093aa33..28beb03812aa5 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -135,6 +135,27 @@ export const CasePostRequestRt = rt.intersection([ ), ]); +/** + * Bulk create cases + */ + +const CaseCreateRequestWithOptionalId = rt.intersection([ + CasePostRequestRt, + rt.exact(rt.partial({ id: rt.string })), +]); + +export const BulkCreateCasesRequestRt = rt.strict({ + cases: rt.array(CaseCreateRequestWithOptionalId), +}); + +export const BulkCreateCasesResponseRt = rt.strict({ + cases: rt.array(CaseRt), +}); + +/** + * Find cases + */ + export const CasesFindRequestSearchFieldsRt = rt.keyof({ description: null, title: null, @@ -480,3 +501,5 @@ export type CasesBulkGetRequest = rt.TypeOf; export type CasesBulkGetResponse = rt.TypeOf; export type GetRelatedCasesByAlertResponse = rt.TypeOf; export type CaseRequestCustomFields = rt.TypeOf; +export type BulkCreateCasesRequest = rt.TypeOf; +export type BulkCreateCasesResponse = rt.TypeOf; diff --git a/x-pack/plugins/cases/server/client/attachments/delete.ts b/x-pack/plugins/cases/server/client/attachments/delete.ts index b21c6b5ae753f..e67a5788b3476 100644 --- a/x-pack/plugins/cases/server/client/attachments/delete.ts +++ b/x-pack/plugins/cases/server/client/attachments/delete.ts @@ -124,13 +124,15 @@ export async function deleteComment( const attachmentRequestAttributes = decodeOrThrow(AttachmentRequestRt)(attachment.attributes); await userActionService.creator.createUserAction({ - type: UserActionTypes.comment, - action: UserActionActions.delete, - caseId: id, - attachmentId: attachmentID, - payload: { attachment: attachmentRequestAttributes }, - user, - owner: attachment.attributes.owner, + userAction: { + type: UserActionTypes.comment, + action: UserActionActions.delete, + caseId: id, + attachmentId: attachmentID, + payload: { attachment: attachmentRequestAttributes }, + user, + owner: attachment.attributes.owner, + }, }); await handleAlerts({ alertsService, attachments: [attachment.attributes], caseId: id }); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts new file mode 100644 index 0000000000000..fa0b4e3f584e5 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts @@ -0,0 +1,1231 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + MAX_DESCRIPTION_LENGTH, + MAX_TAGS_PER_CASE, + MAX_LENGTH_PER_TAG, + MAX_TITLE_LENGTH, + MAX_ASSIGNEES_PER_CASE, + MAX_CUSTOM_FIELDS_PER_CASE, +} from '../../../common/constants'; +import type { CasePostRequest } from '../../../common'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; +import { mockCases } from '../../mocks'; +import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; +import { bulkCreate } from './bulk_create'; +import { CaseSeverity, ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; + +import type { CaseCustomFields } from '../../../common/types/domain'; +import { omit } from 'lodash'; + +jest.mock('@kbn/core-saved-objects-utils-server', () => { + const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); + + return { + ...actual, + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, + }; +}); + +describe('bulkCreate', () => { + const getCases = (overrides = {}) => [ + { + title: 'My Case', + tags: [], + description: 'testing sir', + connector: { + id: '.none', + name: 'None', + type: ConnectorTypes.none, + fields: null, + }, + settings: { syncAlerts: true }, + severity: CaseSeverity.LOW, + owner: SECURITY_SOLUTION_OWNER, + assignees: [{ uid: '1' }], + ...overrides, + }, + ]; + + const caseSO = mockCases[0]; + const casesClientMock = createCasesClientMock(); + casesClientMock.configure.get = jest.fn().mockResolvedValue([]); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('execution', () => { + const createdAtDate = new Date('2023-11-05'); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(createdAtDate); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const clientArgs = createCasesClientMockArgs(); + + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [caseSO], + }); + + it('create the cases correctly', async () => { + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [ + caseSO, + { ...caseSO, attributes: { ...caseSO.attributes, severity: CaseSeverity.CRITICAL } }, + ], + }); + + const res = await bulkCreate( + { cases: [getCases()[0], getCases({ severity: CaseSeverity.CRITICAL })[0]] }, + clientArgs, + casesClientMock + ); + + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "critical", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + ], + } + `); + }); + + it('accepts an ID in the request correctly', async () => { + await bulkCreate({ cases: getCases({ id: 'my-id' }) }, clientArgs, casesClientMock); + + expect(clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].id).toBe( + 'my-id' + ); + }); + + it('generates an ID if not provided in the request', async () => { + await bulkCreate({ cases: getCases() }, clientArgs, casesClientMock); + + expect(clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].id).toBe( + 'mock-saved-object-id' + ); + }); + + it('calls bulkCreateCases correctly', async () => { + await bulkCreate( + { cases: [getCases()[0], getCases({ severity: CaseSeverity.CRITICAL })[0]] }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0]) + .toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "assignees": Array [ + Object { + "uid": "1", + }, + ], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": null, + "id": ".none", + "name": "None", + "type": ".none", + }, + "created_at": "2023-11-05T00:00:00.000Z", + "created_by": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "username": "damaged_raccoon", + }, + "customFields": Array [], + "description": "testing sir", + "duration": null, + "external_service": null, + "id": "mock-saved-object-id", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [], + "title": "My Case", + "updated_at": null, + "updated_by": null, + }, + Object { + "assignees": Array [ + Object { + "uid": "1", + }, + ], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": null, + "id": ".none", + "name": "None", + "type": ".none", + }, + "created_at": "2023-11-05T00:00:00.000Z", + "created_by": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "username": "damaged_raccoon", + }, + "customFields": Array [], + "description": "testing sir", + "duration": null, + "external_service": null, + "id": "mock-saved-object-id", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "critical", + "status": "open", + "tags": Array [], + "title": "My Case", + "updated_at": null, + "updated_by": null, + }, + ], + "refresh": false, + } + `); + }); + + it('throws an error if bulkCreateCases returns at least one error ', async () => { + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [ + caseSO, + { + id: '2', + type: 'cases', + error: { + error: 'My error', + message: 'not found', + statusCode: 404, + }, + references: [], + }, + { + id: '3', + type: 'cases', + error: { + error: 'My second error', + message: 'conflict', + statusCode: 409, + }, + references: [], + }, + ], + }); + + await expect(bulkCreate({ cases: getCases() }, clientArgs, casesClientMock)).rejects.toThrow( + `Failed to bulk create cases: Error: My error` + ); + }); + + it('constructs the case error correctly', async () => { + expect.assertions(1); + + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [ + caseSO, + { + id: '1', + type: 'cases', + error: { + error: 'My error', + message: 'not found', + statusCode: 404, + }, + references: [], + }, + ], + }); + + try { + await bulkCreate({ cases: getCases() }, clientArgs, casesClientMock); + } catch (error) { + expect(error.wrappedError.output).toEqual({ + headers: {}, + payload: { error: 'Not Found', message: 'My error', statusCode: 404 }, + statusCode: 404, + }); + } + }); + }); + + describe('authorization', () => { + const clientArgs = createCasesClientMockArgs(); + + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [caseSO], + }); + + it('validates the cases correctly', async () => { + await bulkCreate( + { cases: [getCases()[0], getCases({ owner: 'cases' })[0]] }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.authorization.ensureAuthorized).toHaveBeenCalledWith({ + entities: [ + { id: 'mock-saved-object-id', owner: 'securitySolution' }, + { id: 'mock-saved-object-id', owner: 'cases' }, + ], + operation: { + action: 'case_create', + docType: 'case', + ecsType: 'creation', + name: 'createCase', + savedObjectType: 'cases', + verbs: { past: 'created', present: 'create', progressive: 'creating' }, + }, + }); + }); + }); + + describe('Assignees', () => { + const clientArgs = createCasesClientMockArgs(); + + it('notifies single assignees', async () => { + const caseSOWithAssignees = { + ...caseSO, + attributes: { ...caseSO.attributes, assignees: [{ uid: '1' }] }, + }; + + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [caseSOWithAssignees], + }); + + const cases = getCases(); + + await bulkCreate({ cases }, clientArgs, casesClientMock); + + expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ + { + assignees: cases[0].assignees, + theCase: caseSOWithAssignees, + }, + ]); + }); + + it('notifies multiple assignees', async () => { + const caseSOWithAssignees = { + ...caseSO, + attributes: { ...caseSO.attributes, assignees: [{ uid: '1' }, { uid: '2' }] }, + }; + + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [caseSOWithAssignees], + }); + + await bulkCreate( + { cases: getCases({ assignees: [{ uid: '1' }, { uid: '2' }] }) }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ + { + assignees: [{ uid: '1' }, { uid: '2' }], + theCase: caseSOWithAssignees, + }, + ]); + }); + + it('does not notify when there are no assignees', async () => { + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [caseSO], + }); + + await bulkCreate({ cases: getCases({ assignees: [] }) }, clientArgs, casesClientMock); + + expect(clientArgs.services.notificationService.bulkNotifyAssignees).not.toHaveBeenCalled(); + }); + + it('does not notify the current user', async () => { + const caseSOWithAssignees = { + ...caseSO, + attributes: { + ...caseSO.attributes, + assignees: [{ uid: '1' }, { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + }, + }; + + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ + saved_objects: [caseSOWithAssignees], + }); + + await bulkCreate( + { + cases: getCases({ + assignees: [{ uid: '1' }, { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + }), + }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ + { + assignees: [{ uid: '1' }], + theCase: caseSOWithAssignees, + }, + ]); + }); + + it('should throw an error if the assignees array length is too long', async () => { + const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foo' }); + + await expect( + bulkCreate({ cases: getCases({ assignees }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + `Failed to bulk create cases: Error: The length of the field assignees is too long. Array must be of length <= ${MAX_ASSIGNEES_PER_CASE}.` + ); + }); + + it('should throw if the user does not have the correct license', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect(bulkCreate({ cases: getCases() }, clientArgs, casesClientMock)).rejects.toThrow( + `Failed to bulk create cases: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license` + ); + }); + }); + + describe('Attributes', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ saved_objects: [caseSO] }); + + it('should throw an error when an excess field exists', async () => { + await expect( + bulkCreate({ cases: getCases({ foo: 'bar' }) }, clientArgs, casesClientMock) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: invalid keys \\"foo\\""` + ); + }); + }); + + describe('title', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ saved_objects: [caseSO] }); + + it(`should not throw an error if the title is non empty and less than ${MAX_TITLE_LENGTH} characters`, async () => { + await expect( + bulkCreate( + { cases: getCases({ title: 'This is a test case!!' }) }, + clientArgs, + casesClientMock + ) + ).resolves.not.toThrow(); + }); + + it('should throw an error if the title length is too long', async () => { + await expect( + bulkCreate( + { + cases: getCases({ + title: + 'This is a very long title with more than one hundred and sixty characters!! To confirm the maximum limit error thrown for more than one hundred and sixty characters!!', + }), + }, + clientArgs, + casesClientMock + ) + ).rejects.toThrow( + `Failed to bulk create cases: Error: The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.` + ); + }); + + it('should throw an error if the title is an empty string', async () => { + await expect( + bulkCreate({ cases: getCases({ title: '' }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The title field cannot be an empty string.' + ); + }); + + it('should throw an error if the title is a string with empty characters', async () => { + await expect( + bulkCreate({ cases: getCases({ title: ' ' }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The title field cannot be an empty string.' + ); + }); + + it('should trim title', async () => { + await bulkCreate( + { cases: getCases({ title: 'title with spaces ' }) }, + clientArgs, + casesClientMock + ); + + const title = clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].title; + + expect(title).toBe('title with spaces'); + }); + }); + + describe('description', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ saved_objects: [caseSO] }); + + it(`should not throw an error if the description is non empty and less than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { + await expect( + bulkCreate( + { cases: getCases({ description: 'This is a test description!!' }) }, + clientArgs, + casesClientMock + ) + ).resolves.not.toThrow(); + }); + + it('should throw an error if the description length is too long', async () => { + const description = Array(MAX_DESCRIPTION_LENGTH + 1) + .fill('x') + .toString(); + + await expect( + bulkCreate({ cases: getCases({ description }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + `Failed to bulk create cases: Error: The length of the description is too long. The maximum length is ${MAX_DESCRIPTION_LENGTH}.` + ); + }); + + it('should throw an error if the description is an empty string', async () => { + await expect( + bulkCreate({ cases: getCases({ description: '' }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The description field cannot be an empty string.' + ); + }); + + it('should throw an error if the description is a string with empty characters', async () => { + await expect( + bulkCreate({ cases: getCases({ description: ' ' }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The description field cannot be an empty string.' + ); + }); + + it('should trim description', async () => { + await bulkCreate( + { cases: getCases({ description: 'this is a description with spaces!! ' }) }, + clientArgs, + casesClientMock + ); + + const description = + clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].description; + + expect(description).toBe('this is a description with spaces!!'); + }); + }); + + describe('tags', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ saved_objects: [caseSO] }); + + it('should not throw an error if the tags array is empty', async () => { + await expect( + bulkCreate({ cases: getCases({ tags: [] }) }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); + }); + + it('should not throw an error if the tags array has non empty string within limit', async () => { + await expect( + bulkCreate({ cases: getCases({ tags: ['abc'] }) }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); + }); + + it('should throw an error if the tags array length is too long', async () => { + const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foo'); + + await expect( + bulkCreate({ cases: getCases({ tags }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + `Failed to bulk create cases: Error: The length of the field tags is too long. Array must be of length <= ${MAX_TAGS_PER_CASE}.` + ); + }); + + it('should throw an error if the tags array has empty string', async () => { + await expect( + bulkCreate({ cases: getCases({ tags: [''] }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The tag field cannot be an empty string.' + ); + }); + + it('should throw an error if the tags array has string with empty characters', async () => { + await expect( + bulkCreate({ cases: getCases({ tags: [' '] }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The tag field cannot be an empty string.' + ); + }); + + it('should throw an error if the tag length is too long', async () => { + const tag = Array(MAX_LENGTH_PER_TAG + 1) + .fill('f') + .toString(); + + await expect( + bulkCreate({ cases: getCases({ tags: [tag] }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + `Failed to bulk create cases: Error: The length of the tag is too long. The maximum length is ${MAX_LENGTH_PER_TAG}.` + ); + }); + + it('should trim tags', async () => { + await bulkCreate( + { cases: getCases({ tags: ['pepsi ', 'coke'] }) }, + clientArgs, + casesClientMock + ); + + const tags = clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].tags; + + expect(tags).toEqual(['pepsi', 'coke']); + }); + }); + + describe('Category', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ saved_objects: [caseSO] }); + + it('should not throw an error if the category is null', async () => { + await expect( + bulkCreate({ cases: getCases({ category: null }) }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); + }); + + it('should throw an error if the category length is too long', async () => { + await expect( + bulkCreate( + { + cases: getCases({ category: 'A very long category with more than fifty characters!' }), + }, + clientArgs, + casesClientMock + ) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The length of the category is too long.' + ); + }); + + it('should throw an error if the category is an empty string', async () => { + await expect( + bulkCreate({ cases: getCases({ category: '' }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The category field cannot be an empty string.,Invalid value "" supplied to "cases,category"' + ); + }); + + it('should throw an error if the category is a string with empty characters', async () => { + await expect( + bulkCreate({ cases: getCases({ category: ' ' }) }, clientArgs, casesClientMock) + ).rejects.toThrow( + 'Failed to bulk create cases: Error: The category field cannot be an empty string.,Invalid value " " supplied to "cases,category"' + ); + }); + + it('should trim category', async () => { + await bulkCreate( + { cases: getCases({ category: 'reporting ' }) }, + clientArgs, + casesClientMock + ); + + const category = + clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].category; + + expect(category).toEqual('reporting'); + }); + }); + + describe('Custom Fields', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ saved_objects: [caseSO] }); + const theCase = getCases()[0]; + + const casesClient = createCasesClientMock(); + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + const theCustomFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]; + + it('should bulkCreate customFields correctly', async () => { + await expect( + bulkCreate({ cases: getCases({ customFields: theCustomFields }) }, clientArgs, casesClient) + ).resolves.not.toThrow(); + + const customFields = + clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].customFields; + + expect(customFields).toEqual(theCustomFields); + }); + + it('should not throw an error and fill out missing customFields when they are undefined', async () => { + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + await expect( + bulkCreate({ cases: getCases() }, clientArgs, casesClient) + ).resolves.not.toThrow(); + + const customFields = + clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases[0].customFields; + + expect(customFields).toEqual([ + { key: 'first_key', type: 'text', value: null }, + { key: 'second_key', type: 'toggle', value: null }, + ]); + }); + + it('should throw an error when required customFields are undefined', async () => { + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'missing field 1', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + await expect( + bulkCreate({ cases: getCases() }, clientArgs, casesClient) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: Missing required custom fields: \\"missing field 1\\""` + ); + }); + + it('should throw an error when required customFields are null', async () => { + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'missing field 1', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'missing field 2', + required: true, + }, + ], + }, + ]); + + await expect( + bulkCreate( + { + cases: getCases({ + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: Missing required custom fields: \\"missing field 1\\", \\"missing field 2\\""` + ); + }); + + it('throws error when the customFields array is too long', async () => { + await expect( + bulkCreate( + { + cases: getCases({ + customFields: Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill(theCustomFields[0]), + }), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: The length of the field customFields is too long. Array must be of length <= 10."` + ); + }); + + it('throws with duplicated customFields keys', async () => { + await expect( + bulkCreate( + { + cases: getCases({ + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: Invalid duplicated custom field keys in request: duplicated_key"` + ); + }); + + it('throws error when customFields keys are not present in configuration', async () => { + await expect( + bulkCreate( + { + cases: getCases({ + customFields: [ + { + key: 'missing_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: Invalid custom field keys: missing_key"` + ); + }); + + it('throws error when required custom fields are missing', async () => { + await expect( + bulkCreate( + { + cases: getCases({ + customFields: [ + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + }), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: Missing required custom fields: \\"missing field 1\\""` + ); + }); + + it('throws when the customField types do not match the configuration', async () => { + await expect( + bulkCreate( + { + cases: getCases({ + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: 'foobar', + }, + ], + }), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: The following custom fields have the wrong type in the request: first_key,second_key"` + ); + }); + + it('should get all configurations', async () => { + await expect( + bulkCreate({ cases: getCases({ customFields: theCustomFields }) }, clientArgs, casesClient) + ).resolves.not.toThrow(); + + expect(casesClient.configure.get).toHaveBeenCalledWith(); + }); + + it('validate required custom fields from different owners', async () => { + const casesWithDifferentOwners = [getCases()[0], getCases({ owner: 'cases' })[0]]; + + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'sec_first_key', + type: CustomFieldTypes.TEXT, + label: 'sec custom field', + required: false, + }, + ], + }, + { + owner: 'cases', + customFields: [ + { + key: 'cases_first_key', + type: CustomFieldTypes.TEXT, + label: 'stack cases custom field', + required: true, + }, + ], + }, + ]); + + await expect( + bulkCreate({ cases: casesWithDifferentOwners }, clientArgs, casesClient) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to bulk create cases: Error: Missing required custom fields: \\"stack cases custom field\\""` + ); + }); + + it('should fill out missing custom fields from different owners correctly', async () => { + const casesWithDifferentOwners = [getCases()[0], getCases({ owner: 'cases' })[0]]; + + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'sec_first_key', + type: CustomFieldTypes.TEXT, + label: 'sec custom field', + required: false, + }, + ], + }, + { + owner: 'cases', + customFields: [ + { + key: 'cases_first_key', + type: CustomFieldTypes.TEXT, + label: 'stack cases custom field', + required: false, + }, + ], + }, + ]); + + await bulkCreate({ cases: casesWithDifferentOwners }, clientArgs, casesClient); + + const cases = clientArgs.services.caseService.bulkCreateCases.mock.calls[0][0].cases; + + expect(cases[0].owner).toBe('securitySolution'); + expect(cases[1].owner).toBe('cases'); + + expect(cases[0].customFields).toEqual([{ key: 'sec_first_key', type: 'text', value: null }]); + expect(cases[1].customFields).toEqual([ + { key: 'cases_first_key', type: 'text', value: null }, + ]); + }); + }); + + describe('User actions', () => { + const theCase = getCases()[0]; + + const caseWithOnlyRequiredFields = omit(theCase, [ + 'assignees', + 'category', + 'severity', + 'customFields', + ]) as CasePostRequest; + + const caseWithOptionalFields: CasePostRequest = { + ...theCase, + category: 'My category', + severity: CaseSeverity.CRITICAL, + customFields: [ + { + key: 'first_customField_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'second_customField_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }; + + const casesClient = createCasesClientMock(); + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.bulkCreateCases.mockResolvedValue({ saved_objects: [caseSO] }); + + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: caseWithOptionalFields.owner, + customFields: [ + { + key: 'first_customField_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_customField_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + it('should bulkCreate a user action with defaults correctly', async () => { + await bulkCreate({ cases: [caseWithOnlyRequiredFields] }, clientArgs, casesClient); + + expect( + clientArgs.services.userActionService.creator.bulkCreateUserAction + ).toHaveBeenCalledWith({ + userActions: [ + { + caseId: 'mock-id-1', + owner: 'securitySolution', + payload: { + assignees: [], + category: null, + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + description: 'This is a brand new case of a bad meanie defacing data', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'low', + tags: ['defacement'], + title: 'Super Bad Security Issue', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, + }, + ], + }); + }); + + it('should bulkCreate a user action with optional fields set correctly', async () => { + await bulkCreate({ cases: [caseWithOptionalFields] }, clientArgs, casesClient); + + expect( + clientArgs.services.userActionService.creator.bulkCreateUserAction + ).toHaveBeenCalledWith({ + userActions: [ + { + caseId: 'mock-id-1', + owner: 'securitySolution', + payload: { + assignees: [], + category: null, + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + description: 'This is a brand new case of a bad meanie defacing data', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'low', + tags: ['defacement'], + title: 'Super Bad Security Issue', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_create.ts b/x-pack/plugins/cases/server/client/cases/bulk_create.ts new file mode 100644 index 0000000000000..fea7986a9169d --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/bulk_create.ts @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { partition } from 'lodash'; + +import type { SavedObject } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; + +import type { Case, CustomFieldsConfiguration, User } from '../../../common/types/domain'; +import { CaseSeverity, UserActionTypes } from '../../../common/types/domain'; +import { decodeWithExcessOrThrow } from '../../../common/api'; + +import { Operations } from '../../authorization'; +import { createCaseError } from '../../common/error'; +import { flattenCaseSavedObject, isSOError, transformNewCase } from '../../common/utils'; +import type { CasesClient, CasesClientArgs } from '..'; +import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; +import { decodeOrThrow } from '../../../common/api/runtime_types'; +import type { + BulkCreateCasesRequest, + BulkCreateCasesResponse, + CasePostRequest, +} from '../../../common/types/api'; +import { BulkCreateCasesResponseRt, BulkCreateCasesRequestRt } from '../../../common/types/api'; +import { validateCustomFields } from './validators'; +import { normalizeCreateCaseRequest } from './utils'; +import type { BulkCreateCasesArgs } from '../../services/cases/types'; +import type { NotifyAssigneesArgs } from '../../services/notifications/types'; +import type { CaseTransformedAttributes } from '../../common/types/case'; + +export const bulkCreate = async ( + data: BulkCreateCasesRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +): Promise => { + const { + services: { caseService, userActionService, licensingService, notificationService }, + user, + logger, + authorization: auth, + } = clientArgs; + + try { + const decodedData = decodeWithExcessOrThrow(BulkCreateCasesRequestRt)(data); + const configurations = await casesClient.configure.get(); + + const customFieldsConfigurationMap: Map = new Map( + configurations.map((conf) => [conf.owner, conf.customFields]) + ); + + const casesWithIds = getCaseWithIds(decodedData); + + await auth.ensureAuthorized({ + operation: Operations.createCase, + entities: casesWithIds.map((theCase) => ({ owner: theCase.owner, id: theCase.id })), + }); + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + const bulkCreateRequest: BulkCreateCasesArgs['cases'] = []; + + for (const theCase of casesWithIds) { + const customFieldsConfiguration = customFieldsConfigurationMap.get(theCase.owner); + + validateRequest({ theCase, customFieldsConfiguration, hasPlatinumLicenseOrGreater }); + + bulkCreateRequest.push( + createBulkCreateCaseRequest({ theCase, user, customFieldsConfiguration }) + ); + } + + const bulkCreateResponse = await caseService.bulkCreateCases({ + cases: bulkCreateRequest, + refresh: false, + }); + + const userActions = []; + const assigneesPerCase: NotifyAssigneesArgs[] = []; + const res: Case[] = []; + + const [errors, casesSOs] = partition(bulkCreateResponse.saved_objects, isSOError); + + if (errors.length > 0) { + const firstError = errors[0]; + throw new Boom.Boom(firstError.error.error, { + statusCode: firstError.error.statusCode, + message: firstError.error.message, + }); + } + + for (const theCase of casesSOs) { + userActions.push(createBulkCreateUserActionsRequest({ theCase, user })); + + if (theCase.attributes.assignees && theCase.attributes.assignees.length !== 0) { + const assigneesWithoutCurrentUser = theCase.attributes.assignees.filter( + (assignee) => assignee.uid !== user.profile_uid + ); + + assigneesPerCase.push({ assignees: assigneesWithoutCurrentUser, theCase }); + } + + res.push( + flattenCaseSavedObject({ + savedObject: theCase, + }) + ); + } + + await userActionService.creator.bulkCreateUserAction({ userActions }); + + if (assigneesPerCase.length > 0) { + licensingService.notifyUsage(LICENSING_CASE_ASSIGNMENT_FEATURE); + await notificationService.bulkNotifyAssignees(assigneesPerCase); + } + + return decodeOrThrow(BulkCreateCasesResponseRt)({ cases: res }); + } catch (error) { + throw createCaseError({ message: `Failed to bulk create cases: ${error}`, error, logger }); + } +}; + +const getCaseWithIds = ( + req: BulkCreateCasesRequest +): Array<{ id: string } & BulkCreateCasesRequest['cases'][number]> => + req.cases.map((theCase) => ({ + ...theCase, + id: theCase.id ?? SavedObjectsUtils.generateId(), + })); + +const validateRequest = ({ + theCase, + customFieldsConfiguration, + hasPlatinumLicenseOrGreater, +}: { + theCase: BulkCreateCasesRequest['cases'][number]; + customFieldsConfiguration?: CustomFieldsConfiguration; + hasPlatinumLicenseOrGreater: boolean; +}) => { + const customFieldsValidationParams = { + requestCustomFields: theCase.customFields, + customFieldsConfiguration, + }; + + validateCustomFields(customFieldsValidationParams); + validateAssigneesUsage({ assignees: theCase.assignees, hasPlatinumLicenseOrGreater }); +}; + +const validateAssigneesUsage = ({ + assignees, + hasPlatinumLicenseOrGreater, +}: { + assignees?: BulkCreateCasesRequest['cases'][number]['assignees']; + hasPlatinumLicenseOrGreater: boolean; +}) => { + /** + * Assign users to a case is only available to Platinum+ + */ + + if (assignees && assignees.length !== 0) { + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to assign users to cases, you must be subscribed to an Elastic Platinum license' + ); + } + } +}; + +const createBulkCreateCaseRequest = ({ + theCase, + customFieldsConfiguration, + user, +}: { + theCase: { id: string } & BulkCreateCasesRequest['cases'][number]; + customFieldsConfiguration?: CustomFieldsConfiguration; + user: User; +}): BulkCreateCasesArgs['cases'][number] => { + const { id, ...caseWithoutId } = theCase; + + /** + * Trim title, category, description and tags + * and fill out missing custom fields + * before saving to ES + */ + + const normalizedCase = normalizeCreateCaseRequest(caseWithoutId, customFieldsConfiguration); + + return { + id, + ...transformNewCase({ + user, + newCase: normalizedCase, + }), + }; +}; + +const createBulkCreateUserActionsRequest = ({ + theCase, + user, +}: { + theCase: SavedObject; + user: User; +}) => { + const userActionPayload: CasePostRequest = { + title: theCase.attributes.title, + tags: theCase.attributes.tags, + connector: theCase.attributes.connector, + settings: theCase.attributes.settings, + owner: theCase.attributes.owner, + description: theCase.attributes.description, + severity: theCase.attributes.severity ?? CaseSeverity.LOW, + assignees: theCase.attributes.assignees ?? [], + category: theCase.attributes.category ?? null, + customFields: theCase.attributes.customFields ?? [], + }; + + return { + type: UserActionTypes.create_case, + caseId: theCase.id, + user, + payload: userActionPayload, + owner: theCase.attributes.owner, + }; +}; diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index bbe91b1ad791e..4a3ed9e6c4b05 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -18,6 +18,8 @@ import type { AllReportersFindRequest, GetRelatedCasesByAlertResponse, CasesBulkGetResponse, + BulkCreateCasesRequest, + BulkCreateCasesResponse, } from '../../../common/types/api'; import type { CasesClient } from '../client'; import type { CasesClientInternal } from '../client_internal'; @@ -31,6 +33,7 @@ import { get, resolve, getCasesByAlertID, getReporters, getTags, getCategories } import type { PushParams } from './push'; import { push } from './push'; import { update } from './update'; +import { bulkCreate } from './bulk_create'; /** * API for interacting with the cases entities. @@ -40,6 +43,10 @@ export interface CasesSubClient { * Creates a case. */ create(data: CasePostRequest): Promise; + /** + * Bulk create cases. + */ + bulkCreate(data: BulkCreateCasesRequest): Promise; /** * Returns cases that match the search criteria. * @@ -103,6 +110,7 @@ export const createCasesSubClient = ( ): CasesSubClient => { const casesSubClient: CasesSubClient = { create: (data: CasePostRequest) => create(data, clientArgs, casesClient), + bulkCreate: (data: BulkCreateCasesRequest) => bulkCreate(data, clientArgs, casesClient), find: (params: CasesFindRequest) => find(params, clientArgs), get: (params: GetParams) => get(params, clientArgs), resolve: (params: GetParams) => resolve(params, clientArgs), diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index b7cc876c47655..b65061d403fec 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -51,7 +51,7 @@ describe('create', () => { describe('Assignees', () => { const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); beforeEach(() => { jest.clearAllMocks(); @@ -108,11 +108,19 @@ describe('create', () => { `Failed to create case: Error: The length of the field assignees is too long. Array must be of length <= ${MAX_ASSIGNEES_PER_CASE}.` ); }); + + it('should throw if the user does not have the correct license', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect(create(theCase, clientArgs, casesClientMock)).rejects.toThrow( + `Failed to create case: Error: In order to assign users to cases, you must be subscribed to an Elastic Platinum license` + ); + }); }); describe('Attributes', () => { const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); beforeEach(() => { jest.clearAllMocks(); @@ -130,7 +138,7 @@ describe('create', () => { describe('title', () => { const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); beforeEach(() => { jest.clearAllMocks(); @@ -173,7 +181,7 @@ describe('create', () => { it('should trim title', async () => { await create({ ...theCase, title: 'title with spaces ' }, clientArgs, casesClientMock); - expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith( expect.objectContaining({ attributes: { ...theCase, @@ -199,7 +207,7 @@ describe('create', () => { describe('description', () => { const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); beforeEach(() => { jest.clearAllMocks(); @@ -250,7 +258,7 @@ describe('create', () => { casesClientMock ); - expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith( expect.objectContaining({ attributes: { ...theCase, @@ -276,7 +284,7 @@ describe('create', () => { describe('tags', () => { const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); beforeEach(() => { jest.clearAllMocks(); @@ -329,7 +337,7 @@ describe('create', () => { it('should trim tags', async () => { await create({ ...theCase, tags: ['pepsi ', 'coke'] }, clientArgs, casesClientMock); - expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith( expect.objectContaining({ attributes: { ...theCase, @@ -355,7 +363,7 @@ describe('create', () => { describe('Category', () => { const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); beforeEach(() => { jest.clearAllMocks(); @@ -396,7 +404,7 @@ describe('create', () => { it('should trim category', async () => { await create({ ...theCase, category: 'reporting ' }, clientArgs, casesClientMock); - expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith( expect.objectContaining({ attributes: { ...theCase, @@ -421,7 +429,7 @@ describe('create', () => { describe('Custom Fields', () => { const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); const casesClient = createCasesClientMock(); casesClient.configure.get = jest.fn().mockResolvedValue([ @@ -473,7 +481,7 @@ describe('create', () => { ) ).resolves.not.toThrow(); - expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith( expect.objectContaining({ attributes: { ...theCase, @@ -517,7 +525,7 @@ describe('create', () => { ]); await expect(create({ ...theCase }, clientArgs, casesClient)).resolves.not.toThrow(); - expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith( expect.objectContaining({ attributes: { ...theCase, @@ -758,7 +766,7 @@ describe('create', () => { const casesClient = createCasesClientMock(); const clientArgs = createCasesClientMockArgs(); - clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + clientArgs.services.caseService.createCase.mockResolvedValue(caseSO); casesClient.configure.get = jest.fn().mockResolvedValue([ { @@ -784,26 +792,28 @@ describe('create', () => { await create(caseWithOnlyRequiredFields, clientArgs, casesClient); expect(clientArgs.services.userActionService.creator.createUserAction).toHaveBeenCalledWith({ - caseId: 'mock-id-1', - owner: 'securitySolution', - payload: { - assignees: [], - category: null, - connector: { fields: null, id: '.none', name: 'None', type: '.none' }, - customFields: [], - description: 'testing sir', + userAction: { + caseId: 'mock-id-1', owner: 'securitySolution', - settings: { syncAlerts: true }, - severity: 'low', - tags: [], - title: 'My Case', - }, - type: 'create_case', - user: { - email: 'damaged_raccoon@elastic.co', - full_name: 'Damaged Raccoon', - profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', - username: 'damaged_raccoon', + payload: { + assignees: [], + category: null, + connector: { fields: null, id: '.none', name: 'None', type: '.none' }, + customFields: [], + description: 'testing sir', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'low', + tags: [], + title: 'My Case', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, }, }); }); @@ -812,26 +822,28 @@ describe('create', () => { await create(caseWithOptionalFields, clientArgs, casesClient); expect(clientArgs.services.userActionService.creator.createUserAction).toHaveBeenCalledWith({ - caseId: 'mock-id-1', - owner: 'securitySolution', - payload: { - assignees: [{ uid: '1' }], - category: 'My category', - connector: { fields: null, id: '.none', name: 'None', type: '.none' }, - customFields: caseWithOptionalFields.customFields, - description: 'testing sir', + userAction: { + caseId: 'mock-id-1', owner: 'securitySolution', - settings: { syncAlerts: true }, - severity: 'critical', - tags: [], - title: 'My Case', - }, - type: 'create_case', - user: { - email: 'damaged_raccoon@elastic.co', - full_name: 'Damaged Raccoon', - profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', - username: 'damaged_raccoon', + payload: { + assignees: [{ uid: '1' }], + category: 'My category', + connector: { fields: null, id: '.none', name: 'None', type: '.none' }, + customFields: caseWithOptionalFields.customFields, + description: 'testing sir', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'critical', + tags: [], + title: 'My Case', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, }, }); }); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index d48eaa080dee8..4e548d07a2ef6 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -23,7 +23,7 @@ import type { CasePostRequest } from '../../../common/types/api'; import { CasePostRequestRt } from '../../../common/types/api'; import {} from '../utils'; import { validateCustomFields } from './validators'; -import { fillMissingCustomFields } from './utils'; +import { normalizeCreateCaseRequest } from './utils'; /** * Creates a new case. @@ -82,39 +82,31 @@ export const create = async ( * before saving to ES */ - const normalizedQuery = { - ...query, - title: query.title.trim(), - description: query.description.trim(), - category: query.category?.trim() ?? null, - tags: query.tags?.map((tag) => tag.trim()) ?? [], - customFields: fillMissingCustomFields({ - customFields: query.customFields, - customFieldsConfiguration, - }), - }; + const normalizedCase = normalizeCreateCaseRequest(query, customFieldsConfiguration); - const newCase = await caseService.postNewCase({ + const newCase = await caseService.createCase({ attributes: transformNewCase({ user, - newCase: normalizedQuery, + newCase: normalizedCase, }), id: savedObjectID, refresh: false, }); await userActionService.creator.createUserAction({ - type: UserActionTypes.create_case, - caseId: newCase.id, - user, - payload: { - ...query, - severity: query.severity ?? CaseSeverity.LOW, - assignees: query.assignees ?? [], - category: query.category ?? null, - customFields: query.customFields ?? [], + userAction: { + type: UserActionTypes.create_case, + caseId: newCase.id, + user, + payload: { + ...query, + severity: query.severity ?? CaseSeverity.LOW, + assignees: query.assignees ?? [], + category: query.category ?? null, + customFields: query.customFields ?? [], + }, + owner: newCase.attributes.owner, }, - owner: newCase.attributes.owner, }); if (query.assignees && query.assignees.length !== 0) { diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index cf3732c065e23..fba7908ed2762 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -261,11 +261,13 @@ export const push = async ( if (shouldMarkAsClosed) { await userActionService.creator.createUserAction({ - type: UserActionTypes.status, - payload: { status: CaseStatuses.closed }, - user, - caseId, - owner: myCase.attributes.owner, + userAction: { + type: UserActionTypes.status, + payload: { status: CaseStatuses.closed }, + user, + caseId, + owner: myCase.attributes.owner, + }, refresh: false, }); @@ -275,11 +277,13 @@ export const push = async ( } await userActionService.creator.createUserAction({ - type: UserActionTypes.pushed, - payload: { externalService }, - user, - caseId, - owner: myCase.attributes.owner, + userAction: { + type: UserActionTypes.pushed, + payload: { externalService }, + user, + caseId, + owner: myCase.attributes.owner, + }, }); /* End of update case with push information */ diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index b2baff721302f..1f7a489127a08 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -369,7 +369,7 @@ export const update = async ( ); } - const configurations = await casesClient.configure.get({}); + const configurations = await casesClient.configure.get(); const customFieldsConfigurationMap: Map = new Map( configurations.map((conf) => [conf.owner, conf.customFields]) ); diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index a1017b20b4286..2d697b597baac 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { omit } from 'lodash'; + import { comment as commentObj, userActions, @@ -28,9 +30,16 @@ import { formatComments, addKibanaInformationToDescription, fillMissingCustomFields, + normalizeCreateCaseRequest, } from './utils'; import type { CaseCustomFields } from '../../../common/types/domain'; -import { CaseStatuses, CustomFieldTypes, UserActionActions } from '../../../common/types/domain'; +import { + CaseStatuses, + CustomFieldTypes, + UserActionActions, + CaseSeverity, + ConnectorTypes, +} from '../../../common/types/domain'; import { flattenCaseSavedObject } from '../../common/utils'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { casesConnectors } from '../../connectors'; @@ -1471,3 +1480,96 @@ describe('utils', () => { }); }); }); + +describe('normalizeCreateCaseRequest', () => { + const theCase = { + title: 'My Case', + tags: [], + description: 'testing sir', + connector: { + id: '.none', + name: 'None', + type: ConnectorTypes.none, + fields: null, + }, + settings: { syncAlerts: true }, + severity: CaseSeverity.LOW, + owner: SECURITY_SOLUTION_OWNER, + assignees: [{ uid: '1' }], + category: 'my category', + customFields: [], + }; + + it('should trim title', async () => { + expect(normalizeCreateCaseRequest({ ...theCase, title: 'title with spaces ' })).toEqual({ + ...theCase, + title: 'title with spaces', + }); + }); + + it('should trim description', async () => { + expect( + normalizeCreateCaseRequest({ + ...theCase, + description: 'this is a description with spaces!! ', + }) + ).toEqual({ + ...theCase, + description: 'this is a description with spaces!!', + }); + }); + + it('should trim tags', async () => { + expect( + normalizeCreateCaseRequest({ + ...theCase, + tags: ['pepsi ', 'coke'], + }) + ).toEqual({ + ...theCase, + tags: ['pepsi', 'coke'], + }); + }); + + it('should trim category', async () => { + expect( + normalizeCreateCaseRequest({ + ...theCase, + category: 'reporting ', + }) + ).toEqual({ + ...theCase, + category: 'reporting', + }); + }); + + it('should set the category to null if missing', async () => { + expect(normalizeCreateCaseRequest(omit(theCase, 'category'))).toEqual({ + ...theCase, + category: null, + }); + }); + + it('should fill out missing custom fields', async () => { + expect( + normalizeCreateCaseRequest(omit(theCase, 'customFields'), [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + ]) + ).toEqual({ + ...theCase, + customFields: [{ key: 'first_key', type: CustomFieldTypes.TEXT, value: null }], + }); + }); + + it('should set the customFields to an empty array if missing', async () => { + expect(normalizeCreateCaseRequest(omit(theCase, 'customFields'))).toEqual({ + ...theCase, + customFields: [], + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 9e7f1ab73af20..474871af02c5d 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -25,6 +25,7 @@ import type { } from '../../../common/types/domain'; import { CaseStatuses, UserActionTypes, AttachmentType } from '../../../common/types/domain'; import type { + CasePostRequest, CaseRequestCustomFields, CaseUserActionsDeprecatedResponse, } from '../../../common/types/api'; @@ -480,3 +481,18 @@ export const fillMissingCustomFields = ({ return [...customFields, ...missingCustomFields]; }; + +export const normalizeCreateCaseRequest = ( + request: CasePostRequest, + customFieldsConfiguration?: CustomFieldsConfiguration +) => ({ + ...request, + title: request.title.trim(), + description: request.description.trim(), + category: request.category?.trim() ?? null, + tags: request.tags?.map((tag) => tag.trim()) ?? [], + customFields: fillMissingCustomFields({ + customFields: request.customFields, + customFieldsConfiguration, + }), +}); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 1482ea501ca87..1261f1061a371 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -69,7 +69,7 @@ export interface ConfigureSubClient { /** * Retrieves the external connector configuration for a particular case owner. */ - get(params: GetConfigurationFindRequest): Promise; + get(params?: GetConfigurationFindRequest): Promise; /** * Retrieves the valid external connectors supported by the cases plugin. */ @@ -120,7 +120,7 @@ export const createConfigurationSubClient = ( casesInternalClient: CasesClientInternal ): ConfigureSubClient => { return Object.freeze({ - get: (params: GetConfigurationFindRequest) => get(params, clientArgs, casesInternalClient), + get: (params?: GetConfigurationFindRequest) => get(params, clientArgs, casesInternalClient), getConnectors: () => getConnectors(clientArgs), update: (configurationId: string, configuration: ConfigurationPatchRequest) => update(configurationId, configuration, clientArgs, casesInternalClient), @@ -130,7 +130,7 @@ export const createConfigurationSubClient = ( }; export async function get( - params: GetConfigurationFindRequest, + params: GetConfigurationFindRequest = {}, clientArgs: CasesClientArgs, casesClientInternal: CasesClientInternal ): Promise { diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 8fd0a61c35f30..7d4015d15a085 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -47,6 +47,7 @@ type CasesSubClientMock = jest.Mocked; const createCasesSubClientMock = (): CasesSubClientMock => { return { create: jest.fn(), + bulkCreate: jest.fn(), find: jest.fn(), resolve: jest.fn(), get: jest.fn(), diff --git a/x-pack/plugins/cases/server/common/models/case_with_comments.ts b/x-pack/plugins/cases/server/common/models/case_with_comments.ts index 5e48c38e67d0d..e1b89d7af791e 100644 --- a/x-pack/plugins/cases/server/common/models/case_with_comments.ts +++ b/x-pack/plugins/cases/server/common/models/case_with_comments.ts @@ -193,13 +193,15 @@ export class CaseCommentModel { const { id, version, ...queryRestAttributes } = updateRequest; await this.params.services.userActionService.creator.createUserAction({ - type: UserActionTypes.comment, - action: UserActionActions.update, - caseId: this.caseInfo.id, - attachmentId: comment.id, - payload: { attachment: queryRestAttributes }, - user: this.params.user, - owner, + userAction: { + type: UserActionTypes.comment, + action: UserActionActions.update, + caseId: this.caseInfo.id, + attachmentId: comment.id, + payload: { attachment: queryRestAttributes }, + user: this.params.user, + owner, + }, }); } @@ -403,15 +405,17 @@ export class CaseCommentModel { req: AttachmentRequest ) { await this.params.services.userActionService.creator.createUserAction({ - type: UserActionTypes.comment, - action: UserActionActions.create, - caseId: this.caseInfo.id, - attachmentId: comment.id, - payload: { - attachment: req, + userAction: { + type: UserActionTypes.comment, + action: UserActionActions.create, + caseId: this.caseInfo.id, + attachmentId: comment.id, + payload: { + attachment: req, + }, + user: this.params.user, + owner: comment.attributes.owner, }, - user: this.params.user, - owner: comment.attributes.owner, }); } diff --git a/x-pack/plugins/cases/server/common/types.ts b/x-pack/plugins/cases/server/common/types.ts index c79cb96d0d0b6..8855d73e9f4b3 100644 --- a/x-pack/plugins/cases/server/common/types.ts +++ b/x-pack/plugins/cases/server/common/types.ts @@ -52,3 +52,7 @@ export type FileAttachmentRequest = Omit< export type AttachmentSavedObject = SavedObject; export type SOWithErrors = Omit, 'attributes'> & { error: SavedObjectError }; + +export interface SavedObjectsBulkResponseWithErrors { + saved_objects: Array | SOWithErrors>; +} diff --git a/x-pack/plugins/cases/server/services/attachments/index.test.ts b/x-pack/plugins/cases/server/services/attachments/index.test.ts index fc12e26b67ed6..b089616717e76 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.test.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.test.ts @@ -21,9 +21,10 @@ import { persistableStateAttachmentAttributes, persistableStateAttachmentAttributesWithoutInjectedId, } from '../../attachment_framework/mocks'; -import { createAlertAttachment, createErrorSO, createUserAttachment } from './test_utils'; +import { createAlertAttachment, createUserAttachment } from './test_utils'; import { AttachmentType } from '../../../common/types/domain'; -import { createSOFindResponse } from '../test_utils'; +import { createErrorSO, createSOFindResponse } from '../test_utils'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../common'; describe('AttachmentService', () => { const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -135,9 +136,10 @@ describe('AttachmentService', () => { it('returns error objects unmodified', async () => { const userAttachment = createUserAttachment({ foo: 'bar' }); - const errorResponseObj = createErrorSO(); + const errorResponseObj = createErrorSO(CASE_COMMENT_SAVED_OBJECT); unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + // @ts-expect-error: SO client types are wrong saved_objects: [errorResponseObj, userAttachment], }); @@ -411,9 +413,10 @@ describe('AttachmentService', () => { it('returns error objects unmodified', async () => { const userAttachment = createUserAttachment({ foo: 'bar' }); - const errorResponseObj = createErrorSO(); + const errorResponseObj = createErrorSO(CASE_COMMENT_SAVED_OBJECT); unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + // @ts-expect-error: SO client types are wrong saved_objects: [errorResponseObj, userAttachment], }); diff --git a/x-pack/plugins/cases/server/services/attachments/operations/get.test.ts b/x-pack/plugins/cases/server/services/attachments/operations/get.test.ts index 5ee0cdce35769..590cabcae67a1 100644 --- a/x-pack/plugins/cases/server/services/attachments/operations/get.test.ts +++ b/x-pack/plugins/cases/server/services/attachments/operations/get.test.ts @@ -12,13 +12,9 @@ import type { SavedObjectsFindResponse } from '@kbn/core/server'; import { loggerMock } from '@kbn/logging-mocks'; import { createPersistableStateAttachmentTypeRegistryMock } from '../../../attachment_framework/mocks'; import { AttachmentGetter } from './get'; -import { - createAlertAttachment, - createErrorSO, - createFileAttachment, - createUserAttachment, -} from '../test_utils'; -import { mockPointInTimeFinder, createSOFindResponse } from '../../test_utils'; +import { createAlertAttachment, createFileAttachment, createUserAttachment } from '../test_utils'; +import { mockPointInTimeFinder, createSOFindResponse, createErrorSO } from '../../test_utils'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../../common'; describe('AttachmentService getter', () => { const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -50,12 +46,15 @@ describe('AttachmentService getter', () => { it('does not modified the error saved objects', async () => { unsecuredSavedObjectsClient.bulkGet.mockResolvedValue({ - saved_objects: [createUserAttachment(), createErrorSO()], + // @ts-expect-error: SO client types are not correct + saved_objects: [createUserAttachment(), createErrorSO(CASE_COMMENT_SAVED_OBJECT)], }); const res = await attachmentGetter.bulkGet(['1', '2']); - expect(res).toStrictEqual({ saved_objects: [createUserAttachment(), createErrorSO()] }); + expect(res).toStrictEqual({ + saved_objects: [createUserAttachment(), createErrorSO(CASE_COMMENT_SAVED_OBJECT)], + }); }); it('strips excess fields', async () => { diff --git a/x-pack/plugins/cases/server/services/attachments/test_utils.ts b/x-pack/plugins/cases/server/services/attachments/test_utils.ts index ab3c7a6f710be..b90fac81b5442 100644 --- a/x-pack/plugins/cases/server/services/attachments/test_utils.ts +++ b/x-pack/plugins/cases/server/services/attachments/test_utils.ts @@ -22,19 +22,6 @@ import { } from '../../../common/constants'; import { CASE_REF_NAME, EXTERNAL_REFERENCE_REF_NAME } from '../../common/constants'; -export const createErrorSO = () => - ({ - id: '1', - type: CASE_COMMENT_SAVED_OBJECT, - error: { - error: 'error', - message: 'message', - statusCode: 500, - }, - references: [], - // casting because this complains about attributes not being there - } as unknown as SavedObject); - export const createUserAttachment = ( attributes?: object ): SavedObject => { diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 712e22732b9ff..db6776b752474 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -41,10 +41,15 @@ import { createCaseSavedObjectResponse, basicCaseFields, createSOFindResponse, + createErrorSO, } from '../test_utils'; import { AttachmentService } from '../attachments'; import { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry'; -import type { CaseSavedObjectTransformed, CasePersistedAttributes } from '../../common/types/case'; +import type { + CaseSavedObjectTransformed, + CasePersistedAttributes, + CaseTransformedAttributes, +} from '../../common/types/case'; import { CasePersistedSeverity, CasePersistedStatus, @@ -175,6 +180,101 @@ describe('CasesService', () => { }); }); + describe('execution', () => { + describe('bulkCreateCases', () => { + it('return cases with the SO errors correctly', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + // @ts-expect-error: SO client types are not correct + saved_objects: [createCaseSavedObjectResponse(), createErrorSO('cases')], + }); + + const res = await service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ + connector: getNoneCaseConnector(), + severity: CaseSeverity.MEDIUM, + }), + id: '1', + }, + ], + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": Object { + "connector_id": "none", + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "id": "1", + "references": Array [], + "type": "cases", + }, + Object { + "error": Object { + "error": "error", + "message": "message", + "statusCode": 500, + }, + "id": "1", + "references": Array [], + "type": "cases", + }, + ], + } + `); + }); + }); + }); + describe('transforms the external model to the Elasticsearch model', () => { describe('patch', () => { it('includes the passed in fields', async () => { @@ -663,11 +763,11 @@ describe('CasesService', () => { }); }); - describe('post', () => { + describe('createCase', () => { it('creates a null external_service field when the attribute was null in the creation parameters', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: createJiraConnector() }), id: '1', }); @@ -680,7 +780,7 @@ describe('CasesService', () => { it('includes the creation attributes excluding the connector.id and connector_id', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: createJiraConnector(), externalService: createExternalService(), @@ -780,7 +880,7 @@ describe('CasesService', () => { it('includes default values for total_alerts and total_comments', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector(), }), @@ -797,7 +897,7 @@ describe('CasesService', () => { it('moves the connector.id and connector_id to the references', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: createJiraConnector(), externalService: createExternalService(), @@ -826,7 +926,7 @@ describe('CasesService', () => { it('sets fields to an empty array when it is not included with the connector', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: createJiraConnector({ setFieldsToNull: true }), externalService: createExternalService(), @@ -842,7 +942,7 @@ describe('CasesService', () => { it('does not create a reference for a none connector', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector() }), id: '1', }); @@ -855,7 +955,7 @@ describe('CasesService', () => { it('does not create a reference for an external_service field that is null', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector() }), id: '1', }); @@ -875,7 +975,7 @@ describe('CasesService', () => { async (postParamsSeverity, expectedSeverity) => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector(), severity: postParamsSeverity, @@ -898,7 +998,7 @@ describe('CasesService', () => { async (postParamsStatus, expectedStatus) => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); - await service.postNewCase({ + await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector(), status: postParamsStatus, @@ -912,6 +1012,103 @@ describe('CasesService', () => { } ); }); + + describe('bulkCreateCases', () => { + it('creates a null external_service field when the attribute was null in the creation parameters', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [createCaseSavedObjectResponse()], + }); + + await service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ + connector: getNoneCaseConnector(), + severity: CaseSeverity.MEDIUM, + }), + id: '1', + }, + ], + }); + + const bulkCreateRequest = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]; + + expect(bulkCreateRequest).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "attributes": Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [], + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": 10, + "status": 0, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "total_alerts": -1, + "total_comments": -1, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "id": "1", + "references": Array [], + "type": "cases", + }, + ], + Object { + "refresh": undefined, + }, + ] + `); + }); + }); + + it('includes default values for total_alerts and total_comments', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [createCaseSavedObjectResponse({})], + }); + + await service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ connector: getNoneCaseConnector() }), + id: '1', + }, + ], + }); + + const postAttributes = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0][0] + .attributes as CasePersistedAttributes; + + expect(postAttributes.total_alerts).toEqual(-1); + expect(postAttributes.total_comments).toEqual(-1); + }); }); describe('transforms the Elasticsearch model to the external model', () => { @@ -1364,7 +1561,7 @@ describe('CasesService', () => { }); }); - describe('post', () => { + describe('createCase', () => { it('includes the connector.id and connector_id fields in the response', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue( createCaseSavedObjectResponse({ @@ -1373,7 +1570,7 @@ describe('CasesService', () => { }) ); - const res = await service.postNewCase({ + const res = await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector() }), id: '1', }); @@ -1394,7 +1591,7 @@ describe('CasesService', () => { createCaseSavedObjectResponse({ overrides: { severity: internalSeverityValue } }) ); - const res = await service.postNewCase({ + const res = await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector() }), id: '1', }); @@ -1414,7 +1611,7 @@ describe('CasesService', () => { createCaseSavedObjectResponse({ overrides: { status: internalStatusValue } }) ); - const res = await service.postNewCase({ + const res = await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector() }), id: '1', }); @@ -1426,7 +1623,7 @@ describe('CasesService', () => { it('does not include total_alerts and total_comments fields in the response', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse({})); - const res = await service.postNewCase({ + const res = await service.createCase({ attributes: createCasePostParams({ connector: getNoneCaseConnector() }), id: '1', }); @@ -1436,6 +1633,109 @@ describe('CasesService', () => { }); }); + describe('bulkCreateCases', () => { + it('includes the connector.id and connector_id fields in the response', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + createCaseSavedObjectResponse({ + overrides: { severity: CasePersistedSeverity.MEDIUM }, + }), + ], + }); + + const res = await service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ connector: getNoneCaseConnector() }), + id: '1', + }, + ], + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": Object { + "connector_id": "none", + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "medium", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "id": "1", + "references": Array [], + "type": "cases", + }, + ], + } + `); + }); + + it('does not include total_alerts and total_comments fields in the response', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [createCaseSavedObjectResponse({})], + }); + + const res = await service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ connector: getNoneCaseConnector() }), + id: '1', + }, + ], + }); + + const theCase = res.saved_objects[0] as SavedObject; + + expect(theCase.attributes).not.toHaveProperty('total_alerts'); + expect(theCase.attributes).not.toHaveProperty('total_comments'); + }); + }); + describe('find', () => { it('includes the connector.id and connector_id field in the response', async () => { const findMockReturn = createSOFindResponse([ @@ -2469,12 +2769,12 @@ describe('CasesService', () => { }); }); - describe('post', () => { + describe('createCase', () => { it('decodes correctly', async () => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); await expect( - service.postNewCase({ + service.createCase({ attributes: createCasePostParams({ connector: createJiraConnector() }), id: '1', }) @@ -2489,7 +2789,7 @@ describe('CasesService', () => { unsecuredSavedObjectsClient.create.mockResolvedValue({ ...theCase, attributes }); await expect( - service.postNewCase({ + service.createCase({ attributes: createCasePostParams({ connector: createJiraConnector() }), id: '1', }) @@ -2503,7 +2803,7 @@ describe('CasesService', () => { unsecuredSavedObjectsClient.create.mockResolvedValue({ ...theCase, attributes }); await expect( - service.postNewCase({ + service.createCase({ attributes: createCasePostParams({ connector: createJiraConnector() }), id: '1', }) @@ -2567,6 +2867,126 @@ describe('CasesService', () => { }); }); + describe('bulkCreateCases', () => { + it('decodes correctly', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [createCaseSavedObjectResponse()], + }); + + await expect( + service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ connector: createJiraConnector() }), + id: '1', + }, + ], + }) + ).resolves.not.toThrow(); + }); + + it.each(Object.keys(attributesToValidateIfMissing))( + 'throws if %s is omitted', + async (key) => { + const theCase = createCaseSavedObjectResponse(); + const attributes = omit({ ...theCase.attributes }, key); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [{ ...theCase, attributes }], + }); + + await expect( + service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ connector: createJiraConnector() }), + id: '1', + }, + ], + }) + ).rejects.toThrow(`Invalid value "undefined" supplied to "${key}"`); + } + ); + + it('strips out excess attributes', async () => { + const theCase = createCaseSavedObjectResponse(); + const attributes = { ...theCase.attributes, 'not-exists': 'not-exists' }; + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [{ ...theCase, attributes }], + }); + + await expect( + service.bulkCreateCases({ + cases: [ + { + ...createCasePostParams({ connector: createJiraConnector() }), + id: '1', + }, + ], + }) + ).resolves.toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": Object { + "connector_id": "none", + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "id": "1", + "references": Array [], + "type": "cases", + }, + ], + } + `); + }); + }); + describe('patchCase', () => { it('decodes correctly', async () => { unsecuredSavedObjectsClient.update.mockResolvedValue(createUpdateSOResponse()); @@ -2732,7 +3152,7 @@ describe('CasesService', () => { }); describe('Decoding requests', () => { - describe('create case', () => { + describe('createCase', () => { beforeEach(() => { unsecuredSavedObjectsClient.create.mockResolvedValue(createCaseSavedObjectResponse()); }); @@ -2740,7 +3160,7 @@ describe('CasesService', () => { it('decodes correctly the requested attributes', async () => { const attributes = createCasePostParams({ connector: createJiraConnector() }); - await expect(service.postNewCase({ id: 'a', attributes })).resolves.not.toThrow(); + await expect(service.createCase({ id: 'a', attributes })).resolves.not.toThrow(); }); it('throws if title is omitted', async () => { @@ -2748,7 +3168,7 @@ describe('CasesService', () => { unset(attributes, 'title'); await expect( - service.postNewCase({ + service.createCase({ attributes, id: '1', }) @@ -2761,13 +3181,54 @@ describe('CasesService', () => { foo: 'bar', }; - await expect(service.postNewCase({ id: 'a', attributes })).resolves.not.toThrow(); + await expect(service.createCase({ id: 'a', attributes })).resolves.not.toThrow(); const persistedAttributes = unsecuredSavedObjectsClient.create.mock.calls[0][1]; expect(persistedAttributes).not.toHaveProperty('foo'); }); }); + describe('bulkCreateCases', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [createCaseSavedObjectResponse()], + }); + }); + + it('decodes correctly the requested attributes', async () => { + const attributes = createCasePostParams({ connector: createJiraConnector() }); + + await expect( + service.bulkCreateCases({ cases: [{ id: 'a', ...attributes }] }) + ).resolves.not.toThrow(); + }); + + it('throws if title is omitted', async () => { + const attributes = createCasePostParams({ connector: createJiraConnector() }); + unset(attributes, 'title'); + + await expect( + service.bulkCreateCases({ cases: [{ id: '1', ...attributes }] }) + ).rejects.toThrow(`Invalid value "undefined" supplied to "title"`); + }); + + it('remove excess fields', async () => { + const attributes = { + ...createCasePostParams({ connector: createJiraConnector() }), + foo: 'bar', + }; + + await expect( + service.bulkCreateCases({ cases: [{ id: 'a', ...attributes }] }) + ).resolves.not.toThrow(); + + const persistedAttributes = + unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0][0].attributes; + + expect(persistedAttributes).not.toHaveProperty('foo'); + }); + }); + describe('patch case', () => { beforeEach(() => { unsecuredSavedObjectsClient.update.mockResolvedValue( diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index 8ead5738d5195..5942f5ce1096c 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -31,7 +31,11 @@ import { MAX_DOCS_PER_PAGE, } from '../../../common/constants'; import { decodeOrThrow } from '../../../common/api'; -import type { SavedObjectFindOptionsKueryNode, SOWithErrors } from '../../common/types'; +import type { + SavedObjectFindOptionsKueryNode, + SavedObjectsBulkResponseWithErrors, + SOWithErrors, +} from '../../common/types'; import { defaultSortField, flattenCaseSavedObject, isSOError } from '../../common/utils'; import { DEFAULT_PAGE, DEFAULT_PER_PAGE } from '../../routes/api'; import { combineFilters } from '../../client/utils'; @@ -67,10 +71,11 @@ import type { FindCaseCommentsArgs, GetReportersArgs, GetTagsArgs, - PostCaseArgs, + CreateCaseArgs, PatchCaseArgs, PatchCasesArgs, GetCategoryArgs, + BulkCreateCasesArgs, } from './types'; import type { AttachmentTransformedAttributes } from '../../common/types/attachments'; import { bulkDecodeSOAttributes } from '../utils'; @@ -589,13 +594,13 @@ export class CasesService { } } - public async postNewCase({ + public async createCase({ attributes, id, refresh, - }: PostCaseArgs): Promise { + }: CreateCaseArgs): Promise { try { - this.log.debug(`Attempting to POST a new case`); + this.log.debug(`Attempting to create a new case`); const decodedAttributes = decodeOrThrow(CaseTransformedAttributesRt)(attributes); const transformedAttributes = transformAttributesToESModel(decodedAttributes); @@ -614,7 +619,57 @@ export class CasesService { return { ...res, attributes: decodedRes }; } catch (error) { - this.log.error(`Error on POST a new case: ${error}`); + this.log.error(`Error on creating a new case: ${error}`); + throw error; + } + } + + public async bulkCreateCases({ + cases, + refresh, + }: BulkCreateCasesArgs): Promise> { + try { + this.log.debug(`Attempting to bulk create cases`); + + const bulkCreateRequest = cases.map(({ id, ...attributes }) => { + const decodedAttributes = decodeOrThrow(CaseTransformedAttributesRt)(attributes); + + const { attributes: transformedAttributes, referenceHandler } = + transformAttributesToESModel(decodedAttributes); + + transformedAttributes.total_alerts = -1; + transformedAttributes.total_comments = -1; + + return { + type: CASE_SAVED_OBJECT, + id, + attributes: transformedAttributes, + references: referenceHandler.build(), + }; + }); + + const bulkCreateResponse = + await this.unsecuredSavedObjectsClient.bulkCreate( + bulkCreateRequest, + { + refresh, + } + ); + + const res = bulkCreateResponse.saved_objects.map((theCase) => { + if (isSOError(theCase)) { + return theCase; + } + + const transformedCase = transformSavedObjectToExternalModel(theCase); + const decodedRes = decodeOrThrow(CaseTransformedAttributesRt)(transformedCase.attributes); + + return { ...transformedCase, attributes: decodedRes }; + }); + + return { saved_objects: res }; + } catch (error) { + this.log.error(`Case Service: Error on bulk creating cases: ${error}`); throw error; } } diff --git a/x-pack/plugins/cases/server/services/cases/types.ts b/x-pack/plugins/cases/server/services/cases/types.ts index 14f210134e48d..478525f32d6fa 100644 --- a/x-pack/plugins/cases/server/services/cases/types.ts +++ b/x-pack/plugins/cases/server/services/cases/types.ts @@ -46,11 +46,15 @@ export interface FindCaseCommentsArgs { options?: SavedObjectFindOptionsKueryNode; } -export interface PostCaseArgs extends IndexRefresh { +export interface CreateCaseArgs extends IndexRefresh { attributes: CaseTransformedAttributes; id: string; } +export interface BulkCreateCasesArgs extends IndexRefresh { + cases: Array<{ id: string } & CaseTransformedAttributes>; +} + export interface PatchCase extends IndexRefresh { caseId: string; updatedAttributes: Partial; diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index 7a9507be8b080..7e2636ffbd689 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -55,7 +55,8 @@ export const createCaseServiceMock = (): CaseServiceMock => { getResolveCase: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), - postNewCase: jest.fn(), + createCase: jest.fn(), + bulkCreateCases: jest.fn(), patchCase: jest.fn(), patchCases: jest.fn(), findCasesGroupedByID: jest.fn(), @@ -101,6 +102,7 @@ const createUserActionPersisterServiceMock = (): CaseUserActionPersisterServiceM bulkCreateAttachmentDeletion: jest.fn(), bulkCreateAttachmentCreation: jest.fn(), createUserAction: jest.fn(), + bulkCreateUserAction: jest.fn(), }; return service as unknown as CaseUserActionPersisterServiceMock; diff --git a/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts b/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts index 73ae62d582289..e17eb2f22f7bc 100644 --- a/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts +++ b/x-pack/plugins/cases/server/services/notifications/email_notification_service.ts @@ -13,7 +13,7 @@ import type { UserProfileUserInfo } from '@kbn/user-profile-components'; import { CASE_SAVED_OBJECT, MAX_CONCURRENT_SEARCHES } from '../../../common/constants'; import type { CaseSavedObjectTransformed } from '../../common/types/case'; import { getCaseViewPath } from '../../common/utils'; -import type { NotificationService, NotifyArgs } from './types'; +import type { NotificationService, NotifyAssigneesArgs } from './types'; import { assigneesTemplateRenderer } from './templates/assignees/renderer'; type WithRequiredProperty = T & Required>; @@ -86,7 +86,7 @@ export class EmailNotificationService implements NotificationService { return assigneesTemplateRenderer(theCase, caseUrl); } - public async notifyAssignees({ assignees, theCase }: NotifyArgs) { + public async notifyAssignees({ assignees, theCase }: NotifyAssigneesArgs) { try { if (!this.notifications.isEmailServiceAvailable()) { this.logger.warn('Could not notifying assignees. Email service is not available.'); @@ -139,14 +139,14 @@ export class EmailNotificationService implements NotificationService { } } - public async bulkNotifyAssignees(casesAndAssigneesToNotifyForAssignment: NotifyArgs[]) { + public async bulkNotifyAssignees(casesAndAssigneesToNotifyForAssignment: NotifyAssigneesArgs[]) { if (casesAndAssigneesToNotifyForAssignment.length === 0) { return; } await pMap( casesAndAssigneesToNotifyForAssignment, - (args: NotifyArgs) => this.notifyAssignees(args), + (args: NotifyAssigneesArgs) => this.notifyAssignees(args), { concurrency: MAX_CONCURRENT_SEARCHES, } diff --git a/x-pack/plugins/cases/server/services/notifications/types.ts b/x-pack/plugins/cases/server/services/notifications/types.ts index d89184f03b01c..7cbbc33cf808e 100644 --- a/x-pack/plugins/cases/server/services/notifications/types.ts +++ b/x-pack/plugins/cases/server/services/notifications/types.ts @@ -8,12 +8,12 @@ import type { CaseAssignees } from '../../../common/types/domain'; import type { CaseSavedObjectTransformed } from '../../common/types/case'; -export interface NotifyArgs { +export interface NotifyAssigneesArgs { assignees: CaseAssignees; theCase: CaseSavedObjectTransformed; } export interface NotificationService { - notifyAssignees: (args: NotifyArgs) => Promise; - bulkNotifyAssignees: (args: NotifyArgs[]) => Promise; + notifyAssignees: (args: NotifyAssigneesArgs) => Promise; + bulkNotifyAssignees: (args: NotifyAssigneesArgs[]) => Promise; } diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts index 022a868ee0d9b..cd0f16d66e4cb 100644 --- a/x-pack/plugins/cases/server/services/test_utils.ts +++ b/x-pack/plugins/cases/server/services/test_utils.ts @@ -26,6 +26,7 @@ import type { ConnectorPersistedFields } from '../common/types/connectors'; import type { CasePersistedAttributes } from '../common/types/case'; import { CasePersistedSeverity, CasePersistedStatus } from '../common/types/case'; import type { ExternalServicePersisted } from '../common/types/external_service'; +import type { SOWithErrors } from '../common/types'; /** * This is only a utility interface to help with constructing test cases. After the migration, the ES format will no longer @@ -271,3 +272,14 @@ export const mockPointInTimeFinder = }, }); }; + +export const createErrorSO = (type: string): SOWithErrors => ({ + id: '1', + type, + error: { + error: 'error', + message: 'message', + statusCode: 500, + }, + references: [], +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts index 905e94bc11d8a..20c06f2701fed 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts @@ -107,9 +107,11 @@ describe('CaseUserActionService', () => { describe('create case', () => { it('creates a create case user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: casePayload, - type: UserActionTypes.create_case, + userAction: { + ...commonArgs, + payload: casePayload, + type: UserActionTypes.create_case, + }, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( @@ -159,9 +161,11 @@ describe('CaseUserActionService', () => { it('logs a create case user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: casePayload, - type: UserActionTypes.create_case, + userAction: { + ...commonArgs, + payload: casePayload, + type: UserActionTypes.create_case, + }, }); expect(mockAuditLogger.log).toBeCalledTimes(1); @@ -193,9 +197,11 @@ describe('CaseUserActionService', () => { describe('status', () => { it('creates an update status user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: { status: CaseStatuses.closed }, - type: UserActionTypes.status, + userAction: { + ...commonArgs, + payload: { status: CaseStatuses.closed }, + type: UserActionTypes.status, + }, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( @@ -218,9 +224,11 @@ describe('CaseUserActionService', () => { it('logs an update status user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: { status: CaseStatuses.closed }, - type: UserActionTypes.status, + userAction: { + ...commonArgs, + payload: { status: CaseStatuses.closed }, + type: UserActionTypes.status, + }, }); expect(mockAuditLogger.log).toBeCalledTimes(1); @@ -253,9 +261,11 @@ describe('CaseUserActionService', () => { describe('severity', () => { it('creates an update severity user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: { severity: CaseSeverity.MEDIUM }, - type: UserActionTypes.severity, + userAction: { + ...commonArgs, + payload: { severity: CaseSeverity.MEDIUM }, + type: UserActionTypes.severity, + }, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( @@ -278,9 +288,11 @@ describe('CaseUserActionService', () => { it('logs an update severity user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: { severity: CaseSeverity.MEDIUM }, - type: UserActionTypes.severity, + userAction: { + ...commonArgs, + payload: { severity: CaseSeverity.MEDIUM }, + type: UserActionTypes.severity, + }, }); expect(mockAuditLogger.log).toBeCalledTimes(1); @@ -313,9 +325,11 @@ describe('CaseUserActionService', () => { describe('push', () => { it('creates a push user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: { externalService }, - type: UserActionTypes.pushed, + userAction: { + ...commonArgs, + payload: { externalService }, + type: UserActionTypes.pushed, + }, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( @@ -357,9 +371,11 @@ describe('CaseUserActionService', () => { it('logs a push user action', async () => { await service.creator.createUserAction({ - ...commonArgs, - payload: { externalService }, - type: UserActionTypes.pushed, + userAction: { + ...commonArgs, + payload: { externalService }, + type: UserActionTypes.pushed, + }, }); expect(mockAuditLogger.log).toBeCalledTimes(1); @@ -396,11 +412,13 @@ describe('CaseUserActionService', () => { [UserActionActions.update], ])('creates a comment user action of action: %s', async (action) => { await service.creator.createUserAction({ - ...commonArgs, - type: UserActionTypes.comment, - action, - attachmentId: 'test-id', - payload: { attachment: comment }, + userAction: { + ...commonArgs, + type: UserActionTypes.comment, + action, + attachmentId: 'test-id', + payload: { attachment: comment }, + }, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( @@ -438,11 +456,13 @@ describe('CaseUserActionService', () => { [UserActionActions.update], ])('logs a comment user action of action: %s', async (action) => { await service.creator.createUserAction({ - ...commonArgs, - type: UserActionTypes.comment, - action, - attachmentId: 'test-id', - payload: { attachment: comment }, + userAction: { + ...commonArgs, + type: UserActionTypes.comment, + action, + attachmentId: 'test-id', + payload: { attachment: comment }, + }, }); expect(mockAuditLogger.log).toBeCalledTimes(1); diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts index 853c4969237f5..833e8676a2619 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts @@ -15,7 +15,11 @@ import { set, unset } from 'lodash'; import { createConnectorObject } from '../../test_utils'; import { UserActionPersister } from './create'; import { createUserActionSO } from '../test_utils'; -import type { BulkCreateAttachmentUserAction, CreateUserActionClient } from '../types'; +import type { + BuilderParameters, + BulkCreateAttachmentUserAction, + CreateUserActionArgs, +} from '../types'; import type { UserActionPersistedAttributes } from '../../../common/types/user_actions'; import { getAssigneesAddedRemovedUserActions, @@ -65,16 +69,19 @@ describe('UserActionPersister', () => { jest.useRealTimers(); }); - const getRequest = () => + const getRequest = (overrides = {}) => ({ - action: 'update' as const, - type: 'connector' as const, - caseId: 'test', - payload: { connector: createConnectorObject().connector }, - connectorId: '1', - owner: 'cases', - user: { email: '', full_name: '', username: '' }, - } as CreateUserActionClient<'connector'>); + userAction: { + action: 'update' as const, + type: 'connector' as const, + caseId: 'test', + payload: { connector: createConnectorObject().connector }, + connectorId: '1', + owner: 'cases', + user: { email: '', full_name: '', username: '' }, + ...overrides, + }, + } as CreateUserActionArgs); const getBulkCreateAttachmentRequest = (): BulkCreateAttachmentUserAction => ({ caseId: 'test', @@ -107,7 +114,7 @@ describe('UserActionPersister', () => { it('throws if fields is omitted', async () => { const req = getRequest(); - unset(req, 'payload.connector.fields'); + unset(req, 'userAction.payload.connector.fields'); await expect(persister.createUserAction(req)).rejects.toThrow( 'Invalid value "undefined" supplied to "payload,connector,fields"' @@ -141,6 +148,56 @@ describe('UserActionPersister', () => { }); }); + it('decodes correctly the requested attributes', async () => { + await expect( + persister.bulkCreateUserAction({ + userActions: [getRequest().userAction], + }) + ).resolves.not.toThrow(); + }); + + it('throws if owner is omitted', async () => { + const req = getRequest().userAction; + unset(req, 'owner'); + + await expect( + persister.bulkCreateUserAction({ + userActions: [req], + }) + ).rejects.toThrow('Invalid value "undefined" supplied to "owner"'); + }); + + it('strips out excess attributes', async () => { + const req = getRequest().userAction; + set(req, 'payload.foo', 'bar'); + + await expect( + persister.bulkCreateUserAction({ + userActions: [req], + }) + ).resolves.not.toThrow(); + + const persistedAttributes = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0][0] + .attributes as UserActionPersistedAttributes; + + expect(persistedAttributes.payload).not.toHaveProperty('foo'); + }); + }); + + describe('bulkCreateUserAction', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + attributes: createUserActionSO(), + id: '1', + type: CASE_USER_ACTION_SAVED_OBJECT, + references: [], + }, + ], + }); + }); + it('decodes correctly the requested attributes', async () => { await expect( persister.bulkCreateAttachmentCreation(getBulkCreateAttachmentRequest()) @@ -524,4 +581,103 @@ describe('UserActionPersister', () => { }); }); }); + + describe('bulkCreateUserAction', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + attributes: [createUserActionSO()], + id: '1', + type: CASE_USER_ACTION_SAVED_OBJECT, + references: [], + }, + { + attributes: [createUserActionSO()], + id: '2', + type: CASE_USER_ACTION_SAVED_OBJECT, + references: [], + }, + ], + }); + }); + + it('bulk creates the user actions correctly', async () => { + const connectorUserAction = getRequest().userAction; + const titleUserAction = getRequest<'title'>({ + type: 'title', + payload: { title: 'my title' }, + }).userAction; + + await persister.bulkCreateUserAction({ + userActions: [connectorUserAction, titleUserAction], + }); + + expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0]).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "", + "full_name": "", + "username": "", + }, + "owner": "cases", + "payload": Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + }, + "type": "connector", + }, + "references": Array [ + Object { + "id": "test", + "name": "associated-cases", + "type": "cases", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ], + "type": "cases-user-actions", + }, + Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "", + "full_name": "", + "username": "", + }, + "owner": "cases", + "payload": Object { + "title": "my title", + }, + "type": "title", + }, + "references": Array [ + Object { + "id": "test", + "name": "associated-cases", + "type": "cases", + }, + ], + "type": "cases-user-actions", + }, + ] + `); + }); + }); }); diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts index 1f2ba3dc8f046..e66f375e39108 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts @@ -30,7 +30,6 @@ import type { BulkCreateBulkUpdateCaseUserActions, CommonUserActionArgs, CreatePayloadFunction, - CreateUserActionClient, CreateUserActionES, GetUserActionItemByDifference, PostCaseUserActionArgs, @@ -38,6 +37,8 @@ import type { TypedUserActionDiffedItems, UserActionEvent, UserActionsDict, + CreateUserActionArgs, + BulkCreateUserActionArgs, } from '../types'; import { isAssigneesArray, isCustomFieldsArray, isStringArray } from '../type_guards'; import type { IndexRefresh } from '../../types'; @@ -375,21 +376,16 @@ export class UserActionPersister { } public async createUserAction({ - action, - type, - caseId, - user, - owner, - payload, - connectorId, - attachmentId, + userAction, refresh, - }: CreateUserActionClient): Promise { + }: CreateUserActionArgs): Promise { + const { action, type, caseId, user, owner, payload, connectorId, attachmentId } = userAction; + try { this.context.log.debug(`Attempting to create a user action of type: ${type}`); const userActionBuilder = this.builderFactory.getBuilder(type); - const userAction = userActionBuilder?.build({ + const userActionPayload = userActionBuilder?.build({ action, caseId, user, @@ -399,8 +395,8 @@ export class UserActionPersister { payload, }); - if (userAction) { - await this.createAndLog({ userAction, refresh }); + if (userActionPayload) { + await this.createAndLog({ userAction: userActionPayload, refresh }); } } catch (error) { this.context.log.error(`Error on creating user action of type: ${type}. Error: ${error}`); @@ -408,6 +404,45 @@ export class UserActionPersister { } } + public async bulkCreateUserAction({ + userActions, + refresh, + }: BulkCreateUserActionArgs): Promise { + try { + this.context.log.debug(`Attempting to bulk create a user actions`); + + if (userActions.length <= 0) { + return; + } + + const userActionsPayload = userActions + .map(({ action, type, caseId, user, owner, payload, connectorId, attachmentId }) => { + const userActionBuilder = this.builderFactory.getBuilder(type); + const userAction = userActionBuilder?.build({ + action, + caseId, + user, + owner, + connectorId, + attachmentId, + payload, + }); + + if (userAction == null) { + return null; + } + + return userAction; + }) + .filter(Boolean) as UserActionEvent[]; + + await this.bulkCreateAndLog({ userActions: userActionsPayload, refresh }); + } catch (error) { + this.context.log.error(`Error on bulk creating user actions. Error: ${error}`); + throw error; + } + } + private async createAndLog({ userAction, refresh, diff --git a/x-pack/plugins/cases/server/services/user_actions/types.ts b/x-pack/plugins/cases/server/services/user_actions/types.ts index 42ebb7e413582..4b99651300852 100644 --- a/x-pack/plugins/cases/server/services/user_actions/types.ts +++ b/x-pack/plugins/cases/server/services/user_actions/types.ts @@ -312,9 +312,13 @@ export interface BulkCreateAttachmentUserAction attachments: Array<{ id: string; owner: string; attachment: AttachmentRequest }>; } -export type CreateUserActionClient = CreateUserAction & - CommonUserActionArgs & - IndexRefresh; +export type CreateUserActionArgs = { + userAction: CreateUserAction & CommonUserActionArgs; +} & IndexRefresh; + +export type BulkCreateUserActionArgs = { + userActions: Array & CommonUserActionArgs>; +} & IndexRefresh; export interface CreateUserActionES extends IndexRefresh { attributes: T; diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts b/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts index d68b87a2b132d..30dc4da6bb324 100644 --- a/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts +++ b/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts @@ -5,6 +5,7 @@ * 2.0. */ +import Boom from '@hapi/boom'; import { createHash } from 'crypto'; import { schema } from '@kbn/config-schema'; import type { CoreSetup, Logger } from '@kbn/core/server'; @@ -12,7 +13,7 @@ import type { ExternalReferenceAttachmentType, PersistableStateAttachmentTypeSetup, } from '@kbn/cases-plugin/server/attachment_framework/types'; -import { CasesPatchRequest } from '@kbn/cases-plugin/common/types/api'; +import { BulkCreateCasesRequest, CasesPatchRequest } from '@kbn/cases-plugin/common/types/api'; import type { FixtureStartDeps } from './plugin'; const hashParts = (parts: string[]): string => { @@ -106,4 +107,35 @@ export const registerRoutes = (core: CoreSetup, logger: Logger } } ); + + router.post( + { + path: '/api/cases_fixture/cases:bulkCreate', + validate: { + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + try { + const [_, { cases }] = await core.getStartServices(); + const client = await cases.getCasesClientWithRequest(request); + + return response.ok({ + body: await client.cases.bulkCreate(request.body as BulkCreateCasesRequest), + }); + } catch (error) { + logger.error(`Error : ${error}`); + + const boom = new Boom.Boom(error.message, { + statusCode: error.wrappedError.output.statusCode, + }); + + return response.customError({ + body: boom, + headers: boom.output.headers as { [key: string]: string }, + statusCode: boom.output.statusCode, + }); + } + } + ); }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/bulk_create_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/bulk_create_cases.ts new file mode 100644 index 0000000000000..8177649f22ab3 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/bulk_create_cases.ts @@ -0,0 +1,309 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type SuperTest from 'supertest'; +import expect from '@kbn/expect'; +import { BulkCreateCasesResponse } from '@kbn/cases-plugin/common/types/api'; +import { CaseSeverity } from '@kbn/cases-plugin/common'; +import { CaseStatuses, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { User } from '../../../../common/lib/authentication/types'; +import { defaultUser, getPostCaseRequest, postCaseResp } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + getCaseUserActions, + getSpaceUrlPrefix, + removeServerGeneratedPropertiesFromCase, + removeServerGeneratedPropertiesFromUserAction, +} from '../../../../common/lib/api'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + secOnly, + secOnlyRead, + globalRead, + obsOnlyRead, + obsSecRead, + noKibanaPrivileges, + testDisabled, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const es = getService('es'); + + /** + * There is no official route that supports + * bulk creating cases. The purpose of this test + * is to test the bulkCreate method of the cases client in + * x-pack/plugins/cases/server/client/cases/bulk_create.ts + * + * The test route is configured here x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts + */ + describe('bulk_create_cases', () => { + const bulkCreateCases = async ({ + superTestService = supertest, + data, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, + }: { + superTestService?: SuperTest.SuperTest; + data: object; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; + }): Promise => { + return superTestService + .post(`${getSpaceUrlPrefix(auth?.space)}/api/cases_fixture/cases:bulkCreate`) + .set('kbn-xsrf', 'foo') + .set('x-elastic-internal-origin', 'foo') + .auth(auth.user.username, auth.user.password) + .send(data) + .expect(expectedHttpCode) + .then((response) => response.body); + }; + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should bulk create cases', async () => { + const createdCases = await bulkCreateCases({ + data: { + cases: [getPostCaseRequest(), getPostCaseRequest({ severity: CaseSeverity.MEDIUM })], + }, + }); + + expect(createdCases.cases.length === 2); + + const firstCase = removeServerGeneratedPropertiesFromCase(createdCases.cases[0]); + const secondCase = removeServerGeneratedPropertiesFromCase(createdCases.cases[1]); + + expect(firstCase).to.eql(postCaseResp(null, getPostCaseRequest())); + expect(secondCase).to.eql( + postCaseResp(null, getPostCaseRequest({ severity: CaseSeverity.MEDIUM })) + ); + }); + + it('should bulk create cases with different owners', async () => { + const createdCases = await bulkCreateCases({ + data: { + cases: [getPostCaseRequest(), getPostCaseRequest({ owner: 'observabilityFixture' })], + }, + }); + + expect(createdCases.cases.length === 2); + + const firstCase = removeServerGeneratedPropertiesFromCase(createdCases.cases[0]); + const secondCase = removeServerGeneratedPropertiesFromCase(createdCases.cases[1]); + + expect(firstCase).to.eql(postCaseResp(null, getPostCaseRequest())); + expect(secondCase).to.eql( + postCaseResp(null, getPostCaseRequest({ owner: 'observabilityFixture' })) + ); + }); + + it('should allow creating a case with custom ID', async () => { + const createdCases = await bulkCreateCases({ + data: { + cases: [{ id: 'test-case', ...getPostCaseRequest() }], + }, + }); + + expect(createdCases.cases.length === 1); + + const firstCase = createdCases.cases[0]; + + expect(firstCase.id).to.eql('test-case'); + }); + + it('should validate custom fields correctly', async () => { + await bulkCreateCases({ + data: { + cases: [ + getPostCaseRequest({ + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: 'this is a text field value', + }, + ], + }), + ], + }, + expectedHttpCode: 400, + }); + }); + + it('should throw an error correctly', async () => { + await bulkCreateCases({ + data: { + cases: [ + // two cases with the same ID will result to a conflict error + { id: 'test-case', ...getPostCaseRequest() }, + { id: 'test-case', ...getPostCaseRequest() }, + ], + }, + expectedHttpCode: 409, + }); + }); + + it('should create user actions correctly', async () => { + const createdCases = await bulkCreateCases({ + data: { + cases: [getPostCaseRequest(), getPostCaseRequest({ severity: CaseSeverity.MEDIUM })], + }, + }); + + const firstCase = createdCases.cases[0]; + const secondCase = createdCases.cases[1]; + + const firstCaseUserActions = await getCaseUserActions({ + supertest, + caseID: firstCase.id, + }); + + const secondCaseUserActions = await getCaseUserActions({ + supertest, + caseID: secondCase.id, + }); + + expect(firstCaseUserActions.length).to.eql(1); + expect(secondCaseUserActions.length).to.eql(1); + + const firstCaseCreationUserAction = removeServerGeneratedPropertiesFromUserAction( + firstCaseUserActions[0] + ); + + const secondCaseCreationUserAction = removeServerGeneratedPropertiesFromUserAction( + secondCaseUserActions[0] + ); + + expect(firstCaseCreationUserAction).to.eql({ + action: 'create', + type: 'create_case', + created_by: defaultUser, + case_id: firstCase.id, + comment_id: null, + owner: 'securitySolutionFixture', + payload: { + description: firstCase.description, + title: firstCase.title, + tags: firstCase.tags, + connector: firstCase.connector, + settings: firstCase.settings, + owner: firstCase.owner, + status: CaseStatuses.open, + severity: CaseSeverity.LOW, + assignees: [], + category: null, + customFields: [], + }, + }); + + expect(secondCaseCreationUserAction).to.eql({ + action: 'create', + type: 'create_case', + created_by: defaultUser, + case_id: secondCase.id, + comment_id: null, + owner: 'securitySolutionFixture', + payload: { + description: secondCase.description, + title: secondCase.title, + tags: secondCase.tags, + connector: secondCase.connector, + settings: secondCase.settings, + owner: secondCase.owner, + status: CaseStatuses.open, + severity: CaseSeverity.MEDIUM, + assignees: [], + category: null, + customFields: [], + }, + }); + }); + + describe('rbac', () => { + it('returns a 403 when attempting to create a case with an owner that was from a disabled feature in the space', async () => { + const theCase = (await bulkCreateCases({ + superTestService: supertestWithoutAuth, + data: { cases: [getPostCaseRequest({ owner: 'testDisabledFixture' })] }, + expectedHttpCode: 403, + auth: { + user: testDisabled, + space: 'space1', + }, + })) as unknown as { message: string }; + + expect(theCase.message).to.eql( + 'Failed to bulk create cases: Error: Unauthorized to create case with owners: "testDisabledFixture"' + ); + }); + + it('User: security solution only - should create a case', async () => { + const cases = await bulkCreateCases({ + superTestService: supertestWithoutAuth, + data: { cases: [getPostCaseRequest({ owner: 'securitySolutionFixture' })] }, + expectedHttpCode: 200, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + expect(cases.cases[0].owner).to.eql('securitySolutionFixture'); + }); + + it('User: security solution only - should NOT create a case of different owner', async () => { + await bulkCreateCases({ + superTestService: supertestWithoutAuth, + data: { cases: [getPostCaseRequest({ owner: 'observabilityFixture' })] }, + expectedHttpCode: 403, + auth: { + user: secOnly, + space: 'space1', + }, + }); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT create a case`, async () => { + await bulkCreateCases({ + superTestService: supertestWithoutAuth, + data: { cases: [getPostCaseRequest({ owner: 'securitySolutionFixture' })] }, + expectedHttpCode: 403, + auth: { + user, + space: 'space1', + }, + }); + }); + } + + it('should NOT create a case in a space with no permissions', async () => { + await bulkCreateCases({ + superTestService: supertestWithoutAuth, + data: { cases: [getPostCaseRequest({ owner: 'securitySolutionFixture' })] }, + expectedHttpCode: 403, + auth: { + user: secOnly, + space: 'space2', + }, + }); + }); + }); + }); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts index 72d6c093f4bfb..a0aee09f47ed9 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts @@ -10,6 +10,9 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default ({ loadTestFile }: FtrProviderContext): void => { describe('Common', function () { + /** + * Public routes + */ loadTestFile(require.resolve('./client/update_alert_status')); loadTestFile(require.resolve('./comments/delete_comment')); loadTestFile(require.resolve('./comments/delete_comments')); @@ -46,7 +49,6 @@ export default ({ loadTestFile }: FtrProviderContext): void => { /** * Internal routes */ - loadTestFile(require.resolve('./internal/bulk_create_attachments')); loadTestFile(require.resolve('./internal/bulk_get_cases')); loadTestFile(require.resolve('./internal/bulk_get_attachments')); @@ -61,6 +63,11 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./attachments_framework/external_references.ts')); loadTestFile(require.resolve('./attachments_framework/persistable_state.ts')); + /** + * Cases client + */ + loadTestFile(require.resolve('./cases/bulk_create_cases')); + // NOTE: Migrations are not included because they can inadvertently remove the .kibana indices which removes the users and spaces // which causes errors in any tests after them that relies on those });