diff --git a/x-pack/plugins/case/server/client/alerts/update_status.test.ts b/x-pack/plugins/case/server/client/alerts/update_status.test.ts new file mode 100644 index 000000000000..834a72b849f6 --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/update_status.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CaseStatuses } from '../../../common/api'; +import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__'; +import { createCaseClientWithMockSavedObjectsClient } from '../mocks'; + +describe('updateAlertsStatus', () => { + describe('happy path', () => { + test('it update the status of the alert correctly', async () => { + const savedObjectsClient = createMockSavedObjectsRepository(); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + await caseClient.client.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + }); + + expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ + ids: ['alert-id-1'], + index: '.siem-signals', + request: {}, + status: CaseStatuses.closed, + }); + }); + + describe('unhappy path', () => { + test('it throws when missing securitySolutionClient', async () => { + expect.assertions(3); + + const savedObjectsClient = createMockSavedObjectsRepository(); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ + savedObjectsClient, + omitFromContext: ['securitySolution'], + }); + caseClient.client + .updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(404); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index 90116e372888..7c2091fe5e22 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -43,7 +43,7 @@ describe('create', () => { caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.create({ theCase: postCase }); expect(res).toEqual({ @@ -120,7 +120,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.create({ theCase: postCase }); expect(res).toEqual({ @@ -165,7 +165,10 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ + savedObjectsClient, + badAuth: true, + }); const res = await caseClient.client.create({ theCase: postCase }); expect(res).toEqual({ @@ -213,7 +216,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client // @ts-expect-error .create({ theCase: postCase }) @@ -240,7 +243,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client // @ts-expect-error .create({ theCase: postCase }) @@ -267,7 +270,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client // @ts-expect-error .create({ theCase: postCase }) @@ -289,7 +292,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client // @ts-expect-error .create({ theCase: postCase }) @@ -317,7 +320,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client // @ts-expect-error .create({ theCase: postCase }) @@ -349,7 +352,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.create({ theCase: postCase }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); @@ -375,7 +378,7 @@ describe('create', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.create({ theCase: postCase }).catch((e) => { expect(e).not.toBeNull(); diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 1f9e8cc78840..a3ddb5f61a5c 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -9,6 +9,7 @@ import { createMockSavedObjectsRepository, mockCaseNoConnectorId, mockCases, + mockCaseComments, } from '../../routes/api/__fixtures__'; import { createCaseClientWithMockSavedObjectsClient } from '../mocks'; @@ -37,7 +38,7 @@ describe('update', () => { caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update({ caseClient: caseClient.client, cases: patchCases, @@ -120,7 +121,7 @@ describe('update', () => { ], }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update({ caseClient: caseClient.client, cases: patchCases, @@ -156,6 +157,61 @@ describe('update', () => { ]); }); + test('it change the status of case to in-progress correctly', async () => { + const patchCases = { + cases: [ + { + id: 'mock-id-4', + status: CaseStatuses['in-progress'], + version: 'WzUsMV0=', + }, + ], + }; + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); + + expect(res).toEqual([ + { + closed_at: null, + closed_by: null, + comments: [], + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: { + issueType: 'Task', + parent: null, + priority: 'High', + }, + }, + created_at: '2019-11-25T22:32:17.947Z', + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + id: 'mock-id-4', + external_service: null, + status: CaseStatuses['in-progress'], + tags: ['LOLBins'], + title: 'Another bad one', + totalComment: 0, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, + }, + ]); + }); + test('it updates a case without a connector.id', async () => { const patchCases = { cases: [ @@ -171,7 +227,7 @@ describe('update', () => { caseSavedObject: [mockCaseNoConnectorId], }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update({ caseClient: caseClient.client, cases: patchCases, @@ -227,7 +283,7 @@ describe('update', () => { caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.update({ caseClient: caseClient.client, cases: patchCases, @@ -270,6 +326,204 @@ describe('update', () => { }, ]); }); + + test('it updates alert status when the status is updated and syncAlerts=true', async () => { + const patchCases = { + cases: [ + { + id: 'mock-id-1', + status: CaseStatuses.closed, + version: 'WzAsMV0=', + }, + ], + }; + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: [{ ...mockCaseComments[3] }], + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); + + expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ + ids: ['test-id'], + status: 'closed', + }); + }); + + test('it does NOT updates alert status when the status is updated and syncAlerts=false', async () => { + const patchCases = { + cases: [ + { + id: 'mock-id-1', + status: CaseStatuses.closed, + version: 'WzAsMV0=', + }, + ], + }; + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: [ + { + ...mockCases[0], + attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, + }, + ], + caseCommentSavedObject: [{ ...mockCaseComments[3] }], + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); + + expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); + }); + + test('it updates alert status when syncAlerts is turned on', async () => { + const patchCases = { + cases: [ + { + id: 'mock-id-1', + settings: { syncAlerts: true }, + version: 'WzAsMV0=', + }, + ], + }; + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: [ + { + ...mockCases[0], + attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, + }, + ], + caseCommentSavedObject: [{ ...mockCaseComments[3] }], + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); + + expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ + ids: ['test-id'], + status: 'open', + }); + }); + + test('it does NOT updates alert status when syncAlerts is turned off', async () => { + const patchCases = { + cases: [ + { + id: 'mock-id-1', + settings: { syncAlerts: false }, + version: 'WzAsMV0=', + }, + ], + }; + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: [{ ...mockCaseComments[3] }], + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); + + expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); + }); + + test('it updates alert status for multiple cases', async () => { + const patchCases = { + cases: [ + { + id: 'mock-id-1', + settings: { syncAlerts: true }, + version: 'WzAsMV0=', + }, + { + id: 'mock-id-2', + status: CaseStatuses.closed, + version: 'WzQsMV0=', + }, + ], + }; + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: [ + { + ...mockCases[0], + attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, + }, + { + ...mockCases[1], + }, + ], + caseCommentSavedObject: [{ ...mockCaseComments[3] }, { ...mockCaseComments[4] }], + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); + + expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(1, { + ids: ['test-id', 'test-id-2'], + status: 'open', + }); + + expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(2, { + ids: ['test-id', 'test-id-2'], + status: 'closed', + }); + }); + + test('it does NOT call updateAlertsStatus when there is no comments of type alerts', async () => { + const patchCases = { + cases: [ + { + id: 'mock-id-1', + status: CaseStatuses.closed, + version: 'WzAsMV0=', + }, + ], + }; + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); + + expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); + }); }); describe('unhappy path', () => { @@ -293,7 +547,7 @@ describe('update', () => { caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client // @ts-expect-error .update({ cases: patchCases }) @@ -324,7 +578,7 @@ describe('update', () => { caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client // @ts-expect-error .update({ cases: patchCases }) @@ -351,7 +605,7 @@ describe('update', () => { caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); @@ -381,7 +635,7 @@ describe('update', () => { caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); @@ -408,7 +662,7 @@ describe('update', () => { caseSavedObject: mockCases, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index e2b6cb833725..3dc3921c23cf 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -110,7 +110,8 @@ export const update = ({ }; } else if ( updateCaseAttributes.status && - updateCaseAttributes.status === CaseStatuses.open + (updateCaseAttributes.status === CaseStatuses.open || + updateCaseAttributes.status === CaseStatuses['in-progress']) ) { closedInfo = { closed_at: null, @@ -182,11 +183,14 @@ export const update = ({ // The filter guarantees that the comments will be of type alert })) as SavedObjectsFindResponse<{ alertId: string }>; - caseClient.updateAlertsStatus({ - ids: caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId), - // Either there is a status update or the syncAlerts got turned on. - status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, - }); + const commentIds = caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId); + if (commentIds.length > 0) { + caseClient.updateAlertsStatus({ + ids: commentIds, + // Either there is a status update or the syncAlerts got turned on. + status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, + }); + } } const returnUpdatedCase = myCases.saved_objects diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index 40b87f6ad17f..85967d4d79cc 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -29,7 +29,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ caseClient: caseClient.client, caseId: 'mock-id-1', @@ -65,7 +65,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ caseClient: caseClient.client, caseId: 'mock-id-1', @@ -103,7 +103,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ caseClient: caseClient.client, caseId: 'mock-id-1', @@ -127,7 +127,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); await caseClient.client.addComment({ caseClient: caseClient.client, caseId: 'mock-id-1', @@ -175,7 +175,10 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ + savedObjectsClient, + badAuth: true, + }); const res = await caseClient.client.addComment({ caseClient: caseClient.client, caseId: 'mock-id-1', @@ -203,6 +206,66 @@ describe('addComment', () => { version: 'WzksMV0=', }); }); + + test('it update the status of the alert if the case is synced with alerts', async () => { + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ + savedObjectsClient, + badAuth: true, + }); + + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.addComment({ + caseClient: caseClient.client, + caseId: 'mock-id-1', + comment: { + type: CommentType.alert, + alertId: 'test-alert', + index: 'test-index', + }, + }); + + expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ + ids: ['test-alert'], + status: 'open', + }); + }); + + test('it should NOT update the status of the alert if the case is NOT synced with alerts', async () => { + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: [ + { + ...mockCases[0], + attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, + }, + ], + caseCommentSavedObject: mockCaseComments, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ + savedObjectsClient, + badAuth: true, + }); + + caseClient.client.updateAlertsStatus = jest.fn(); + + await caseClient.client.addComment({ + caseClient: caseClient.client, + caseId: 'mock-id-1', + comment: { + type: CommentType.alert, + alertId: 'test-alert', + index: 'test-index', + }, + }); + + expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); + }); }); describe('unhappy path', () => { @@ -213,7 +276,7 @@ describe('addComment', () => { caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ caseId: 'mock-id-1', @@ -235,7 +298,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const allRequestAttributes = { type: CommentType.user, comment: 'a comment', @@ -267,7 +330,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); ['alertId', 'index'].forEach((attribute) => { caseClient.client @@ -296,7 +359,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const allRequestAttributes = { type: CommentType.alert, index: 'test-index', @@ -329,7 +392,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); ['comment'].forEach((attribute) => { caseClient.client @@ -358,7 +421,7 @@ describe('addComment', () => { caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ caseClient: caseClient.client, @@ -382,7 +445,7 @@ describe('addComment', () => { caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ caseClient: caseClient.client, @@ -398,5 +461,31 @@ describe('addComment', () => { expect(e.output.statusCode).toBe(400); }); }); + + test('it throws when the case is closed and the comment is of type alert', async () => { + expect.assertions(3); + + const savedObjectsClient = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + caseClient.client + .addComment({ + caseClient: caseClient.client, + caseId: 'mock-id-4', + comment: { + type: CommentType.alert, + alertId: 'test-alert', + index: 'test-index', + }, + }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }); + }); }); }); diff --git a/x-pack/plugins/case/server/client/configure/get_fields.test.ts b/x-pack/plugins/case/server/client/configure/get_fields.test.ts index b465d916b229..9e39e26440b6 100644 --- a/x-pack/plugins/case/server/client/configure/get_fields.test.ts +++ b/x-pack/plugins/case/server/client/configure/get_fields.test.ts @@ -22,7 +22,7 @@ describe('get_fields', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseMappingsSavedObject: mockCaseMappings, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getFields({ actionsClient: actionsMock, connectorType: ConnectorTypes.jira, @@ -43,7 +43,7 @@ describe('get_fields', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseMappingsSavedObject: mockCaseMappings, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); await caseClient.client .getFields({ actionsClient: { ...actionsMock, execute: jest.fn().mockReturnValue(actionsErrResponse) }, diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts index e68db5cde940..06f24190e256 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts @@ -27,7 +27,7 @@ describe('get_mappings', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseMappingsSavedObject: mockCaseMappings, }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, caseClient: caseClient.client, @@ -41,7 +41,7 @@ describe('get_mappings', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseMappingsSavedObject: [], }); - const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, caseClient: caseClient.client, diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 54af9bee2b31..78cb7f71cef4 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; -import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsClientMock } from '../../../actions/server/mocks'; import { - AlertService, + AlertServiceContract, CaseConfigureService, CaseService, CaseUserActionServiceSetup, @@ -29,17 +30,24 @@ export const createCaseClientMock = (): CaseClientMock => ({ updateAlertsStatus: jest.fn(), }); -export const createCaseClientWithMockSavedObjectsClient = async ( - savedObjectsClient: any, - badAuth: boolean = false -): Promise<{ +export const createCaseClientWithMockSavedObjectsClient = async ({ + savedObjectsClient, + badAuth = false, + omitFromContext = [], +}: { + savedObjectsClient: any; + badAuth?: boolean; + omitFromContext?: string[]; +}): Promise<{ client: CaseClient; - services: { userActionService: jest.Mocked }; + services: { + userActionService: jest.Mocked; + alertsService: jest.Mocked; + }; }> => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); - const esClientMock = elasticsearchServiceMock.createClusterClient(); const request = {} as KibanaRequest; const caseServicePlugin = new CaseService(log); @@ -56,10 +64,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ( postUserActions: jest.fn(), getUserActions: jest.fn(), }; - const alertsService = new AlertService(); - alertsService.initialize(esClientMock); - const context = ({ + const alertsService = { initialize: jest.fn(), updateAlertsStatus: jest.fn() }; + + const context = { core: { savedObjects: { client: savedObjectsClient, @@ -74,7 +82,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ( getSignalsIndex: () => '.siem-signals', }), }, - } as unknown) as RequestHandlerContext; + }; const caseClient = createCaseClient({ savedObjectsClient, @@ -84,10 +92,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ( connectorMappingsService, userActionService, alertsService, - context, + context: (omit(omitFromContext, context) as unknown) as RequestHandlerContext, }); return { client: caseClient, - services: { userActionService }, + services: { userActionService, alertsService }, }; }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 1335d6107744..9010d1bcbe87 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -28,7 +28,7 @@ export const createMockSavedObjectsRepository = ({ caseCommentSavedObject?: any[]; caseConfigureSavedObject?: any[]; caseMappingsSavedObject?: any[]; -}) => { +} = {}) => { const mockSavedObjectsClientContract = ({ bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { return { @@ -100,9 +100,12 @@ export const createMockSavedObjectsRepository = ({ } if ( - findArgs.type === CASE_CONFIGURE_SAVED_OBJECT && - caseConfigureSavedObject[0] && - caseConfigureSavedObject[0].id === 'throw-error-find' + (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT && + caseConfigureSavedObject[0] && + caseConfigureSavedObject[0].id === 'throw-error-find') || + (findArgs.type === CASE_SAVED_OBJECT && + caseSavedObject[0] && + caseSavedObject[0].id === 'throw-error-find') ) { throw SavedObjectsErrorHelpers.createGenericNotFoundError('Error thrown for testing'); } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 0d78bceeaf2f..3d4bc8f76815 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -348,6 +348,38 @@ export const mockCaseComments: Array> = [ updated_at: '2019-11-25T22:32:30.608Z', version: 'WzYsMV0=', }, + { + type: 'cases-comment', + id: 'mock-comment-5', + attributes: { + type: CommentType.alert, + index: 'test-index-2', + alertId: 'test-id-2', + created_at: '2019-11-25T22:32:30.608Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2019-11-25T22:32:30.608Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-4', + }, + ], + updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', + }, ]; export const mockCaseConfigure: Array> = [ diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index ca6598fcb288..1dd2c1332868 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -104,7 +104,7 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments).toHaveLength(4); + expect(response.payload.comments).toHaveLength(5); }); it(`returns an error when thrown from getAllCaseComments`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts new file mode 100644 index 000000000000..a5fe5bb3695a --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../../__fixtures__'; +import { initGetCasesStatusApi } from './get_status'; +import { CASE_STATUS_URL } from '../../../../../common/constants'; + +describe('GET status', () => { + let routeHandler: RequestHandler; + const findArgs = { + fields: [], + page: 1, + perPage: 1, + type: 'cases', + }; + + beforeAll(async () => { + routeHandler = await createRoute(initGetCasesStatusApi, 'get'); + }); + + it(`returns the status`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_STATUS_URL, + method: 'get', + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { + ...findArgs, + filter: 'cases.attributes.status: open', + }); + + expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { + ...findArgs, + filter: 'cases.attributes.status: in-progress', + }); + + expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { + ...findArgs, + filter: 'cases.attributes.status: closed', + }); + + expect(response.payload).toEqual({ + count_open_cases: 4, + count_in_progress_cases: 4, + count_closed_cases: 4, + }); + }); + + it(`returns an error when findCases throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_STATUS_URL, + method: 'get', + }); + + const theContext = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [{ ...mockCases[0], id: 'throw-error-find' }], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); +}); diff --git a/x-pack/plugins/case/server/services/alerts/index.test.ts b/x-pack/plugins/case/server/services/alerts/index.test.ts new file mode 100644 index 000000000000..c0edf4516d3f --- /dev/null +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from 'kibana/server'; +import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; +import { CaseStatuses } from '../../../common/api'; +import { AlertService, AlertServiceContract } from '.'; + +describe('updateAlertsStatus', () => { + const esClientMock = elasticsearchServiceMock.createClusterClient(); + + describe('happy path', () => { + let alertService: AlertServiceContract; + const args = { + ids: ['alert-id-1'], + index: '.siem-signals', + request: {} as KibanaRequest, + status: CaseStatuses.closed, + }; + + beforeEach(async () => { + alertService = new AlertService(); + jest.restoreAllMocks(); + }); + + test('it update the status of the alert correctly', async () => { + alertService.initialize(esClientMock); + await alertService.updateAlertsStatus(args); + + expect(esClientMock.asScoped().asCurrentUser.updateByQuery).toHaveBeenCalledWith({ + body: { + query: { ids: { values: args.ids } }, + script: { lang: 'painless', source: `ctx._source.signal.status = '${args.status}'` }, + }, + conflicts: 'abort', + ignore_unavailable: true, + index: args.index, + }); + }); + + describe('unhappy path', () => { + test('it throws when service is already initialized', async () => { + alertService.initialize(esClientMock); + expect(() => { + alertService.initialize(esClientMock); + }).toThrow(); + }); + + test('it throws when service is not initialized and try to update the status', async () => { + await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx index e656c98d3657..cbb87912060b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx @@ -7,14 +7,15 @@ import React from 'react'; import { mount } from 'enzyme'; import { waitFor, act } from '@testing-library/react'; +import { noop } from 'lodash/fp'; -import { AddComment, AddCommentRefObject } from '.'; import { TestProviders } from '../../../common/mock'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { CommentRequest, CommentType } from '../../../../../case/common/api'; import { useInsertTimeline } from '../use_insert_timeline'; import { usePostComment } from '../../containers/use_post_comment'; +import { AddComment, AddCommentRefObject } from '.'; jest.mock('../../containers/use_post_comment'); jest.mock('../use_insert_timeline'); @@ -34,7 +35,7 @@ const addCommentProps = { showLoading: false, }; -const defaultPostCommment = { +const defaultPostComment = { isLoading: false, isError: false, postComment, @@ -48,7 +49,7 @@ const sampleData: CommentRequest = { describe('AddComment ', () => { beforeEach(() => { jest.resetAllMocks(); - usePostCommentMock.mockImplementation(() => defaultPostCommment); + usePostCommentMock.mockImplementation(() => defaultPostComment); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); }); @@ -83,7 +84,7 @@ describe('AddComment ', () => { }); it('should render spinner and disable submit when loading', () => { - usePostCommentMock.mockImplementation(() => ({ ...defaultPostCommment, isLoading: true })); + usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); const wrapper = mount( @@ -99,7 +100,7 @@ describe('AddComment ', () => { }); it('should disable submit button when disabled prop passed', () => { - usePostCommentMock.mockImplementation(() => ({ ...defaultPostCommment, isLoading: true })); + usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); const wrapper = mount( @@ -141,8 +142,9 @@ describe('AddComment ', () => { }); it('it should insert a timeline', async () => { + let attachTimeline = noop; useInsertTimelineMock.mockImplementation((comment, onTimelineAttached) => { - onTimelineAttached(`[title](url)`); + attachTimeline = onTimelineAttached; }); const wrapper = mount( @@ -153,6 +155,10 @@ describe('AddComment ', () => { ); + act(() => { + attachTimeline('[title](url)'); + }); + await waitFor(() => { expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('[title](url)'); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 78bb3a8d2f2f..71fd74570c16 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -9,9 +9,8 @@ import { mount } from 'enzyme'; import moment from 'moment-timezone'; import { waitFor } from '@testing-library/react'; import '../../../common/mock/match_media'; -import { AllCases } from '.'; import { TestProviders } from '../../../common/mock'; -import { useGetCasesMockState } from '../../containers/mock'; +import { casesStatus, useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; import { CaseStatuses } from '../../../../../case/common/api'; @@ -22,6 +21,7 @@ import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useUpdateCases } from '../../containers/use_bulk_update_case'; import { getCasesColumns } from './columns'; +import { AllCases } from '.'; jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); @@ -61,6 +61,7 @@ describe('AllCases', () => { setQueryParams, setSelectedCases, }; + const defaultDeleteCases = { dispatchResetIsDeleted, handleOnDeleteConfirm, @@ -69,13 +70,14 @@ describe('AllCases', () => { isDisplayConfirmDeleteModal: false, isLoading: false, }; + const defaultCasesStatus = { - countClosedCases: 0, - countOpenCases: 5, + ...casesStatus, fetchCasesStatus, isError: false, - isLoading: true, + isLoading: false, }; + const defaultUpdateCases = { isUpdated: false, isLoading: false, @@ -103,6 +105,7 @@ describe('AllCases', () => { ); + await waitFor(() => { expect(wrapper.find(`a[data-test-subj="case-details-link"]`).first().prop('href')).toEqual( `/${useGetCasesMockState.data.cases[0].id}` @@ -128,6 +131,63 @@ describe('AllCases', () => { ); }); }); + + it('should render the stats', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="openStatsHeader"]').exists()).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="openStatsHeader"] .euiDescriptionList__description') + .first() + .text() + ).toBe('20'); + + expect(wrapper.find('[data-test-subj="inProgressStatsHeader"]').exists()).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="inProgressStatsHeader"] .euiDescriptionList__description') + .first() + .text() + ).toBe('40'); + + expect(wrapper.find('[data-test-subj="closedStatsHeader"]').exists()).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="closedStatsHeader"] .euiDescriptionList__description') + .first() + .text() + ).toBe('130'); + }); + }); + + it('should render the loading spinner when loading stats', async () => { + useGetCasesStatusMock.mockReturnValue({ ...defaultCasesStatus, isLoading: true }); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="openStatsHeader-loading-spinner"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="inProgressStatsHeader-loading-spinner"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="closedStatsHeader-loading-spinner"]').exists() + ).toBeTruthy(); + }); + }); + it('should render empty fields', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, @@ -199,6 +259,7 @@ describe('AllCases', () => { }); }); }); + it('closes case when row action icon clicked', async () => { const wrapper = mount( @@ -217,6 +278,7 @@ describe('AllCases', () => { }); }); }); + it('opens case when row action icon clicked', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, @@ -240,6 +302,7 @@ describe('AllCases', () => { }); }); }); + it('Bulk delete', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, @@ -277,6 +340,7 @@ describe('AllCases', () => { ); }); }); + it('Bulk close status update', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, @@ -294,6 +358,7 @@ describe('AllCases', () => { expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.closed); }); }); + it('Bulk open status update', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, @@ -315,6 +380,7 @@ describe('AllCases', () => { expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.open); }); }); + it('isDeleted is true, refetch', async () => { useDeleteCasesMock.mockReturnValue({ ...defaultDeleteCases, @@ -492,4 +558,73 @@ describe('AllCases', () => { expect(onRowClick).not.toHaveBeenCalled(); }); }); + + it('should change the status to closed', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click'); + expect(setQueryParams).toBeCalledWith({ + sortField: 'closedAt', + }); + }); + }); + + it('should change the status to in-progress', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').simulate('click'); + expect(setQueryParams).toBeCalledWith({ + sortField: 'updatedAt', + }); + }); + }); + + it('should change the status to open', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + wrapper.find('button[data-test-subj="case-status-filter-open"]').simulate('click'); + expect(setQueryParams).toBeCalledWith({ + sortField: 'createdAt', + }); + }); + }); + + it('should show the correct count on stats', async () => { + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + expect(wrapper.find('button[data-test-subj="case-status-filter-open"]').text()).toBe( + 'Open (20)' + ); + expect(wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').text()).toBe( + 'In progress (40)' + ); + expect(wrapper.find('button[data-test-subj="case-status-filter-closed"]').text()).toBe( + 'Closed (130)' + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx new file mode 100644 index 000000000000..e68ead14eaee --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; + +import { CaseStatuses } from '../../../../../case/common/api'; +import { StatusFilter } from './status_filter'; + +const stats = { + [CaseStatuses.open]: 2, + [CaseStatuses['in-progress']]: 5, + [CaseStatuses.closed]: 7, +}; + +describe('StatusFilter', () => { + const onStatusChanged = jest.fn(); + const defaultProps = { + selectedStatus: CaseStatuses.open, + onStatusChanged, + stats, + }; + + it('should render', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="case-status-filter"]').exists()).toBeTruthy(); + }); + + it('should call onStatusChanged when changing status to open', async () => { + const wrapper = mount(); + + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + wrapper.find('button[data-test-subj="case-status-filter-open"]').simulate('click'); + await waitFor(() => { + expect(onStatusChanged).toBeCalledWith('open'); + }); + }); + + it('should call onStatusChanged when changing status to in-progress', async () => { + const wrapper = mount(); + + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').simulate('click'); + await waitFor(() => { + expect(onStatusChanged).toBeCalledWith('in-progress'); + }); + }); + + it('should call onStatusChanged when changing status to closed', async () => { + const wrapper = mount(); + + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click'); + await waitFor(() => { + expect(onStatusChanged).toBeCalledWith('closed'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/actions.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.test.tsx similarity index 91% rename from x-pack/plugins/security_solution/public/cases/components/case_view/actions.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.test.tsx index 86ec7d79a3f7..2cfa237e8c59 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/actions.test.tsx @@ -10,8 +10,8 @@ import { mount } from 'enzyme'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { TestProviders } from '../../../common/mock'; import { basicCase, basicPush } from '../../containers/mock'; -import { CaseViewActions } from './actions'; -import * as i18n from './translations'; +import { Actions } from './actions'; +import * as i18n from '../case_view/translations'; jest.mock('../../containers/use_delete_cases'); const useDeleteCasesMock = useDeleteCases as jest.Mock; @@ -39,14 +39,16 @@ describe('CaseView actions', () => { isDeleted: false, isDisplayConfirmDeleteModal: false, }; + beforeEach(() => { jest.resetAllMocks(); useDeleteCasesMock.mockImplementation(() => defaultDeleteState); }); + it('clicking trash toggles modal', () => { const wrapper = mount( - + ); @@ -56,6 +58,7 @@ describe('CaseView actions', () => { wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); expect(handleToggleModal).toHaveBeenCalled(); }); + it('toggle delete modal and confirm', () => { useDeleteCasesMock.mockImplementation(() => ({ ...defaultDeleteState, @@ -63,7 +66,7 @@ describe('CaseView actions', () => { })); const wrapper = mount( - + ); @@ -73,10 +76,11 @@ describe('CaseView actions', () => { { id: basicCase.id, title: basicCase.title }, ]); }); + it('displays active incident link', () => { const wrapper = mount( - = ({ +const ActionsComponent: React.FC = ({ caseData, currentExternalIncident, disabled = false, @@ -80,4 +80,4 @@ const CaseViewActionsComponent: React.FC = ({ ); }; -export const CaseViewActions = React.memo(CaseViewActionsComponent); +export const Actions = React.memo(ActionsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts new file mode 100644 index 000000000000..c8893ceaaafb --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/helpers.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CaseStatuses } from '../../../../../case/common/api'; +import { basicCase } from '../../containers/mock'; +import { getStatusDate, getStatusTitle } from './helpers'; + +describe('helpers', () => { + const caseData = { + ...basicCase, + status: CaseStatuses.open, + createdAt: 'createAt', + updatedAt: 'updatedAt', + closedAt: 'closedAt', + }; + + describe('getStatusDate', () => { + it('it return the createdAt when the status is open', () => { + expect(getStatusDate(caseData)).toBe(caseData.createdAt); + }); + + it('it return the createdAt when the status is in-progress', () => { + expect(getStatusDate({ ...caseData, status: CaseStatuses['in-progress'] })).toBe( + caseData.updatedAt + ); + }); + + it('it return the createdAt when the status is closed', () => { + expect(getStatusDate({ ...caseData, status: CaseStatuses.closed })).toBe(caseData.closedAt); + }); + }); + + describe('getStatusTitle', () => { + it('it return the correct title for open status', () => { + expect(getStatusTitle(CaseStatuses.open)).toBe('Case opened'); + }); + + it('it return the correct title for in-progress status', () => { + expect(getStatusTitle(CaseStatuses['in-progress'])).toBe('Case in progress'); + }); + + it('it return the correct title for closed status', () => { + expect(getStatusTitle(CaseStatuses.closed)).toBe('Case closed'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.test.tsx new file mode 100644 index 000000000000..f441e936b139 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { basicCase } from '../../containers/mock'; +import { CaseActionBar } from '.'; +import { TestProviders } from '../../../common/mock'; + +describe('CaseActionBar', () => { + const onRefresh = jest.fn(); + const onUpdateField = jest.fn(); + const defaultProps = { + caseData: basicCase, + isLoading: false, + onRefresh, + onUpdateField, + currentExternalIncident: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('it renders', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="case-view-status"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="sync-alerts-switch"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="case-refresh"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="case-view-actions"]`).exists()).toBeTruthy(); + }); + + it('it should show correct status', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toBe( + 'Open' + ); + }); + + it('it should show the correct date', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).prop('value')).toBe( + basicCase.createdAt + ); + }); + + it('it call onRefresh', () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="case-refresh"]`).first().simulate('click'); + expect(onRefresh).toHaveBeenCalled(); + }); + + it('it should call onUpdateField when changing status', () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); + wrapper + .find(`[data-test-subj="case-view-status-dropdown-in-progress"] button`) + .simulate('click'); + + expect(onUpdateField).toHaveBeenCalledWith({ key: 'status', value: 'in-progress' }); + }); + + it('it should call onUpdateField when changing syncAlerts setting', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click'); + + expect(onUpdateField).toHaveBeenCalledWith({ + key: 'settings', + value: { + syncAlerts: false, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 3a65ea724d76..dd6d6a18364b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -18,7 +18,7 @@ import { import { CaseStatuses } from '../../../../../case/common/api'; import * as i18n from '../case_view/translations'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; -import { CaseViewActions } from '../case_view/actions'; +import { Actions } from './actions'; import { Case } from '../../containers/types'; import { CaseService } from '../../containers/use_get_case_user_actions'; import { StatusContextMenu } from './status_context_menu'; @@ -124,8 +124,8 @@ const CaseActionBarComponent: React.FC = ({ {i18n.CASE_REFRESH} - - + { + const onStatusChanged = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('it renders', async () => { + const wrapper = mount( + + ); + + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeTruthy(); + }); + + it('it renders the current status correctly', async () => { + const wrapper = mount( + + ); + + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toBe( + 'Closed' + ); + }); + + it('it changes the status', async () => { + const wrapper = mount( + + ); + + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); + wrapper + .find(`[data-test-subj="case-view-status-dropdown-in-progress"] button`) + .simulate('click'); + + expect(onStatusChanged).toHaveBeenCalledWith('in-progress'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.test.tsx new file mode 100644 index 000000000000..e20fab150aa9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; + +import { SyncAlertsSwitch } from './sync_alerts_switch'; + +describe('SyncAlertsSwitch', () => { + it('it renders', async () => { + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="sync-alerts-switch"]`).exists()).toBeTruthy(); + }); + + it('it toggles the switch', async () => { + const wrapper = mount(); + + wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click'); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="sync-alerts-switch"]').first().prop('checked')).toBe( + false + ); + }); + }); + + it('it disables the switch', async () => { + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="sync-alerts-switch"]`).first().prop('disabled')).toBe( + true + ); + }); + + it('it start as off', async () => { + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="sync-alerts-switch"]`).first().text()).toBe('Off'); + }); + + it('it shows the correct labels', async () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sync-alerts-switch"]').first().text()).toBe('On'); + wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click'); + + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="sync-alerts-switch"]`).first().text()).toBe('Off'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx index ab91f2ae8cdf..e66419b10781 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx @@ -39,6 +39,7 @@ const SyncAlertsSwitchComponent: React.FC = ({ checked={isOn} onChange={onChange} disabled={disabled} + data-test-subj="sync-alerts-switch" /> ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx new file mode 100644 index 000000000000..cfcfa412c79c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CommentType } from '../../../../../case/common/api'; +import { Comment } from '../../containers/types'; + +import { getRuleIdsFromComments, buildAlertsQuery } from './helpers'; + +const comments: Comment[] = [ + { + type: CommentType.alert, + alertId: 'alert-id-1', + index: 'alert-index-1', + id: 'comment-id', + createdAt: '2020-02-19T23:06:33.798Z', + createdBy: { username: 'elastic' }, + pushedAt: null, + pushedBy: null, + updatedAt: null, + updatedBy: null, + version: 'WzQ3LDFc', + }, + { + type: CommentType.alert, + alertId: 'alert-id-2', + index: 'alert-index-2', + id: 'comment-id', + createdAt: '2020-02-19T23:06:33.798Z', + createdBy: { username: 'elastic' }, + pushedAt: null, + pushedBy: null, + updatedAt: null, + updatedBy: null, + version: 'WzQ3LDFc', + }, +]; + +describe('Case view helpers', () => { + describe('getRuleIdsFromComments', () => { + it('it returns the rules ids from the comments', () => { + expect(getRuleIdsFromComments(comments)).toEqual(['alert-id-1', 'alert-id-2']); + }); + }); + + describe('buildAlertsQuery', () => { + it('it builds the alerts query', () => { + expect(buildAlertsQuery(['alert-id-1', 'alert-id-2'])).toEqual({ + query: { + bool: { + filter: { + bool: { + should: [{ match: { _id: 'alert-id-1' } }, { match: { _id: 'alert-id-2' } }], + minimum_should_match: 1, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 34b71dd301d1..c64cb2087252 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -10,7 +10,13 @@ import { mount } from 'enzyme'; import '../../../common/mock/match_media'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { CaseComponent, CaseProps, CaseView } from '.'; -import { basicCase, basicCaseClosed, caseUserActions } from '../../containers/mock'; +import { + basicCase, + basicCaseClosed, + caseUserActions, + alertComment, + getAlertUserAction, +} from '../../containers/mock'; import { TestProviders } from '../../../common/mock'; import { useUpdateCase } from '../../containers/use_update_case'; import { useGetCase } from '../../containers/use_get_case'; @@ -51,6 +57,7 @@ export const caseProps: CaseProps = { userCanCrud: true, caseData: { ...basicCase, + comments: [...basicCase.comments, alertComment], connector: { id: 'resilient-2', name: 'Resilient', @@ -67,6 +74,33 @@ export const caseClosedProps: CaseProps = { caseData: basicCaseClosed, }; +const alertsHit = [ + { + _id: 'alert-id-1', + _index: 'alert-index-1', + _source: { + signal: { + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, + }, + }, + }, + { + _id: 'alert-id-2', + _index: 'alert-index-2', + _source: { + signal: { + rule: { + id: 'rule-id-2', + name: 'Awesome rule 2', + }, + }, + }, + }, +]; + describe('CaseView ', () => { const updateCaseProperty = jest.fn(); const fetchCaseUserActions = jest.fn(); @@ -91,7 +125,7 @@ describe('CaseView ', () => { }; const defaultUseGetCaseUserActions = { - caseUserActions, + caseUserActions: [...caseUserActions, getAlertUserAction()], caseServices: {}, fetchCaseUserActions, firstIndexPushToService: -1, @@ -103,6 +137,7 @@ describe('CaseView ', () => { }; beforeEach(() => { + jest.clearAllMocks(); jest.resetAllMocks(); useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); @@ -111,8 +146,8 @@ describe('CaseView ', () => { usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService })); useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, isLoading: false })); useQueryAlertsMock.mockImplementation(() => ({ - isLoading: false, - alerts: { hits: { hists: [] } }, + loading: false, + data: { hits: { hits: alertsHit } }, })); }); @@ -124,6 +159,7 @@ describe('CaseView ', () => { ); + await waitFor(() => { expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( data.title @@ -188,7 +224,7 @@ describe('CaseView ', () => { }); }); - it('should dispatch update state when status is changed', async () => { + it('should update status', async () => { const wrapper = mount( @@ -204,7 +240,11 @@ describe('CaseView ', () => { .find('button[data-test-subj="case-view-status-dropdown-closed"]') .first() .simulate('click'); - expect(updateCaseProperty).toHaveBeenCalled(); + + wrapper.update(); + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('status'); + expect(updateObject.updateValue).toEqual('closed'); }); }); @@ -579,4 +619,90 @@ describe('CaseView ', () => { }); }); }); + + it('should show loading content when loading alerts', async () => { + useQueryAlertsMock.mockImplementation(() => ({ + loading: true, + data: { hits: { hits: [] } }, + })); + + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="case-view-loading-content"]').first().exists() + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="user-actions"]').first().exists()).toBeFalsy(); + }); + }); + + it('should open the alert flyout', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper + .find('[data-test-subj="comment-action-show-alert-alert-action-id"] button') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'x-pack/security_solution/local/timeline/TOGGLE_EXPANDED_EVENT', + payload: { + event: { eventId: 'alert-id-1', indexName: 'alert-index-1' }, + timelineId: 'timeline-case', + }, + }); + }); + }); + + it('should show the rule name', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect( + wrapper + .find( + '[data-test-subj="comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent' + ) + .first() + .text() + ).toBe('added an alert from Awesome rule'); + }); + }); + + it('should update settings', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click'); + + wrapper.update(); + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('settings'); + expect(updateObject.updateValue).toEqual({ syncAlerts: false }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 56bec02b6e6c..8d5201e68371 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -429,7 +429,9 @@ export const CaseComponent = React.memo( {!initLoadingData && pushCallouts != null && pushCallouts} - {initLoadingData && } + {initLoadingData && ( + + )} {!initLoadingData && ( <> { return { @@ -70,8 +71,12 @@ describe('Connector', () => { let globalForm: FormHook; const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm<{ connectorId: string; fields: Record | null }>({ + const { form } = useForm({ defaultValue: { connectorId: connectorsMock[0].id, fields: null }, + schema: { + connectorId: schema.connectorId, + fields: schema.fields, + }, }); globalForm = form; @@ -96,7 +101,14 @@ describe('Connector', () => { expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); expect(wrapper.find(`[data-test-subj="connector-settings"]`).exists()).toBeTruthy(); - waitFor(() => { + await waitFor(() => { + expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe( + 'My Connector' + ); + }); + + await waitFor(() => { + wrapper.update(); expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/description.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/description.test.tsx index 201a61febc62..7522623f8098 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/description.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/description.test.tsx @@ -10,13 +10,17 @@ import { act } from '@testing-library/react'; import { useForm, Form, FormHook } from '../../../shared_imports'; import { Description } from './description'; +import { schema, FormProps } from './schema'; describe('Description', () => { let globalForm: FormHook; const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm<{ description: string }>({ + const { form } = useForm({ defaultValue: { description: 'My description' }, + schema: { + description: schema.description, + }, }); globalForm = form; @@ -41,7 +45,7 @@ describe('Description', () => { it('it changes the description', async () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx index 3091e6b33d33..3a0172708883 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx @@ -6,8 +6,9 @@ import React from 'react'; import { mount } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; -import { useForm, Form } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../../shared_imports'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/mock'; @@ -29,6 +30,7 @@ const initialCaseValue: FormProps = { }; describe('CreateCaseForm', () => { + let globalForm: FormHook; const MockHookWrapperComponent: React.FC = ({ children }) => { const { form } = useForm({ defaultValue: initialCaseValue, @@ -36,6 +38,8 @@ describe('CreateCaseForm', () => { schema, }); + globalForm = form; + return
{children}
; }; @@ -64,4 +68,41 @@ describe('CreateCaseForm', () => { expect(wrapper.find(`[data-test-subj="case-creation-form-steps"]`).exists()).toBeFalsy(); }); + + it('it renders all form fields', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="caseTitle"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseTags"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseDescription"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); + }); + + it('should render spinner when loading', async () => { + const wrapper = mount( + + + + ); + + await act(async () => { + globalForm.setFieldValue('title', 'title'); + globalForm.setFieldValue('description', 'description'); + globalForm.submit(); + // For some weird reason this is needed to pass the test. + // It does not do anything useful + await wrapper.find(`[data-test-subj="caseTitle"]`); + await wrapper.update(); + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists() + ).toBeTruthy(); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx new file mode 100644 index 000000000000..f3b47f756bce --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx @@ -0,0 +1,420 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { act, waitFor } from '@testing-library/react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { ConnectorTypes } from '../../../../../case/common/api'; +import { TestProviders } from '../../../common/mock'; +import { usePostCase } from '../../containers/use_post_case'; +import { useGetTags } from '../../containers/use_get_tags'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { connectorsMock } from '../../containers/configure/mock'; +import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types'; +import { useGetSeverity } from '../settings/resilient/use_get_severity'; +import { useGetIssueTypes } from '../settings/jira/use_get_issue_types'; +import { useGetFieldsByIssueType } from '../settings/jira/use_get_fields_by_issue_type'; +import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { + sampleConnectorData, + sampleData, + sampleTags, + useGetIncidentTypesResponse, + useGetSeverityResponse, + useGetIssueTypesResponse, + useGetFieldsByIssueTypeResponse, +} from './mock'; +import { FormContext } from './form_context'; +import { CreateCaseForm } from './form'; +import { SubmitCaseButton } from './submit_button'; + +jest.mock('../../containers/use_post_case'); +jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../../containers/configure/use_configure'); +jest.mock('../settings/resilient/use_get_incident_types'); +jest.mock('../settings/resilient/use_get_severity'); +jest.mock('../settings/jira/use_get_issue_types'); +jest.mock('../settings/jira/use_get_fields_by_issue_type'); +jest.mock('../settings/jira/use_get_single_issue'); +jest.mock('../settings/jira/use_get_issues'); + +const useConnectorsMock = useConnectors as jest.Mock; +const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const usePostCaseMock = usePostCase as jest.Mock; +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; +const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; +const postCase = jest.fn(); + +const defaultPostCase = { + isLoading: false, + isError: false, + caseData: null, + postCase, +}; + +const fillForm = (wrapper: ReactWrapper) => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: sampleData.title } }); + + wrapper + .find(`[data-test-subj="caseDescription"] textarea`) + .first() + .simulate('change', { target: { value: sampleData.description } }); + + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange(sampleTags.map((tag) => ({ label: tag }))); + }); +}; + +describe('Create case', () => { + const fetchTags = jest.fn(); + const onFormSubmitSuccess = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + usePostCaseMock.mockImplementation(() => defaultPostCase); + useConnectorsMock.mockReturnValue(sampleConnectorData); + useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + + (useGetTags as jest.Mock).mockImplementation(() => ({ + tags: sampleTags, + fetchTags, + })); + }); + + describe('Step 1 - Case Fields', () => { + it('it renders', async () => { + const wrapper = mount( + + + + + + + ); + + expect(wrapper.find(`[data-test-subj="caseTitle"]`).first().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseDescription"]`).first().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseTags"]`).first().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="caseConnectors"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="case-creation-form-steps"]`).first().exists() + ).toBeTruthy(); + }); + + it('should post case on submit click', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); + }); + + it('should toggle sync settings', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => + expect(postCase).toBeCalledWith({ ...sampleData, settings: { syncAlerts: false } }) + ); + }); + + it('should redirect to new case when caseData is there', async () => { + const sampleId = 'case-id'; + usePostCaseMock.mockImplementation(() => ({ + ...defaultPostCase, + caseData: { id: sampleId }, + })); + + mount( + + + + + + + ); + + await waitFor(() => expect(onFormSubmitSuccess).toHaveBeenCalledWith({ id: 'case-id' })); + }); + + it('it should select the default connector set in the configuration', async () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.servicenow, + fields: null, + }, + persistLoading: false, + })); + + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + await act(async () => { + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + }); + + await waitFor(() => + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + fields: { + impact: null, + severity: null, + urgency: null, + }, + id: 'servicenow-1', + name: 'My Connector', + type: '.servicenow', + }, + }) + ); + }); + + it('it should default to none if the default connector does not exist in connectors', async () => { + useCaseConfigureMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + connector: { + id: 'not-exist', + name: 'SN', + type: ConnectorTypes.servicenow, + fields: null, + }, + persistLoading: false, + })); + + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); + }); + }); + + describe('Step 2 - Connector Fields', () => { + it(`it should submit a Jira connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeTruthy(); + }); + + wrapper + .find('select[data-test-subj="issueTypeSelect"]') + .first() + .simulate('change', { + target: { value: '10007' }, + }); + + wrapper + .find('select[data-test-subj="prioritySelect"]') + .first() + .simulate('change', { + target: { value: '2' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'jira-1', + name: 'Jira', + type: '.jira', + fields: { issueType: '10007', parent: null, priority: '2' }, + }, + }) + ); + }); + + it(`it should submit a resilient connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect( + wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists() + ).toBeTruthy(); + }); + + act(() => { + ((wrapper.find(EuiComboBox).at(1).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ value: '19', label: 'Denial of Service' }]); + }); + + wrapper + .find('select[data-test-subj="severitySelect"]') + .first() + .simulate('change', { + target: { value: '4' }, + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'resilient-2', + name: 'My Connector 2', + type: '.resilient', + fields: { incidentTypes: ['19'], severityCode: '4' }, + }, + }) + ); + }); + + it(`it should submit a servicenow connector`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); + expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy(); + + ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { + wrapper + .find(`select[data-test-subj="${subj}"]`) + .first() + .simulate('change', { + target: { value: '2' }, + }); + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => + expect(postCase).toBeCalledWith({ + ...sampleData, + connector: { + id: 'servicenow-1', + name: 'My Connector', + type: '.servicenow', + fields: { impact: '2', severity: '2', urgency: '2' }, + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 3122b1a60203..e1cf2cb35222 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -7,25 +7,32 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; +import { noop } from 'lodash/fp'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { CasePostRequest } from '../../../../../case/common/api'; import { TestProviders } from '../../../common/mock'; -import { usePostCase } from '../../containers/use_post_case'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { connectorsMock } from '../../containers/configure/mock'; -import { ConnectorTypes } from '../../../../../case/common/api/connectors'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types'; import { useGetSeverity } from '../settings/resilient/use_get_severity'; import { useGetIssueTypes } from '../settings/jira/use_get_issue_types'; import { useGetFieldsByIssueType } from '../settings/jira/use_get_fields_by_issue_type'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { useInsertTimeline } from '../use_insert_timeline'; +import { + sampleConnectorData, + sampleData, + sampleTags, + useGetIncidentTypesResponse, + useGetSeverityResponse, + useGetIssueTypesResponse, + useGetFieldsByIssueTypeResponse, +} from './mock'; import { Create } from '.'; -jest.mock('../../containers/use_post_case'); +jest.mock('../../containers/api'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); @@ -35,125 +42,30 @@ jest.mock('../settings/jira/use_get_issue_types'); jest.mock('../settings/jira/use_get_fields_by_issue_type'); jest.mock('../settings/jira/use_get_single_issue'); jest.mock('../settings/jira/use_get_issues'); +jest.mock('../use_insert_timeline'); const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; -const usePostCaseMock = usePostCase as jest.Mock; +const useGetTagsMock = useGetTags as jest.Mock; const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; -const postCase = jest.fn(); +const useInsertTimelineMock = useInsertTimeline as jest.Mock; +const fetchTags = jest.fn(); -const sampleTags = ['coke', 'pepsi']; -const sampleData: CasePostRequest = { - description: 'what a great description', - tags: sampleTags, - title: 'what a cool title', - connector: { - fields: null, - id: 'none', - name: 'none', - type: ConnectorTypes.none, - }, - settings: { - syncAlerts: true, - }, -}; +const fillForm = (wrapper: ReactWrapper) => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: sampleData.title } }); -const defaultPostCase = { - isLoading: false, - isError: false, - caseData: null, - postCase, -}; + wrapper + .find(`[data-test-subj="caseDescription"] textarea`) + .first() + .simulate('change', { target: { value: sampleData.description } }); -const sampleConnectorData = { loading: false, connectors: [] }; - -const useGetIncidentTypesResponse = { - isLoading: false, - incidentTypes: [ - { - id: 19, - name: 'Malware', - }, - { - id: 21, - name: 'Denial of Service', - }, - ], -}; - -const useGetSeverityResponse = { - isLoading: false, - severity: [ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, - ], -}; - -const useGetIssueTypesResponse = { - isLoading: false, - issueTypes: [ - { - id: '10006', - name: 'Task', - }, - { - id: '10007', - name: 'Bug', - }, - ], -}; - -const useGetFieldsByIssueTypeResponse = { - isLoading: false, - fields: { - summary: { allowedValues: [], defaultValue: {} }, - labels: { allowedValues: [], defaultValue: {} }, - description: { allowedValues: [], defaultValue: {} }, - priority: { - allowedValues: [ - { - name: 'Medium', - id: '3', - }, - { - name: 'Low', - id: '2', - }, - ], - defaultValue: { name: 'Medium', id: '3' }, - }, - }, -}; - -const fillForm = async (wrapper: ReactWrapper) => { - await act(async () => { - wrapper - .find(`[data-test-subj="caseTitle"] input`) - .first() - .simulate('change', { target: { value: sampleData.title } }); - }); - - await act(async () => { - wrapper - .find(`[data-test-subj="caseDescription"] textarea`) - .first() - .simulate('change', { target: { value: sampleData.description } }); - }); - - await waitFor(() => { + act(() => { ((wrapper.find(EuiComboBox).props() as unknown) as { onChange: (a: EuiComboBoxOptionOption[]) => void; }).onChange(sampleTags.map((tag) => ({ label: tag }))); @@ -161,381 +73,83 @@ const fillForm = async (wrapper: ReactWrapper) => { }; describe('Create case', () => { - const fetchTags = jest.fn(); beforeEach(() => { jest.resetAllMocks(); - usePostCaseMock.mockImplementation(() => defaultPostCase); + jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); useConnectorsMock.mockReturnValue(sampleConnectorData); useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); - - jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); - (useGetTags as jest.Mock).mockImplementation(() => ({ + useGetTagsMock.mockImplementation(() => ({ tags: sampleTags, fetchTags, })); }); - describe('Step 1 - Case Fields', () => { - it('it renders', async () => { - const wrapper = mount( - - - - - - ); + it('it renders', async () => { + const wrapper = mount( + + + + + + ); - expect(wrapper.find(`[data-test-subj="caseTitle"]`).first().exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseDescription"]`).first().exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseTags"]`).first().exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="caseConnectors"]`).first().exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="create-case-submit"]`).first().exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).first().exists()).toBeTruthy(); - expect( - wrapper.find(`[data-test-subj="case-creation-form-steps"]`).first().exists() - ).toBeTruthy(); - }); - - it('should post case on submit click', async () => { - useConnectorsMock.mockReturnValue({ - ...sampleConnectorData, - connectors: connectorsMock, - }); - - const wrapper = mount( - - - - - - ); - - await fillForm(wrapper); - wrapper.update(); - - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - }); - await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); - }); - - it('should redirect to all cases on cancel click', async () => { - const wrapper = mount( - - - - - - ); - - wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click'); - await waitFor(() => expect(mockHistory.push).toHaveBeenCalledWith('/')); - }); - - it('should redirect to new case when caseData is there', async () => { - const sampleId = 'case-id'; - usePostCaseMock.mockImplementation(() => ({ - ...defaultPostCase, - caseData: { id: sampleId }, - })); - - mount( - - - - - - ); - - await waitFor(() => expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/case-id')); - }); - - it('should render spinner when loading', async () => { - const wrapper = mount( - - - - - - ); - - await fillForm(wrapper); - await act(async () => { - await wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - wrapper.update(); - expect( - wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists() - ).toBeTruthy(); - }); - }); - - it('it should select the default connector set in the configuration', async () => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.servicenow, - fields: null, - }, - persistLoading: false, - })); - - useConnectorsMock.mockReturnValue({ - ...sampleConnectorData, - connectors: connectorsMock, - }); - - const wrapper = mount( - - - - - - ); - - await fillForm(wrapper); - wrapper.update(); - - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - }); - - await waitFor(() => - expect(postCase).toBeCalledWith({ - ...sampleData, - connector: { - fields: { - impact: null, - severity: null, - urgency: null, - }, - id: 'servicenow-1', - name: 'My Connector', - type: '.servicenow', - }, - }) - ); - }); - - it('it should default to none if the default connector does not exist in connectors', async () => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.servicenow, - fields: null, - }, - persistLoading: false, - })); - - useConnectorsMock.mockReturnValue({ - ...sampleConnectorData, - connectors: connectorsMock, - }); - - const wrapper = mount( - - - - - - ); - - await fillForm(wrapper); - wrapper.update(); - - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - }); - - await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); - }); + expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="create-case-cancel"]`).exists()).toBeTruthy(); }); - describe('Step 2 - Connector Fields', () => { - it(`it should submit a Jira connector`, async () => { - useConnectorsMock.mockReturnValue({ - ...sampleConnectorData, - connectors: connectorsMock, - }); + it('should redirect to all cases on cancel click', async () => { + const wrapper = mount( + + + + + + ); - const wrapper = mount( - - - - - - ); + wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click'); + await waitFor(() => expect(mockHistory.push).toHaveBeenCalledWith('/')); + }); - await fillForm(wrapper); - await waitFor(() => { - expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); - wrapper.update(); - }); + it('should redirect to new case when posting the case', async () => { + const wrapper = mount( + + + + + + ); - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeTruthy(); - }); + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - act(() => { - wrapper - .find('select[data-test-subj="issueTypeSelect"]') - .first() - .simulate('change', { - target: { value: '10007' }, - }); - }); + await waitFor(() => expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/basic-case-id')); + }); - act(() => { - wrapper - .find('select[data-test-subj="prioritySelect"]') - .first() - .simulate('change', { - target: { value: '2' }, - }); - }); - - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - }); - - await waitFor(() => - expect(postCase).toBeCalledWith({ - ...sampleData, - connector: { - id: 'jira-1', - name: 'Jira', - type: '.jira', - fields: { issueType: '10007', parent: null, priority: '2' }, - }, - }) - ); + it('it should insert a timeline', async () => { + let attachTimeline = noop; + useInsertTimelineMock.mockImplementation((value, onTimelineAttached) => { + attachTimeline = onTimelineAttached; }); - it(`it should submit a resilient connector`, async () => { - useConnectorsMock.mockReturnValue({ - ...sampleConnectorData, - connectors: connectorsMock, - }); + const wrapper = mount( + + + + + + ); - const wrapper = mount( - - - - - - ); - - await fillForm(wrapper); - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists() - ).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); - wrapper.update(); - }); - - await waitFor(() => { - wrapper.update(); - expect( - wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists() - ).toBeTruthy(); - }); - - act(() => { - ((wrapper.find(EuiComboBox).at(1).props() as unknown) as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - }).onChange([{ value: '19', label: 'Denial of Service' }]); - }); - - act(() => { - wrapper - .find('select[data-test-subj="severitySelect"]') - .first() - .simulate('change', { - target: { value: '4' }, - }); - }); - - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - }); - - await waitFor(() => - expect(postCase).toBeCalledWith({ - ...sampleData, - connector: { - id: 'resilient-2', - name: 'My Connector 2', - type: '.resilient', - fields: { incidentTypes: ['19'], severityCode: '4' }, - }, - }) - ); + act(() => { + attachTimeline('[title](url)'); }); - it(`it should submit a servicenow connector`, async () => { - useConnectorsMock.mockReturnValue({ - ...sampleConnectorData, - connectors: connectorsMock, - }); - - const wrapper = mount( - - - - - - ); - - await fillForm(wrapper); - await waitFor(() => { - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeFalsy(); - wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); - wrapper.update(); - }); - - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy(); - }); - - ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { - act(() => { - wrapper - .find(`select[data-test-subj="${subj}"]`) - .first() - .simulate('change', { - target: { value: '2' }, - }); - }); - }); - - await act(async () => { - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - }); - - await waitFor(() => - expect(postCase).toBeCalledWith({ - ...sampleData, - connector: { - id: 'servicenow-1', - name: 'My Connector', - type: '.servicenow', - fields: { impact: '2', severity: '2', urgency: '2' }, - }, - }) + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="caseDescription"] textarea`).text()).toBe( + '[title](url)' ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/mock.ts b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts new file mode 100644 index 000000000000..b8481d50451a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/mock.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CasePostRequest } from '../../../../../case/common/api'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; + +export const sampleTags = ['coke', 'pepsi']; +export const sampleData: CasePostRequest = { + description: 'what a great description', + tags: sampleTags, + title: 'what a cool title', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + settings: { + syncAlerts: true, + }, +}; + +export const sampleConnectorData = { loading: false, connectors: [] }; + +export const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes: [ + { + id: 19, + name: 'Malware', + }, + { + id: 21, + name: 'Denial of Service', + }, + ], +}; + +export const useGetSeverityResponse = { + isLoading: false, + severity: [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, + ], +}; + +export const useGetIssueTypesResponse = { + isLoading: false, + issueTypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], +}; + +export const useGetFieldsByIssueTypeResponse = { + isLoading: false, + fields: { + summary: { allowedValues: [], defaultValue: {} }, + labels: { allowedValues: [], defaultValue: {} }, + description: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + { + name: 'Low', + id: '2', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/submit_button.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/submit_button.test.tsx index c8f6ebc05582..c1f31c20e88a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/submit_button.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/submit_button.test.tsx @@ -10,13 +10,17 @@ import { act, waitFor } from '@testing-library/react'; import { useForm, Form } from '../../../shared_imports'; import { SubmitCaseButton } from './submit_button'; +import { schema, FormProps } from './schema'; describe('SubmitCaseButton', () => { const onSubmit = jest.fn(); const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm<{ title: string }>({ + const { form } = useForm({ defaultValue: { title: 'My title' }, + schema: { + title: schema.title, + }, onSubmit, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.test.tsx new file mode 100644 index 000000000000..60232b2f3e33 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; + +import { useForm, Form, FormHook } from '../../../shared_imports'; +import { SyncAlertsToggle } from './sync_alerts_toggle'; +import { schema, FormProps } from './schema'; + +describe('SyncAlertsToggle', () => { + let globalForm: FormHook; + + const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm({ + defaultValue: { syncAlerts: true }, + schema: { + syncAlerts: schema.syncAlerts, + }, + }); + + globalForm = form; + + return
{children}
; + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('it renders', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="caseSyncAlerts"]`).exists()).toBeTruthy(); + }); + + it('it toggles the switch', async () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ syncAlerts: false }); + }); + }); + + it('it shows the correct labels', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text()).toBe( + 'On' + ); + + wrapper.find('[data-test-subj="caseSyncAlerts"] button').first().simulate('click'); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="caseSyncAlerts"] .euiSwitch__label`).first().text() + ).toBe('Off'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/tags.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/tags.test.tsx index c06ac011a035..06c4cc2f6e02 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/tags.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/tags.test.tsx @@ -9,9 +9,10 @@ import { mount } from 'enzyme'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; -import { useForm, Form, FormHook, FIELD_TYPES } from '../../../shared_imports'; +import { useForm, Form, FormHook } from '../../../shared_imports'; import { useGetTags } from '../../containers/use_get_tags'; import { Tags } from './tags'; +import { schema, FormProps } from './schema'; jest.mock('../../containers/use_get_tags'); const useGetTagsMock = useGetTags as jest.Mock; @@ -20,10 +21,10 @@ describe('Tags', () => { let globalForm: FormHook; const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm<{ tags: string[] }>({ + const { form } = useForm({ defaultValue: { tags: [] }, schema: { - tags: { type: FIELD_TYPES.COMBO_BOX }, + tags: schema.tags, }, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/title.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/title.test.tsx index 54a4e665a56e..7e6e1287c19b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/title.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/title.test.tsx @@ -10,13 +10,17 @@ import { act } from '@testing-library/react'; import { useForm, Form, FormHook } from '../../../shared_imports'; import { Title } from './title'; +import { schema, FormProps } from './schema'; describe('Title', () => { let globalForm: FormHook; const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm<{ title: string }>({ + const { form } = useForm({ defaultValue: { title: 'My title' }, + schema: { + title: schema.title, + }, }); globalForm = form; diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx new file mode 100644 index 000000000000..2eb325d43ff4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { CaseStatuses } from '../../../../../case/common/api'; +import { StatusActionButton } from './button'; + +describe('StatusActionButton', () => { + const onStatusChanged = jest.fn(); + const defaultProps = { + status: CaseStatuses.open, + disabled: false, + isLoading: false, + onStatusChanged, + }; + + it('it renders', async () => { + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="case-view-status-action-button"]`).exists()).toBeTruthy(); + }); + + describe('Button icons', () => { + it('it renders the correct button icon: status open', () => { + const wrapper = mount(); + + expect( + wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') + ).toBe('folderExclamation'); + }); + + it('it renders the correct button icon: status in-progress', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') + ).toBe('folderCheck'); + }); + + it('it renders the correct button icon: status closed', () => { + const wrapper = mount(); + + expect( + wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') + ).toBe('folderCheck'); + }); + }); + + describe('Status rotation', () => { + it('rotates correctly to in-progress when status is open', () => { + const wrapper = mount(); + + wrapper + .find(`button[data-test-subj="case-view-status-action-button"]`) + .first() + .simulate('click'); + expect(onStatusChanged).toHaveBeenCalledWith('in-progress'); + }); + + it('rotates correctly to closed when status is in-progress', () => { + const wrapper = mount( + + ); + + wrapper + .find(`button[data-test-subj="case-view-status-action-button"]`) + .first() + .simulate('click'); + expect(onStatusChanged).toHaveBeenCalledWith('closed'); + }); + + it('rotates correctly to open when status is closed', () => { + const wrapper = mount(); + + wrapper + .find(`button[data-test-subj="case-view-status-action-button"]`) + .first() + .simulate('click'); + expect(onStatusChanged).toHaveBeenCalledWith('open'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.tsx b/x-pack/plugins/security_solution/public/cases/components/status/button.tsx index 18aa683ed451..94377fefe2fc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/button.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/button.tsx @@ -38,7 +38,7 @@ const StatusActionButtonComponent: React.FC = ({ return ( { + const defaultProps = { + caseStatus: CaseStatuses.open, + caseCount: 2, + isLoading: false, + dataTestSubj: 'test-stats', + }; + it('it renders', async () => { + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="test-stats"]`).exists()).toBeTruthy(); + }); + + it('shows the count', async () => { + const wrapper = mount(); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__description`).first().text() + ).toBe('2'); + }); + + it('shows the loading spinner', async () => { + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="test-stats-loading-spinner"]`).exists()).toBeTruthy(); + }); + + describe('Status title', () => { + it('shows the correct title for status open', async () => { + const wrapper = mount(); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() + ).toBe('Open cases'); + }); + + it('shows the correct title for status in-progress', async () => { + const wrapper = mount(); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() + ).toBe('In progress cases'); + }); + + it('shows the correct title for status closed', async () => { + const wrapper = mount(); + + expect( + wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() + ).toBe('Closed cases'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx b/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx index 0d217dc87f62..acd17e8187cb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/stats.tsx @@ -21,10 +21,14 @@ const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dat () => [ { title: statuses[caseStatus].stats.title, - description: isLoading ? : caseCount ?? 'N/A', + description: isLoading ? ( + + ) : ( + caseCount ?? 'N/A' + ), }, ], - [caseCount, caseStatus, isLoading] + [caseCount, caseStatus, dataTestSubj, isLoading] ); return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx b/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx new file mode 100644 index 000000000000..0b96d4fefb1a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/status/status.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { CaseStatuses } from '../../../../../case/common/api'; +import { Status } from './status'; + +describe('Stats', () => { + const onClick = jest.fn(); + + it('it renders', async () => { + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="status-badge-open"]`).exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists() + ).toBeFalsy(); + }); + + it('it renders with arrow', async () => { + const wrapper = mount(); + + expect( + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists() + ).toBeTruthy(); + }); + + it('it calls onClick when pressing the badge', async () => { + const wrapper = mount(); + + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + + describe('Colors', () => { + it('shows the correct color when status is open', async () => { + const wrapper = mount( + + ); + + expect(wrapper.find(`[data-test-subj="status-badge-open"]`).first().prop('color')).toBe( + 'primary' + ); + }); + + it('shows the correct color when status is in-progress', async () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="status-badge-in-progress"]`).first().prop('color') + ).toBe('warning'); + }); + + it('shows the correct color when status is closed', async () => { + const wrapper = mount( + + ); + + expect(wrapper.find(`[data-test-subj="status-badge-closed"]`).first().prop('color')).toBe( + 'default' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx new file mode 100644 index 000000000000..0c156e247a5e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable react/display-name */ +import React, { ReactNode } from 'react'; + +import { mount } from 'enzyme'; +import { TestProviders } from '../../../common/mock'; +import { usePostComment } from '../../containers/use_post_comment'; +import { AddToCaseAction } from './add_to_case_action'; + +jest.mock('../../containers/use_post_comment'); +jest.mock('../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../common/lib/kibana'); + return { + ...originalModule, + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); + +jest.mock('../all_cases', () => { + return { + AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => { + return ( + + ); + }, + }; +}); + +jest.mock('../create/form_context', () => { + return { + FormContext: ({ + children, + onSuccess, + }: { + children: ReactNode; + onSuccess: ({ id }: { id: string }) => void; + }) => { + return ( + <> + + {children} + + ); + }, + }; +}); + +jest.mock('../create/form', () => { + return { + CreateCaseForm: () => { + return <>{'form'}; + }, + }; +}); + +jest.mock('../create/submit_button', () => { + return { + SubmitCaseButton: () => { + return <>{'Submit'}; + }, + }; +}); + +const usePostCommentMock = usePostComment as jest.Mock; +const postComment = jest.fn(); +const defaultPostComment = { + isLoading: false, + isError: false, + postComment, +}; + +describe('AddToCaseAction', () => { + const props = { + ecsRowData: { + _id: 'test-id', + _index: 'test-index', + }, + disabled: false, + }; + + beforeEach(() => { + jest.resetAllMocks(); + usePostCommentMock.mockImplementation(() => defaultPostComment); + }); + + it('it renders', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeTruthy(); + }); + + it('it opens the context menu', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + expect(wrapper.find(`[data-test-subj="add-new-case-item"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).exists()).toBeTruthy(); + }); + + it('it opens the create case modal', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="form-context-on-success"]`).exists()).toBeTruthy(); + }); + + it('it attach the alert to case on case creation', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); + + wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); + + expect(postComment.mock.calls[0][0]).toBe('new-case'); + expect(postComment.mock.calls[0][1]).toEqual({ + alertId: 'test-id', + index: 'test-index', + type: 'alert', + }); + }); + + it('it opens the all cases modal', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); + + expect(wrapper.find(`[data-test-subj="all-cases-modal-button"]`).exists()).toBeTruthy(); + }); + + it('it attach the alert to case after selecting a case', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); + + wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click'); + + expect(postComment.mock.calls[0][0]).toBe('selected-case'); + expect(postComment.mock.calls[0][1]).toEqual({ + alertId: 'test-id', + index: 'test-index', + type: 'alert', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx new file mode 100644 index 000000000000..b1c0c3f4a82b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable react/display-name */ +import React, { ReactNode } from 'react'; +import { mount } from 'enzyme'; + +import '../../../common/mock/match_media'; +import { CreateCaseModal } from './create_case_modal'; +import { TestProviders } from '../../../common/mock'; + +jest.mock('../create/form_context', () => { + return { + FormContext: ({ + children, + onSuccess, + }: { + children: ReactNode; + onSuccess: ({ id }: { id: string }) => void; + }) => { + return ( + <> + + {children} + + ); + }, + }; +}); + +jest.mock('../create/form', () => { + return { + CreateCaseForm: () => { + return <>{'form'}; + }, + }; +}); + +jest.mock('../create/submit_button', () => { + return { + SubmitCaseButton: () => { + return <>{'Submit'}; + }, + }; +}); + +const onCloseCaseModal = jest.fn(); +const onSuccess = jest.fn(); +const defaultProps = { + isModalOpen: true, + onCloseCaseModal, + onSuccess, +}; + +describe('CreateCaseModal', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); + }); + + it('it does not render the modal isModalOpen=false ', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiModal__closeIcon').first().simulate('click'); + expect(onCloseCaseModal).toBeCalled(); + }); + + it('pass the correct props to FormContext component', () => { + const wrapper = mount( + + + + ); + + const props = wrapper.find('FormContext').props(); + expect(props).toEqual( + expect.objectContaining({ + onSuccess, + }) + ); + }); + + it('onSuccess called when creating a case', () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); + expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx new file mode 100644 index 000000000000..83595c127a26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable react/display-name */ +import React, { ReactNode } from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useKibana } from '../../../common/lib/kibana'; +import '../../../common/mock/match_media'; +import { useCreateCaseModal, UseCreateCaseModalProps, UseCreateCaseModalReturnedValues } from '.'; +import { mockTimelineModel, TestProviders } from '../../../common/mock'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../create/form_context', () => { + return { + FormContext: ({ + children, + onSuccess, + }: { + children: ReactNode; + onSuccess: ({ id }: { id: string }) => void; + }) => { + return ( + <> + + {children} + + ); + }, + }; +}); + +jest.mock('../create/form', () => { + return { + CreateCaseForm: () => { + return <>{'form'}; + }, + }; +}); + +jest.mock('../create/submit_button', () => { + return { + SubmitCaseButton: () => { + return <>{'Submit'}; + }, + }; +}); + +jest.mock('../../../common/hooks/use_selector'); + +const useKibanaMock = useKibana as jest.Mocked; +const onCaseCreated = jest.fn(); + +describe('useCreateCaseModal', () => { + let navigateToApp: jest.Mock; + + beforeEach(() => { + navigateToApp = jest.fn(); + useKibanaMock().services.application.navigateToApp = navigateToApp; + (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); + }); + + it('init', async () => { + const { result } = renderHook( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => {children}, + } + ); + + expect(result.current.isModalOpen).toBe(false); + }); + + it('opens the modal', async () => { + const { result } = renderHook( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.openModal(); + }); + + expect(result.current.isModalOpen).toBe(true); + }); + + it('closes the modal', async () => { + const { result } = renderHook( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.openModal(); + result.current.closeModal(); + }); + + expect(result.current.isModalOpen).toBe(false); + }); + + it('returns a memoized value', async () => { + const { result, rerender } = renderHook< + UseCreateCaseModalProps, + UseCreateCaseModalReturnedValues + >(() => useCreateCaseModal({ onCaseCreated }), { + wrapper: ({ children }) => {children}, + }); + + const result1 = result.current; + act(() => rerender()); + const result2 = result.current; + + expect(Object.is(result1, result2)).toBe(true); + }); + + it('closes the modal when creating a case', async () => { + const { result } = renderHook( + () => useCreateCaseModal({ onCaseCreated }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.openModal(); + }); + + const modal = result.current.modal; + render({modal}); + + act(() => { + userEvent.click(screen.getByText('Form submit')); + }); + + expect(result.current.isModalOpen).toBe(false); + expect(onCaseCreated).toHaveBeenCalledWith({ id: 'case-id' }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx index 0a5751d4c727..85c2b233aba2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx @@ -8,17 +8,17 @@ import React, { useState, useCallback, useMemo } from 'react'; import { Case } from '../../containers/types'; import { CreateCaseModal } from './create_case_modal'; -interface Props { +export interface UseCreateCaseModalProps { onCaseCreated: (theCase: Case) => void; } -export interface UseAllCasesModalReturnedValues { +export interface UseCreateCaseModalReturnedValues { modal: JSX.Element; isModalOpen: boolean; closeModal: () => void; openModal: () => void; } -export const useCreateCaseModal = ({ onCaseCreated }: Props) => { +export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) => { const [isModalOpen, setIsModalOpen] = useState(false); const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_insert_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_insert_timeline/index.test.tsx new file mode 100644 index 000000000000..8846cd3ce1ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_insert_timeline/index.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { mockTimelineModel } from '../../../common/mock'; +import { useFormatUrl } from '../../../common/components/link_to'; +import { SecurityPageName } from '../../../app/types'; +import { useInsertTimeline } from '.'; + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +jest.mock('../../../common/components/link_to', () => { + const originalModule = jest.requireActual('../../../common/components/link_to'); + return { + ...originalModule, + getTimelineTabsUrl: jest.fn(), + useFormatUrl: jest.fn().mockReturnValue({ + formatUrl: jest.fn().mockImplementation((path: string) => path), + search: '', + }), + }; +}); + +jest.mock('../../../common/hooks/use_selector', () => ({ + useShallowEqualSelector: jest.fn().mockReturnValue({ + timelineTitle: mockTimelineModel.title, + timelineSavedObjectId: mockTimelineModel.savedObjectId, + graphEventId: mockTimelineModel.graphEventId, + timelineId: mockTimelineModel.id, + }), +})); + +describe('useInsertTimeline', () => { + const onChange = jest.fn(); + const { formatUrl } = useFormatUrl(SecurityPageName.timelines); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + renderHook(() => useInsertTimeline('', onChange)); + + expect(mockDispatch).toHaveBeenNthCalledWith(1, { + payload: { id: 'ef579e40-jibber-jabber', show: false }, + type: 'x-pack/security_solution/local/timeline/SHOW_TIMELINE', + }); + + expect(mockDispatch).toHaveBeenNthCalledWith(2, { + payload: null, + type: 'x-pack/security_solution/local/timeline/SET_INSERT_TIMELINE', + }); + + expect(onChange).toHaveBeenCalledWith( + `[Test rule](?timeline=(id:'ef579e40-jibber-jabber',isOpen:!t))` + ); + }); + + it('it appends the value if is not empty', async () => { + renderHook(() => useInsertTimeline('New value', onChange)); + + expect(onChange).toHaveBeenCalledWith( + `New value [Test rule](?timeline=(id:'ef579e40-jibber-jabber',isOpen:!t))` + ); + }); + + it('calls formatUrl with correct options', async () => { + renderHook(() => useInsertTimeline('', onChange)); + + expect(formatUrl).toHaveBeenCalledWith(`?timeline=(id:'ef579e40-jibber-jabber',isOpen:!t)`, { + absolute: true, + skipSearch: true, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_insert_timeline/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_insert_timeline/index.tsx index c44193dc363a..fa3575ba52db 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_insert_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_insert_timeline/index.tsx @@ -14,7 +14,7 @@ import { timelineSelectors, timelineActions } from '../../../timelines/store/tim import { SecurityPageName } from '../../../app/types'; import { setInsertTimeline } from '../../../timelines/store/timeline/actions'; -interface UseInsertTimelineReturn { +export interface UseInsertTimelineReturn { handleOnTimelineChange: (title: string, id: string | null, graphEventId?: string) => void; } diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index 975f9b76556c..314be4d8da87 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; +import { mount } from 'enzyme'; import { CaseStatuses } from '../../../../../case/common/api'; import { basicPush, getUserAction } from '../../containers/mock'; import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers'; -import { mount } from 'enzyme'; import { connectorsMock } from '../../containers/configure/mock'; import * as i18n from './translations'; @@ -56,24 +56,52 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); }); - it.skip('label title generated for update status to open', () => { + it('label title generated for update status to open', () => { const action = { ...getUserAction(['status'], 'update'), newValue: CaseStatuses.open }; const result: string | JSX.Element = getLabelTitle({ action, field: 'status', }); - expect(result).toEqual(`${i18n.REOPEN_CASE.toLowerCase()} ${i18n.CASE}`); + const wrapper = mount(<>{result}); + expect(wrapper.find(`[data-test-subj="status-badge-open"]`).first().text()).toEqual('Open'); }); - it.skip('label title generated for update status to closed', () => { + it('label title generated for update status to in-progress', () => { + const action = { + ...getUserAction(['status'], 'update'), + newValue: CaseStatuses['in-progress'], + }; + const result: string | JSX.Element = getLabelTitle({ + action, + field: 'status', + }); + + const wrapper = mount(<>{result}); + expect(wrapper.find(`[data-test-subj="status-badge-in-progress"]`).first().text()).toEqual( + 'In progress' + ); + }); + + it('label title generated for update status to closed', () => { const action = { ...getUserAction(['status'], 'update'), newValue: CaseStatuses.closed }; const result: string | JSX.Element = getLabelTitle({ action, field: 'status', }); - expect(result).toEqual(`${i18n.CLOSE_CASE.toLowerCase()} ${i18n.CASE}`); + const wrapper = mount(<>{result}); + expect(wrapper.find(`[data-test-subj="status-badge-closed"]`).first().text()).toEqual('Closed'); + }); + + it('label title is empty when status is not valid', () => { + const action = { ...getUserAction(['status'], 'update'), newValue: CaseStatuses.closed }; + const result: string | JSX.Element = getLabelTitle({ + action: { ...action, newValue: 'not-exist' }, + field: 'status', + }); + + expect(result).toEqual(''); }); it('label title generated for update comment', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index 0fef2accb2e2..5e5957a4fea1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -31,9 +31,13 @@ interface LabelTitle { field: string; } -const getStatusTitle = (status: CaseStatuses) => { +const getStatusTitle = (id: string, status: CaseStatuses) => { return ( - + {i18n.MARKED_CASE_AS} @@ -42,6 +46,9 @@ const getStatusTitle = (status: CaseStatuses) => { ); }; +const isStatusValid = (status: string): status is CaseStatuses => + Object.prototype.hasOwnProperty.call(statuses, status); + export const getLabelTitle = ({ action, field }: LabelTitle) => { if (field === 'tags') { return getTagsLabelTitle(action); @@ -52,12 +59,12 @@ export const getLabelTitle = ({ action, field }: LabelTitle) => { } else if (field === 'description' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; } else if (field === 'status' && action.action === 'update') { - if (!Object.prototype.hasOwnProperty.call(statuses, action.newValue ?? '')) { - return ''; + const status = action.newValue ?? ''; + if (isStatusValid(status)) { + return getStatusTitle(action.actionId, status); } - // The above check ensures that the newValue is of type CaseStatuses. - return getStatusTitle(action.newValue as CaseStatuses); + return ''; } else if (field === 'comment' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; } diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx new file mode 100644 index 000000000000..aab48b97e43e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { TestProviders } from '../../../common/mock'; +import { useKibana } from '../../../common/lib/kibana'; +import { AlertCommentEvent } from './user_action_alert_comment_event'; + +const props = { + alert: { + _id: 'alert-id-1', + _index: 'alert-index-1', + '@timestamp': '2021-01-07T13:58:31.487Z', + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + from: '2021-01-07T13:58:31.487Z', + to: '2021-01-07T14:58:31.487Z', + }, + }, +}; + +jest.mock('../../../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked; + +describe('UserActionAvatar ', () => { + let navigateToApp: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + navigateToApp = jest.fn(); + useKibanaMock().services.application.navigateToApp = navigateToApp; + }); + + it('it renders', async () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() + ).toBeTruthy(); + expect(wrapper.text()).toBe('added an alert from Awesome rule'); + }); + + it('does NOT render the link when the alert is undefined', async () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() + ).toBeFalsy(); + + expect(wrapper.text()).toBe('added an alert'); + }); + + it('does NOT render the link when the rule is undefined', async () => { + const alert = { + _id: 'alert-id-1', + _index: 'alert-index-1', + }; + + const wrapper = mount( + + {/* @ts-expect-error*/} + + + ); + + expect( + wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() + ).toBeFalsy(); + + expect(wrapper.text()).toBe('added an alert'); + }); + + it('navigate to app on link click', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().simulate('click'); + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:detections', { + path: '/rules/id/rule-id-1', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index be437073e693..aecde6e55e76 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -37,7 +37,9 @@ const AlertCommentEventComponent: React.FC = ({ alert }) => { return ruleId != null && ruleName != null ? ( <> {`${i18n.ALERT_COMMENT_LABEL_TITLE} `} - {ruleName} + + {ruleName} + ) : ( <>{i18n.ALERT_RULE_DELETED_COMMENT_LABEL} diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx new file mode 100644 index 000000000000..fd54aa230ddc --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_show_alert.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionShowAlert } from './user_action_show_alert'; + +const props = { + id: 'action-id', + alert: { + _id: 'alert-id', + _index: 'alert-index', + '@timestamp': '2021-01-07T13:58:31.487Z', + rule: { + id: 'rule-id', + name: 'Awesome Rule', + from: '2021-01-07T13:58:31.487Z', + to: '2021-01-07T14:58:31.487Z', + }, + }, +}; + +describe('UserActionShowAlert ', () => { + let wrapper: ReactWrapper; + const onShowAlertDetails = jest.fn(); + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="comment-action-show-alert-action-id"]').first().exists() + ).toBeTruthy(); + }); + + it('it calls onClick', async () => { + wrapper.find('button[data-test-subj="comment-action-show-alert-action-id"]').simulate('click'); + expect(onShowAlertDetails).toHaveBeenCalledWith('alert-id', 'alert-index'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 2b647de2b14e..fd24a8451fcb 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -54,6 +54,20 @@ export const basicComment: Comment = { version: 'WzQ3LDFc', }; +export const alertComment: Comment = { + alertId: 'alert-id-1', + index: 'alert-index-1', + type: CommentType.alert, + id: 'alert-comment-id', + createdAt: basicCreatedAt, + createdBy: elasticUser, + pushedAt: null, + pushedBy: null, + updatedAt: null, + updatedBy: null, + version: 'WzQ3LDFc', +}; + export const basicCase: Case = { closedAt: null, closedBy: null, @@ -311,6 +325,15 @@ export const getUserAction = (af: UserActionField, a: UserAction) => ({ : basicAction.newValue, }); +export const getAlertUserAction = () => ({ + ...basicAction, + actionId: 'alert-action-id', + actionField: ['comment'], + action: 'create', + commentId: 'alert-comment-id', + newValue: '{"type":"alert","alertId":"alert-id-1","index":"index-id-1"}', +}); + export const caseUserActions: CaseUserActions[] = [ getUserAction(['description'], 'create'), getUserAction(['comment'], 'create'), diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts index 07855c347710..d324e52264f4 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts @@ -10,7 +10,7 @@ export { getDetectionEngineUrl } from '../redirect_to_detection_engine'; export { getAppOverviewUrl } from '../redirect_to_overview'; export { getHostDetailsUrl, getHostsUrl } from '../redirect_to_hosts'; export { getNetworkUrl, getNetworkDetailsUrl } from '../redirect_to_network'; -export { getTimelinesUrl, getTimelineTabsUrl } from '../redirect_to_timelines'; +export { getTimelinesUrl, getTimelineTabsUrl, getTimelineUrl } from '../redirect_to_timelines'; export { getCaseDetailsUrl, getCaseUrl, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index cff3d2890d85..0dd38fc2f040 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -3,6 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +/* eslint-disable react/display-name */ import { mount } from 'enzyme'; import React from 'react'; @@ -11,11 +13,19 @@ import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; import * as i18n from '../translations'; import { EventColumnView } from './event_column_view'; -import { TimelineTabs, TimelineType } from '../../../../../../common/types/timeline'; +import { TimelineTabs, TimelineType, TimelineId } from '../../../../../../common/types/timeline'; import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; jest.mock('../../../../../common/hooks/use_selector'); +jest.mock('../../../../../cases/components/timeline_actions/add_to_case_action', () => { + return { + AddToCaseAction: () => { + return
{'Add to case'}
; + }, + }; +}); + describe('EventColumnView', () => { (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); @@ -49,7 +59,7 @@ describe('EventColumnView', () => { showCheckboxes: false, showNotes: false, tabType: TimelineTabs.query, - timelineId: 'timeline-test', + timelineId: TimelineId.active, toggleShowNotes: jest.fn(), updateNote: jest.fn(), isEventPinned: false, @@ -107,4 +117,39 @@ describe('EventColumnView', () => { expect(props.onPinEvent).toHaveBeenCalled(); }); + + test('it render AddToCaseAction if timelineId === TimelineId.detectionsPage', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy(); + }); + + test('it render AddToCaseAction if timelineId === TimelineId.detectionsRulesDetailsPage', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy(); + }); + + test('it render AddToCaseAction if timelineId === TimelineId.active', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeTruthy(); + }); + + test('it does NOT render AddToCaseAction when timelineId is not in the allowed list', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(wrapper.find('[data-test-subj="add-to-case-action"]').exists()).toBeFalsy(); + }); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index d26e31394b9f..885d2c9d5279 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; import { CommentType } from '../../../../../../plugins/case/common/api'; import { defaultUser, @@ -17,10 +18,22 @@ import { postCommentAlertReq, } from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createSignalsIndex, + deleteSignalsIndex, + deleteAllAlerts, + getRuleForSignalTesting, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, + getSignalsByIds, + createRule, + getQuerySignalIds, +} from '../../../../../detection_engine_api_integration/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); describe('post_comment', () => { @@ -166,5 +179,146 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(400); }); + + it('unhappy path - 400s when adding an alert to a closed case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(400); + }); + + describe('alerts', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); + + it('should change the status of the alert if sync alert is on', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + }) + .expect(200); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); + }); + + it('should NOT change the status of the alert if sync alert is off', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ ...postCaseReq, settings: { syncAlerts: false } }) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + }) + .expect(200); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index 89da67b50800..4c45504f3fd0 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -8,6 +8,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../plugins/security_solution/common/constants'; +import { CommentType } from '../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, @@ -15,10 +17,22 @@ import { removeServerGeneratedPropertiesFromCase, } from '../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions } from '../../../common/lib/utils'; +import { + createSignalsIndex, + deleteSignalsIndex, + deleteAllAlerts, + getRuleForSignalTesting, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, + getSignalsByIds, + createRule, + getQuerySignalIds, +} from '../../../../detection_engine_api_integration/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); describe('patch_cases', () => { @@ -248,5 +262,250 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(409); }); + + describe('alerts', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); + + it('updates alert status when the status is updated and syncAlerts=true', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const { body: caseUpdated } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + }) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); + }); + + it('does NOT updates alert status when the status is updated and syncAlerts=false', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ ...postCaseReq, settings: { syncAlerts: false } }) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const { body: caseUpdated } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + }) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); + }); + + it('it updates alert status when syncAlerts is turned on', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ ...postCaseReq, settings: { syncAlerts: false } }) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const { body: caseUpdated } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + }) + .expect(200); + + // Update the status of the case with sync alerts off + const { body: caseStatusUpdated } = await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + // Turn sync alerts on + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: caseStatusUpdated[0].id, + version: caseStatusUpdated[0].version, + settings: { syncAlerts: true }, + }, + ], + }) + .expect(200); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); + }); + + it('it does NOT updates alert status when syncAlerts is turned off', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const { body: caseUpdated } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + }) + .expect(200); + + // Turn sync alerts off + const { body: caseSettingsUpdated } = await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + settings: { syncAlerts: false }, + }, + ], + }) + .expect(200); + + // Update the status of the case with sync alerts off + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: caseSettingsUpdated[0].id, + version: caseSettingsUpdated[0].version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); + }); + }); }); };