Skip to content

Commit d6327fd

Browse files
committed
[SIEM][CASE] Fix bug when connector is deleted. (#65876)
# Conflicts: # x-pack/plugins/siem/public/cases/components/case_view/index.test.tsx # x-pack/plugins/siem/public/cases/components/case_view/index.tsx # x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx # x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx # x-pack/test/case_api_integration/basic/tests/cases/push_case.ts
1 parent cbc1364 commit d6327fd

13 files changed

Lines changed: 136 additions & 39 deletions

File tree

x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const getActions = (): FindActionResult[] => [
2727
referencedByCount: 0,
2828
},
2929
{
30-
id: 'd611af27-3532-4da9-8034-271fee81d634',
30+
id: '123',
3131
actionTypeId: '.servicenow',
3232
name: 'ServiceNow',
3333
config: {

x-pack/plugins/case/server/routes/api/cases/push_case.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,23 @@ export function initPushCaseUserActionApi({
3737
async (context, request, response) => {
3838
try {
3939
const client = context.core.savedObjects.client;
40+
const actionsClient = await context.actions?.getActionsClient();
41+
4042
const caseId = request.params.case_id;
4143
const query = pipe(
4244
CaseExternalServiceRequestRt.decode(request.body),
4345
fold(throwErrors(Boom.badRequest), identity)
4446
);
47+
48+
if (actionsClient == null) {
49+
throw Boom.notFound('Action client have not been found');
50+
}
51+
4552
const { username, full_name, email } = await caseService.getUser({ request, response });
53+
4654
const pushedDate = new Date().toISOString();
4755

48-
const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([
56+
const [myCase, myCaseConfigure, totalCommentsFindByCases, connectors] = await Promise.all([
4957
caseService.getCase({
5058
client,
5159
caseId: request.params.case_id,
@@ -60,6 +68,7 @@ export function initPushCaseUserActionApi({
6068
perPage: 1,
6169
},
6270
}),
71+
actionsClient.getAll(),
6372
]);
6473

6574
if (myCase.attributes.status === 'closed') {
@@ -85,9 +94,15 @@ export function initPushCaseUserActionApi({
8594
};
8695

8796
const caseConfigureConnectorId = getConnectorId(myCaseConfigure);
97+
8898
// old case may not have new attribute connector_id, so we default to the configured system
89-
const updateConnectorId =
90-
myCase.attributes.connector_id == null ? { connector_id: caseConfigureConnectorId } : {};
99+
const updateConnectorId = {
100+
connector_id: myCase.attributes.connector_id ?? caseConfigureConnectorId,
101+
};
102+
103+
if (!connectors.some(connector => connector.id === updateConnectorId.connector_id)) {
104+
throw Boom.notFound('Connector not found or set to none');
105+
}
91106

92107
const [updatedCase, updatedComments] = await Promise.all([
93108
caseService.patchCase({

x-pack/plugins/siem/cypress/integration/cases.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
ACTION,
2828
CASE_DETAILS_DESCRIPTION,
2929
CASE_DETAILS_PAGE_TITLE,
30-
CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN,
30+
CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN,
3131
CASE_DETAILS_STATUS,
3232
CASE_DETAILS_TAGS,
3333
CASE_DETAILS_TIMELINE_MARKDOWN,
@@ -102,7 +102,7 @@ describe('Cases', () => {
102102
.eq(PARTICIPANTS)
103103
.should('have.text', case1.reporter);
104104
cy.get(CASE_DETAILS_TAGS).should('have.text', expectedTags);
105-
cy.get(CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN).should('have.attr', 'disabled');
105+
cy.get(CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN).should('have.attr', 'disabled');
106106
cy.get(CASE_DETAILS_TIMELINE_MARKDOWN).then($element => {
107107
const timelineLink = $element.prop('href').match(/http(s?):\/\/\w*:\w*(\S*)/)[0];
108108
openCaseTimeline(timelineLink);

x-pack/plugins/siem/cypress/screens/case_details.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]';
1010

1111
export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]';
1212

13-
export const CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN = '[data-test-subj="push-to-external-service"]';
13+
export const CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN =
14+
'[data-test-subj="push-to-external-service"]';
1415

1516
export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]';
1617

x-pack/plugins/siem/public/pages/case/components/case_view/index.test.tsx

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,27 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case';
1515
import { useGetCase } from '../../../../containers/case/use_get_case';
1616
import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions';
1717
import { wait } from '../../../../lib/helpers';
18-
import { usePushToService } from '../use_push_to_service';
18+
19+
import { useConnectors } from '../../../../containers/case/configure/use_connectors';
20+
import { connectorsMock } from '../../../../containers/case/configure/mock';
21+
22+
import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service';
23+
1924
jest.mock('../../../../containers/case/use_update_case');
2025
jest.mock('../../../../containers/case/use_get_case_user_actions');
2126
jest.mock('../../../../containers/case/use_get_case');
22-
jest.mock('../use_push_to_service');
27+
jest.mock('../../../../containers/case/configure/use_connectors');
28+
jest.mock('../../../../containers/case/use_post_push_to_service');
29+
2330
const useUpdateCaseMock = useUpdateCase as jest.Mock;
2431
const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock;
25-
const usePushToServiceMock = usePushToService as jest.Mock;
32+
const useConnectorsMock = useConnectors as jest.Mock;
33+
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
2634

2735
export const caseProps: CaseProps = {
2836
caseId: basicCase.id,
2937
userCanCrud: true,
30-
caseData: basicCase,
38+
caseData: { ...basicCase, connectorId: 'servicenow-2' },
3139
fetchCase: jest.fn(),
3240
updateCase: jest.fn(),
3341
};
@@ -42,6 +50,8 @@ describe('CaseView ', () => {
4250
const fetchCaseUserActions = jest.fn();
4351
const fetchCase = jest.fn();
4452
const updateCase = jest.fn();
53+
const postPushToService = jest.fn();
54+
4555
const data = caseProps.caseData;
4656
const defaultGetCase = {
4757
isLoading: false,
@@ -85,18 +95,8 @@ describe('CaseView ', () => {
8595
useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState);
8696
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
8797
useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions);
88-
usePushToServiceMock.mockImplementation(({ updateCase: updateCaseMockCall }) => ({
89-
pushButton: (
90-
<button
91-
data-test-subj="mock-button"
92-
onClick={() => updateCaseMockCall(caseProps.caseData)}
93-
type="button"
94-
>
95-
{'Hello Button'}
96-
</button>
97-
),
98-
pushCallouts: null,
99-
}));
98+
usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService }));
99+
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, isLoading: false }));
100100
});
101101

102102
it('should render CaseComponent', async () => {
@@ -328,27 +328,32 @@ describe('CaseView ', () => {
328328
...defaultUseGetCaseUserActions,
329329
hasDataToPush: true,
330330
}));
331+
331332
const wrapper = mount(
332333
<TestProviders>
333334
<Router history={mockHistory}>
334335
<CaseComponent {...{ ...caseProps, updateCase }} />
335336
</Router>
336337
</TestProviders>
337338
);
339+
340+
await wait();
341+
338342
expect(
339343
wrapper
340344
.find('[data-test-subj="has-data-to-push-button"]')
341345
.first()
342346
.exists()
343347
).toBeTruthy();
348+
344349
wrapper
345-
.find('[data-test-subj="mock-button"]')
350+
.find('[data-test-subj="push-to-external-service"]')
346351
.first()
347352
.simulate('click');
353+
348354
wrapper.update();
349-
await wait();
350-
expect(updateCase).toBeCalledWith(caseProps.caseData);
351-
expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id);
355+
356+
expect(postPushToService).toHaveBeenCalled();
352357
});
353358

354359
it('should return null if error', () => {
@@ -429,4 +434,32 @@ describe('CaseView ', () => {
429434
expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id);
430435
expect(fetchCase).toBeCalled();
431436
});
437+
438+
it('should disable the push button when connector is invalid', () => {
439+
useGetCaseUserActionsMock.mockImplementation(() => ({
440+
...defaultUseGetCaseUserActions,
441+
hasDataToPush: true,
442+
}));
443+
444+
const wrapper = mount(
445+
<TestProviders>
446+
<Router history={mockHistory}>
447+
<CaseComponent
448+
{...{
449+
...caseProps,
450+
updateCase,
451+
caseData: { ...caseProps.caseData, connectorId: 'not-exist' },
452+
}}
453+
/>
454+
</Router>
455+
</TestProviders>
456+
);
457+
458+
expect(
459+
wrapper
460+
.find('button[data-test-subj="push-to-external-service"]')
461+
.first()
462+
.prop('disabled')
463+
).toBeTruthy();
464+
});
432465
});

x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,11 @@ export const CaseComponent = React.memo<CaseProps>(
163163
);
164164

165165
const { loading: isLoadingConnectors, connectors } = useConnectors();
166-
const caseConnectorName = useMemo(
167-
() => connectors.find(c => c.id === caseData.connectorId)?.name ?? 'none',
168-
[connectors, caseData.connectorId]
169-
);
166+
167+
const [caseConnectorName, isValidConnector] = useMemo(() => {
168+
const connector = connectors.find(c => c.id === caseData.connectorId);
169+
return [connector?.name ?? 'none', !!connector];
170+
}, [connectors, caseData.connectorId]);
170171

171172
const currentExternalIncident = useMemo(
172173
() =>
@@ -185,6 +186,7 @@ export const CaseComponent = React.memo<CaseProps>(
185186
connectors,
186187
updateCase: handleUpdateCase,
187188
userCanCrud,
189+
isValidConnector,
188190
});
189191

190192
const onSubmitConnector = useCallback(

x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ describe('usePushToService', () => {
4646
connectors: connectorsMock,
4747
updateCase,
4848
userCanCrud: true,
49+
isValidConnector: true,
4950
};
51+
5052
beforeEach(() => {
5153
jest.resetAllMocks();
5254
(usePostPushToService as jest.Mock).mockImplementation(() => mockPostPush);
@@ -55,6 +57,7 @@ describe('usePushToService', () => {
5557
actionLicense,
5658
}));
5759
});
60+
5861
it('push case button posts the push with correct args', async () => {
5962
await act(async () => {
6063
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
@@ -75,6 +78,7 @@ describe('usePushToService', () => {
7578
expect(result.current.pushCallouts).toBeNull();
7679
});
7780
});
81+
7882
it('Displays message when user does not have premium license', async () => {
7983
(useGetActionLicense as jest.Mock).mockImplementation(() => ({
8084
isLoading: false,
@@ -96,6 +100,7 @@ describe('usePushToService', () => {
96100
expect(errorsMsg[0].title).toEqual(getLicenseError().title);
97101
});
98102
});
103+
99104
it('Displays message when user does not have case enabled in config', async () => {
100105
(useGetActionLicense as jest.Mock).mockImplementation(() => ({
101106
isLoading: false,
@@ -117,6 +122,7 @@ describe('usePushToService', () => {
117122
expect(errorsMsg[0].title).toEqual(getKibanaConfigError().title);
118123
});
119124
});
125+
120126
it('Displays message when user does not have a connector configured', async () => {
121127
await act(async () => {
122128
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
@@ -135,6 +141,27 @@ describe('usePushToService', () => {
135141
expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE);
136142
});
137143
});
144+
145+
it('Displays message when connector is deleted', async () => {
146+
await act(async () => {
147+
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
148+
() =>
149+
usePushToService({
150+
...defaultArgs,
151+
caseConnectorId: 'not-exist',
152+
isValidConnector: false,
153+
}),
154+
{
155+
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
156+
}
157+
);
158+
await waitForNextUpdate();
159+
const errorsMsg = result.current.pushCallouts?.props.messages;
160+
expect(errorsMsg).toHaveLength(1);
161+
expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE);
162+
});
163+
});
164+
138165
it('Displays message when case is closed', async () => {
139166
await act(async () => {
140167
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(

x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface UsePushToService {
2929
connectors: Connector[];
3030
updateCase: (newCase: Case) => void;
3131
userCanCrud: boolean;
32+
isValidConnector: boolean;
3233
}
3334

3435
export interface ReturnUsePushToService {
@@ -45,6 +46,7 @@ export const usePushToService = ({
4546
connectors,
4647
updateCase,
4748
userCanCrud,
49+
isValidConnector,
4850
}: UsePushToService): ReturnUsePushToService => {
4951
const urlSearch = useGetUrlSearch(navTabs.case);
5052

@@ -77,7 +79,7 @@ export const usePushToService = ({
7779
description: (
7880
<FormattedMessage
7981
defaultMessage="To open and update cases in external systems, you must configure a {link}."
80-
id="xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigDescription"
82+
id="xpack.siem.case.caseView.pushToServiceDisableByNoConnectors"
8183
values={{
8284
link: (
8385
<EuiLink href={getConfigureCasesUrl(urlSearch)} target="_blank">
@@ -97,7 +99,20 @@ export const usePushToService = ({
9799
description: (
98100
<FormattedMessage
99101
defaultMessage="To open and update cases in external systems, you must select an external incident management system for this case."
100-
id="xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigDesc"
102+
id="xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigDescription"
103+
/>
104+
),
105+
},
106+
];
107+
} else if (!isValidConnector && !loadingLicense) {
108+
errors = [
109+
...errors,
110+
{
111+
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
112+
description: (
113+
<FormattedMessage
114+
defaultMessage="The connector used to send updates to external service has been deleted. To update cases in external systems, select a different connector or create a new one."
115+
id="xpack.siem.case.caseView.pushToServiceDisableByInvalidConnector"
101116
/>
102117
),
103118
},
@@ -130,7 +145,9 @@ export const usePushToService = ({
130145
fill
131146
iconType="importAction"
132147
onClick={handlePushToService}
133-
disabled={isLoading || loadingLicense || errorsMsg.length > 0 || !userCanCrud}
148+
disabled={
149+
isLoading || loadingLicense || errorsMsg.length > 0 || !userCanCrud || !isValidConnector
150+
}
134151
isLoading={isLoading}
135152
>
136153
{caseServices[caseConnectorId]
@@ -147,6 +164,7 @@ export const usePushToService = ({
147164
isLoading,
148165
loadingLicense,
149166
userCanCrud,
167+
isValidConnector,
150168
]);
151169

152170
const objToReturn = useMemo(() => {

0 commit comments

Comments
 (0)