diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index 846013674986e..4aa6725159043 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -27,7 +27,7 @@ export const getActions = (): FindActionResult[] => [ referencedByCount: 0, }, { - id: 'd611af27-3532-4da9-8034-271fee81d634', + id: '123', actionTypeId: '.servicenow', name: 'ServiceNow', config: { diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 5be36d34549b7..871e78495c5dd 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -37,15 +37,23 @@ export function initPushCaseUserActionApi({ async (context, request, response) => { try { const client = context.core.savedObjects.client; + const actionsClient = await context.actions?.getActionsClient(); + const caseId = request.params.case_id; const query = pipe( CaseExternalServiceRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); + + if (actionsClient == null) { + throw Boom.notFound('Action client have not been found'); + } + const { username, full_name, email } = await caseService.getUser({ request, response }); + const pushedDate = new Date().toISOString(); - const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([ + const [myCase, myCaseConfigure, totalCommentsFindByCases, connectors] = await Promise.all([ caseService.getCase({ client, caseId: request.params.case_id, @@ -60,6 +68,7 @@ export function initPushCaseUserActionApi({ perPage: 1, }, }), + actionsClient.getAll(), ]); if (myCase.attributes.status === 'closed') { @@ -85,9 +94,15 @@ export function initPushCaseUserActionApi({ }; const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + // old case may not have new attribute connector_id, so we default to the configured system - const updateConnectorId = - myCase.attributes.connector_id == null ? { connector_id: caseConfigureConnectorId } : {}; + const updateConnectorId = { + connector_id: myCase.attributes.connector_id ?? caseConfigureConnectorId, + }; + + if (!connectors.some(connector => connector.id === updateConnectorId.connector_id)) { + throw Boom.notFound('Connector not found or set to none'); + } const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ diff --git a/x-pack/plugins/siem/cypress/integration/cases.spec.ts b/x-pack/plugins/siem/cypress/integration/cases.spec.ts index f541555d56440..e11d76d8f608a 100644 --- a/x-pack/plugins/siem/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/siem/cypress/integration/cases.spec.ts @@ -27,7 +27,7 @@ import { ACTION, CASE_DETAILS_DESCRIPTION, CASE_DETAILS_PAGE_TITLE, - CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN, + CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, CASE_DETAILS_TIMELINE_MARKDOWN, @@ -102,7 +102,7 @@ describe('Cases', () => { .eq(PARTICIPANTS) .should('have.text', case1.reporter); cy.get(CASE_DETAILS_TAGS).should('have.text', expectedTags); - cy.get(CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN).should('have.attr', 'disabled'); + cy.get(CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN).should('have.attr', 'disabled'); cy.get(CASE_DETAILS_TIMELINE_MARKDOWN).then($element => { const timelineLink = $element.prop('href').match(/http(s?):\/\/\w*:\w*(\S*)/)[0]; openCaseTimeline(timelineLink); diff --git a/x-pack/plugins/siem/cypress/screens/case_details.ts b/x-pack/plugins/siem/cypress/screens/case_details.ts index dadc1bdff1933..32bb64e93b05f 100644 --- a/x-pack/plugins/siem/cypress/screens/case_details.ts +++ b/x-pack/plugins/siem/cypress/screens/case_details.ts @@ -10,7 +10,8 @@ export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; -export const CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN = '[data-test-subj="push-to-external-service"]'; +export const CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN = + '[data-test-subj="push-to-external-service"]'; export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; diff --git a/x-pack/plugins/siem/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/siem/public/cases/components/case_view/index.test.tsx index 70d2dc97f3f45..881e607808314 100644 --- a/x-pack/plugins/siem/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/siem/public/cases/components/case_view/index.test.tsx @@ -15,19 +15,27 @@ import { useUpdateCase } from '../../containers/use_update_case'; import { useGetCase } from '../../containers/use_get_case'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; import { wait } from '../../../common/lib/helpers'; -import { usePushToService } from '../use_push_to_service'; + +import { useConnectors } from '../../containers/configure/use_connectors'; +import { connectorsMock } from '../../containers/configure/mock'; + +import { usePostPushToService } from '../../containers/use_post_push_to_service'; + jest.mock('../../containers/use_update_case'); jest.mock('../../containers/use_get_case_user_actions'); jest.mock('../../containers/use_get_case'); -jest.mock('../use_push_to_service'); +jest.mock('../../containers/configure/use_connectors'); +jest.mock('../../containers/use_post_push_to_service'); + const useUpdateCaseMock = useUpdateCase as jest.Mock; const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; -const usePushToServiceMock = usePushToService as jest.Mock; +const useConnectorsMock = useConnectors as jest.Mock; +const usePostPushToServiceMock = usePostPushToService as jest.Mock; export const caseProps: CaseProps = { caseId: basicCase.id, userCanCrud: true, - caseData: basicCase, + caseData: { ...basicCase, connectorId: 'servicenow-2' }, fetchCase: jest.fn(), updateCase: jest.fn(), }; @@ -42,6 +50,8 @@ describe('CaseView ', () => { const fetchCaseUserActions = jest.fn(); const fetchCase = jest.fn(); const updateCase = jest.fn(); + const postPushToService = jest.fn(); + const data = caseProps.caseData; const defaultGetCase = { isLoading: false, @@ -85,18 +95,8 @@ describe('CaseView ', () => { useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); - usePushToServiceMock.mockImplementation(({ updateCase: updateCaseMockCall }) => ({ - pushButton: ( - - ), - pushCallouts: null, - })); + usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService })); + useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, isLoading: false })); }); it('should render CaseComponent', async () => { @@ -328,6 +328,7 @@ describe('CaseView ', () => { ...defaultUseGetCaseUserActions, hasDataToPush: true, })); + const wrapper = mount( @@ -335,20 +336,24 @@ describe('CaseView ', () => { ); + + await wait(); + expect( wrapper .find('[data-test-subj="has-data-to-push-button"]') .first() .exists() ).toBeTruthy(); + wrapper - .find('[data-test-subj="mock-button"]') + .find('[data-test-subj="push-to-external-service"]') .first() .simulate('click'); + wrapper.update(); - await wait(); - expect(updateCase).toBeCalledWith(caseProps.caseData); - expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id); + + expect(postPushToService).toHaveBeenCalled(); }); it('should return null if error', () => { @@ -429,4 +434,32 @@ describe('CaseView ', () => { expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id); expect(fetchCase).toBeCalled(); }); + + it('should disable the push button when connector is invalid', () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find('button[data-test-subj="push-to-external-service"]') + .first() + .prop('disabled') + ).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/siem/public/cases/components/case_view/index.tsx b/x-pack/plugins/siem/public/cases/components/case_view/index.tsx index f80075fbe260d..163272f5087d7 100644 --- a/x-pack/plugins/siem/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/siem/public/cases/components/case_view/index.tsx @@ -163,10 +163,11 @@ export const CaseComponent = React.memo( ); const { loading: isLoadingConnectors, connectors } = useConnectors(); - const caseConnectorName = useMemo( - () => connectors.find(c => c.id === caseData.connectorId)?.name ?? 'none', - [connectors, caseData.connectorId] - ); + + const [caseConnectorName, isValidConnector] = useMemo(() => { + const connector = connectors.find(c => c.id === caseData.connectorId); + return [connector?.name ?? 'none', !!connector]; + }, [connectors, caseData.connectorId]); const currentExternalIncident = useMemo( () => @@ -185,6 +186,7 @@ export const CaseComponent = React.memo( connectors, updateCase: handleUpdateCase, userCanCrud, + isValidConnector, }); const onSubmitConnector = useCallback( diff --git a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx index cb00201942312..e3e627e3a136e 100644 --- a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx @@ -46,7 +46,9 @@ describe('usePushToService', () => { connectors: connectorsMock, updateCase, userCanCrud: true, + isValidConnector: true, }; + beforeEach(() => { jest.resetAllMocks(); (usePostPushToService as jest.Mock).mockImplementation(() => mockPostPush); @@ -55,6 +57,7 @@ describe('usePushToService', () => { actionLicense, })); }); + it('push case button posts the push with correct args', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( @@ -75,6 +78,7 @@ describe('usePushToService', () => { expect(result.current.pushCallouts).toBeNull(); }); }); + it('Displays message when user does not have premium license', async () => { (useGetActionLicense as jest.Mock).mockImplementation(() => ({ isLoading: false, @@ -96,6 +100,7 @@ describe('usePushToService', () => { expect(errorsMsg[0].title).toEqual(getLicenseError().title); }); }); + it('Displays message when user does not have case enabled in config', async () => { (useGetActionLicense as jest.Mock).mockImplementation(() => ({ isLoading: false, @@ -117,6 +122,7 @@ describe('usePushToService', () => { expect(errorsMsg[0].title).toEqual(getKibanaConfigError().title); }); }); + it('Displays message when user does not have a connector configured', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( @@ -135,6 +141,27 @@ describe('usePushToService', () => { expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); }); }); + + it('Displays message when connector is deleted', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...defaultArgs, + caseConnectorId: 'not-exist', + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + const errorsMsg = result.current.pushCallouts?.props.messages; + expect(errorsMsg).toHaveLength(1); + expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); + }); + }); + it('Displays message when case is closed', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( diff --git a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx index 157639f011fef..ae8a67b75d36c 100644 --- a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx @@ -29,6 +29,7 @@ export interface UsePushToService { connectors: Connector[]; updateCase: (newCase: Case) => void; userCanCrud: boolean; + isValidConnector: boolean; } export interface ReturnUsePushToService { @@ -45,6 +46,7 @@ export const usePushToService = ({ connectors, updateCase, userCanCrud, + isValidConnector, }: UsePushToService): ReturnUsePushToService => { const urlSearch = useGetUrlSearch(navTabs.case); @@ -77,7 +79,7 @@ export const usePushToService = ({ description: ( @@ -97,7 +99,20 @@ export const usePushToService = ({ description: ( + ), + }, + ]; + } else if (!isValidConnector && !loadingLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + description: ( + ), }, @@ -130,7 +145,9 @@ export const usePushToService = ({ fill iconType="importAction" onClick={handlePushToService} - disabled={isLoading || loadingLicense || errorsMsg.length > 0 || !userCanCrud} + disabled={ + isLoading || loadingLicense || errorsMsg.length > 0 || !userCanCrud || !isValidConnector + } isLoading={isLoading} > {caseServices[caseConnectorId] @@ -147,6 +164,7 @@ export const usePushToService = ({ isLoading, loadingLicense, userCanCrud, + isValidConnector, ]); const objToReturn = useMemo(() => { diff --git a/x-pack/plugins/siem/public/cases/components/use_push_to_service/translations.ts b/x-pack/plugins/siem/public/cases/components/use_push_to_service/translations.ts index bdd6ae98a5d01..4b55aa83ef726 100644 --- a/x-pack/plugins/siem/public/cases/components/use_push_to_service/translations.ts +++ b/x-pack/plugins/siem/public/cases/components/use_push_to_service/translations.ts @@ -15,9 +15,10 @@ export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( export const PUSH_THIRD = (thirdParty: string) => { if (thirdParty === 'none') { return i18n.translate('xpack.siem.case.caseView.pushThirdPartyIncident', { - defaultMessage: 'Push as third party incident', + defaultMessage: 'Push as external incident', }); } + return i18n.translate('xpack.siem.case.caseView.pushNamedIncident', { values: { thirdParty }, defaultMessage: 'Push as { thirdParty } incident', @@ -27,9 +28,10 @@ export const PUSH_THIRD = (thirdParty: string) => { export const UPDATE_THIRD = (thirdParty: string) => { if (thirdParty === 'none') { return i18n.translate('xpack.siem.case.caseView.updateThirdPartyIncident', { - defaultMessage: 'Update third party incident', + defaultMessage: 'Update external incident', }); } + return i18n.translate('xpack.siem.case.caseView.updateNamedIncident', { values: { thirdParty }, defaultMessage: 'Update { thirdParty } incident', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index abc54183ca41a..e49cfbbc67d61 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13131,7 +13131,6 @@ "xpack.siem.case.caseView.pushToServiceDisableByConfigTitle": "Kibana の構成ファイルで ServiceNow を有効にする", "xpack.siem.case.caseView.pushToServiceDisableByLicenseDescription": "外部システムでケースを開くには、ライセンスをプラチナに更新するか、30 日間の無料トライアルを開始するか、AWS、GCP、または Azure で {link} にサインアップする必要があります。", "xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle": "E lastic Platinum へのアップグレード", - "xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "外部システムでケースを開いて更新するには、{link} を設定する必要があります。", "xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "外部コネクターを構成", "xpack.siem.case.caseView.reopenCase": "ケースを再開", "xpack.siem.case.caseView.reopenedCase": "ケースを再開する", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b86d137f0692d..1d3eb0e3a0da8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13138,7 +13138,6 @@ "xpack.siem.case.caseView.pushToServiceDisableByConfigTitle": "在 Kibana 配置文件中启用 ServiceNow", "xpack.siem.case.caseView.pushToServiceDisableByLicenseDescription": "要在外部系统中打开案例,必须将许可证更新到白金级,开始为期 30 天的免费试用,或在 AWS、GCP 或 Azure 上快速部署 {link}。", "xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle": "升级到 Elastic 白金级", - "xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "要在外部系统上打开和更新案例,必须配置 {link}。", "xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "配置外部连接器", "xpack.siem.case.caseView.reopenCase": "重新打开案例", "xpack.siem.case.caseView.reopenedCase": "重新打开的案例", diff --git a/x-pack/test/case_api_integration/basic/config.ts b/x-pack/test/case_api_integration/basic/config.ts index f9c248ec3d56f..e711560e11097 100644 --- a/x-pack/test/case_api_integration/basic/config.ts +++ b/x-pack/test/case_api_integration/basic/config.ts @@ -9,6 +9,6 @@ import { createTestConfig } from '../common/config'; // eslint-disable-next-line import/no-default-export export default createTestConfig('basic', { disabledPlugins: [], - license: 'basic', + license: 'trial', ssl: true, }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index 848b980dee769..2c1c4369e3ccd 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../alerting_api_integration/common/lib'; import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../plugins/case/common/constants'; import { postCaseReq, defaultUser, postCommentReq } from '../../../common/lib/mock'; @@ -15,6 +16,7 @@ import { deleteComments, deleteConfiguration, getConfiguration, + getConnector, } from '../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export @@ -23,19 +25,31 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('push_case', () => { + const actionsRemover = new ActionsRemover(supertest); + afterEach(async () => { await deleteCases(es); await deleteComments(es); await deleteConfiguration(es); await deleteCasesUserActions(es); + await actionsRemover.removeAll(); }); it('should push a case', async () => { + const { body: connector } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'true') + .send(getConnector()) + .expect(200); + + actionsRemover.add('default', connector.id, 'action'); + const { body: configure } = await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration()) + .send(getConfiguration(connector.id)) .expect(200); + const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -58,11 +72,20 @@ export default ({ getService }: FtrProviderContext): void => { }); it('pushes a comment appropriately', async () => { + const { body: connector } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'true') + .send(getConnector()) + .expect(200); + + actionsRemover.add('default', connector.id, 'action'); + const { body: configure } = await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration()) + .send(getConfiguration(connector.id)) .expect(200); + const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -99,6 +122,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); expect(body.comments[0].pushed_by).to.eql(defaultUser); }); + it('unhappy path - 404s when case does not exist', async () => { await supertest .post(`${CASES_URL}/fake-id/_push`) diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts index 862705ab9610b..8df4ff66c2a2a 100644 --- a/x-pack/test/case_api_integration/common/config.ts +++ b/x-pack/test/case_api_integration/common/config.ts @@ -24,6 +24,7 @@ const enabledActionTypes = [ '.pagerduty', '.server-log', '.servicenow', + '.jira', '.slack', '.webhook', 'test.authorization', diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 4b1dc6ffa5891..5861db2eb8e5b 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -23,6 +23,37 @@ export const getConfigurationOutput = (update = false): Partial ({ + name: 'ServiceNow Connector', + actionTypeId: '.servicenow', + secrets: { + username: 'admin', + password: 'password', + }, + config: { + apiUrl: 'http://some.non.existent.com', + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'append', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, + ], + }, + }, +}); + export const removeServerGeneratedPropertiesFromConfigure = ( config: Partial ): Partial => {