[SIEM] [Cases] External services not getting all comments bug fix (#65307)

This commit is contained in:
Steph Milovic 2020-05-06 16:09:25 -06:00 committed by GitHub
parent c00b36e9e3
commit c03bdccce1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 260 additions and 58 deletions

View file

@ -50,19 +50,11 @@ export const REOPENED_CASES = ({
defaultMessage: 'Reopened {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}',
});
export const TAG_FETCH_FAILURE = i18n.translate(
'xpack.siem.containers.case.tagFetchFailDescription',
{
defaultMessage: 'Failed to fetch Tags',
}
);
export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = i18n.translate(
'xpack.siem.containers.case.pushToExterService',
{
defaultMessage: 'Successfully sent to ServiceNow',
}
);
export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = (serviceName: string) =>
i18n.translate('xpack.siem.containers.case.pushToExternalService', {
values: { serviceName },
defaultMessage: 'Successfully sent to { serviceName }',
});
export const ERROR_PUSH_TO_SERVICE = i18n.translate(
'xpack.siem.case.configure.errorPushingToService',

View file

@ -122,13 +122,14 @@ describe('useGetCaseUserActions', () => {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 3,
commentsToUpdate: [],
hasDataToPush: false,
},
},
});
});
it('Correctly marks first/last index - hasDataToPush: true', () => {
it('Correctly marks first/last index and comment id - hasDataToPush: true', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
@ -142,6 +143,83 @@ describe('useGetCaseUserActions', () => {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 3,
commentsToUpdate: [userActions[userActions.length - 1].commentId],
hasDataToPush: true,
},
},
});
});
it('Correctly marks first/last index and multiple comment ids, both needs push', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
getUserAction(['comment'], 'create'),
{ ...getUserAction(['comment'], 'create'), commentId: 'muahaha' },
];
const result = getPushedInfo(userActions, '123');
expect(result).toEqual({
hasDataToPush: true,
caseServices: {
'123': {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 3,
commentsToUpdate: [
userActions[userActions.length - 2].commentId,
userActions[userActions.length - 1].commentId,
],
hasDataToPush: true,
},
},
});
});
it('Correctly marks first/last index and multiple comment ids, one needs push', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
getUserAction(['comment'], 'create'),
getUserAction(['pushed'], 'push-to-service'),
{ ...getUserAction(['comment'], 'create'), commentId: 'muahaha' },
];
const result = getPushedInfo(userActions, '123');
expect(result).toEqual({
hasDataToPush: true,
caseServices: {
'123': {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 5,
commentsToUpdate: [userActions[userActions.length - 1].commentId],
hasDataToPush: true,
},
},
});
});
it('Correctly marks first/last index and multiple comment ids, one needs push and one needs update', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
getUserAction(['comment'], 'create'),
getUserAction(['pushed'], 'push-to-service'),
{ ...getUserAction(['comment'], 'create'), commentId: 'muahaha' },
getUserAction(['comment'], 'update'),
getUserAction(['comment'], 'update'),
];
const result = getPushedInfo(userActions, '123');
expect(result).toEqual({
hasDataToPush: true,
caseServices: {
'123': {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 5,
commentsToUpdate: [
userActions[userActions.length - 3].commentId,
userActions[userActions.length - 1].commentId,
],
hasDataToPush: true,
},
},
@ -162,6 +240,7 @@ describe('useGetCaseUserActions', () => {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 3,
commentsToUpdate: [],
hasDataToPush: false,
},
},
@ -182,11 +261,34 @@ describe('useGetCaseUserActions', () => {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 5,
commentsToUpdate: [],
hasDataToPush: false,
},
},
});
});
it('Correctly handles comment update with multiple push actions', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
getUserAction(['comment'], 'create'),
getUserAction(['pushed'], 'push-to-service'),
getUserAction(['comment'], 'update'),
];
const result = getPushedInfo(userActions, '123');
expect(result).toEqual({
hasDataToPush: true,
caseServices: {
'123': {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 5,
commentsToUpdate: [userActions[userActions.length - 1].commentId],
hasDataToPush: true,
},
},
});
});
it('Multiple connector tracking - hasDataToPush: true', () => {
const pushAction123 = getUserAction(['pushed'], 'push-to-service');
@ -215,6 +317,7 @@ describe('useGetCaseUserActions', () => {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 3,
commentsToUpdate: [userActions[userActions.length - 2].commentId],
hasDataToPush: true,
},
'456': {
@ -224,6 +327,7 @@ describe('useGetCaseUserActions', () => {
externalId: 'other_external_id',
firstPushIndex: 5,
lastPushIndex: 5,
commentsToUpdate: [],
hasDataToPush: false,
},
},
@ -257,6 +361,7 @@ describe('useGetCaseUserActions', () => {
...basicPush,
firstPushIndex: 3,
lastPushIndex: 3,
commentsToUpdate: [userActions[userActions.length - 2].commentId],
hasDataToPush: true,
},
'456': {
@ -266,6 +371,7 @@ describe('useGetCaseUserActions', () => {
externalId: 'other_external_id',
firstPushIndex: 5,
lastPushIndex: 5,
commentsToUpdate: [],
hasDataToPush: false,
},
},

View file

@ -14,9 +14,10 @@ import { CaseExternalService, CaseUserActions, ElasticUser } from './types';
import { convertToCamelCase, parseString } from './utils';
import { CaseFullExternalService } from '../../../../case/common/api/cases';
interface CaseService extends CaseExternalService {
export interface CaseService extends CaseExternalService {
firstPushIndex: number;
lastPushIndex: number;
commentsToUpdate: string[];
hasDataToPush: boolean;
}
@ -48,6 +49,10 @@ export interface UseGetCaseUserActions extends CaseUserActionsState {
const getExternalService = (value: string): CaseExternalService | null =>
convertToCamelCase<CaseFullExternalService, CaseExternalService>(parseString(`${value}`));
interface CommentsAndIndex {
commentId: string;
commentIndex: number;
}
export const getPushedInfo = (
caseUserActions: CaseUserActions[],
@ -69,11 +74,25 @@ export const getPushedInfo = (
.action !== 'push-to-service'
);
};
const commentsAndIndex = caseUserActions.reduce<CommentsAndIndex[]>(
(bacc, mua, index) =>
mua.actionField[0] === 'comment' && mua.commentId != null
? [
...bacc,
{
commentId: mua.commentId,
commentIndex: index,
},
]
: bacc,
[]
);
const caseServices = caseUserActions.reduce<CaseServices>((acc, cua, i) => {
let caseServices = caseUserActions.reduce<CaseServices>((acc, cua, i) => {
if (cua.action !== 'push-to-service') {
return acc;
}
const externalService = getExternalService(`${cua.newValue}`);
if (externalService === null) {
return acc;
@ -87,6 +106,7 @@ export const getPushedInfo = (
...acc[externalService.connectorId],
...externalService,
lastPushIndex: i,
commentsToUpdate: [],
},
}
: {
@ -95,11 +115,31 @@ export const getPushedInfo = (
firstPushIndex: i,
lastPushIndex: i,
hasDataToPush: hasDataToPushForConnector(externalService.connectorId),
commentsToUpdate: [],
},
}),
};
}, {});
caseServices = Object.keys(caseServices).reduce<CaseServices>((acc, key) => {
return {
...acc,
[key]: {
...caseServices[key],
// if the comment happens after the lastUpdateToCaseIndex, it should be included in commentsToUpdate
commentsToUpdate: commentsAndIndex.reduce<string[]>(
(bacc, currentComment) =>
currentComment.commentIndex > caseServices[key].lastPushIndex
? bacc.indexOf(currentComment.commentId) > -1
? [...bacc.filter(e => e !== currentComment.commentId), currentComment.commentId]
: [...bacc, currentComment.commentId]
: bacc,
[]
),
},
};
}, {});
const hasDataToPush =
caseServices[caseConnectorId] != null ? caseServices[caseConnectorId].hasDataToPush : true;
return {

View file

@ -19,6 +19,7 @@ import {
serviceConnectorUser,
} from './mock';
import * as api from './api';
import { CaseServices } from './use_get_case_user_actions';
jest.mock('./api');
@ -32,6 +33,7 @@ describe('usePostPushToService', () => {
...basicPush,
firstPushIndex: 1,
lastPushIndex: 1,
commentsToUpdate: [basicComment.id],
hasDataToPush: false,
},
},
@ -64,6 +66,7 @@ describe('usePostPushToService', () => {
...basicPush,
firstPushIndex: 1,
lastPushIndex: 1,
commentsToUpdate: [basicComment.id],
hasDataToPush: true,
},
'456': {
@ -71,6 +74,7 @@ describe('usePostPushToService', () => {
connectorId: '456',
externalId: 'other_external_id',
firstPushIndex: 4,
commentsToUpdate: [basicComment.id],
lastPushIndex: 6,
hasDataToPush: false,
},
@ -127,6 +131,31 @@ describe('usePostPushToService', () => {
await waitForNextUpdate();
expect(spyOnPushToService).toBeCalledWith(
samplePush.connectorId,
formatServiceRequestData(basicCase, '123', sampleCaseServices as CaseServices),
abortCtrl.signal
);
});
});
it('calls pushToService with correct arguments when no push history', async () => {
const samplePush2 = {
caseId: pushedCase.id,
caseServices: {},
connectorName: 'connector name',
connectorId: 'none',
updateCase,
};
const spyOnPushToService = jest.spyOn(api, 'pushToService');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostPushToService>(() =>
usePostPushToService()
);
await waitForNextUpdate();
result.current.postPushToService(samplePush2);
await waitForNextUpdate();
expect(spyOnPushToService).toBeCalledWith(
samplePush2.connectorId,
formatServiceRequestData(basicCase, 'none', {}),
abortCtrl.signal
);

View file

@ -122,7 +122,10 @@ export const usePostPushToService = (): UsePostPushToService => {
dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService });
dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase });
updateCase(responseCase);
displaySuccessToast(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE, dispatchToaster);
displaySuccessToast(
i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connectorName),
dispatchToaster
);
}
} catch (error) {
if (!cancel) {
@ -156,25 +159,12 @@ export const formatServiceRequestData = (
createdBy,
comments,
description,
externalService,
title,
updatedAt,
updatedBy,
} = myCase;
let actualExternalService = externalService;
if (
externalService != null &&
externalService.connectorId !== connectorId &&
caseServices[connectorId]
) {
actualExternalService = caseServices[connectorId];
} else if (
externalService != null &&
externalService.connectorId !== connectorId &&
!caseServices[connectorId]
) {
actualExternalService = null;
}
const actualExternalService = caseServices[connectorId] ?? null;
return {
caseId,
createdAt,
@ -183,17 +173,9 @@ export const formatServiceRequestData = (
username: createdBy?.username ?? '',
},
comments: comments
.filter(c => {
const lastPush = c.pushedAt != null ? new Date(c.pushedAt) : null;
const lastUpdate = c.updatedAt != null ? new Date(c.updatedAt) : null;
if (
lastPush === null ||
(lastPush != null && lastUpdate != null && lastPush.getTime() < lastUpdate?.getTime())
) {
return true;
}
return false;
})
.filter(
c => actualExternalService == null || actualExternalService.commentsToUpdate.includes(c.id)
)
.map(c => ({
commentId: c.id,
comment: c.comment,

View file

@ -20,6 +20,7 @@ import * as i18n from '../case_view/translations';
import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date';
import { CaseViewActions } from '../case_view/actions';
import { Case } from '../../../../containers/case/types';
import { CaseService } from '../../../../containers/case/use_get_case_user_actions';
const MyDescriptionList = styled(EuiDescriptionList)`
${({ theme }) => css`
@ -35,6 +36,7 @@ interface CaseStatusProps {
badgeColor: string;
buttonLabel: string;
caseData: Case;
currentExternalIncident: CaseService | null;
disabled?: boolean;
icon: string;
isLoading: boolean;
@ -50,6 +52,7 @@ const CaseStatusComp: React.FC<CaseStatusProps> = ({
badgeColor,
buttonLabel,
caseData,
currentExternalIncident,
disabled = false,
icon,
isLoading,
@ -100,7 +103,11 @@ const CaseStatusComp: React.FC<CaseStatusProps> = ({
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CaseViewActions caseData={caseData} disabled={disabled} />
<CaseViewActions
caseData={caseData}
currentExternalIncident={currentExternalIncident}
disabled={disabled}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -9,8 +9,9 @@ import { mount } from 'enzyme';
import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
import { TestProviders } from '../../../../mock';
import { basicCase } from '../../../../containers/case/mock';
import { basicCase, basicPush } from '../../../../containers/case/mock';
import { CaseViewActions } from './actions';
import * as i18n from './translations';
jest.mock('../../../../containers/case/use_delete_cases');
const useDeleteCasesMock = useDeleteCases as jest.Mock;
@ -34,7 +35,7 @@ describe('CaseView actions', () => {
it('clicking trash toggles modal', () => {
const wrapper = mount(
<TestProviders>
<CaseViewActions caseData={basicCase} />
<CaseViewActions caseData={basicCase} currentExternalIncident={null} />
</TestProviders>
);
@ -54,7 +55,7 @@ describe('CaseView actions', () => {
}));
const wrapper = mount(
<TestProviders>
<CaseViewActions caseData={basicCase} />
<CaseViewActions caseData={basicCase} currentExternalIncident={null} />
</TestProviders>
);
@ -64,4 +65,33 @@ describe('CaseView actions', () => {
{ id: basicCase.id, title: basicCase.title },
]);
});
it('displays active incident link', () => {
const wrapper = mount(
<TestProviders>
<CaseViewActions
caseData={basicCase}
currentExternalIncident={{
...basicPush,
firstPushIndex: 5,
lastPushIndex: 5,
commentsToUpdate: [],
hasDataToPush: false,
}}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
wrapper
.find('button[data-test-subj="property-actions-ellipses"]')
.first()
.simulate('click');
expect(
wrapper
.find('[data-test-subj="property-actions-popout"]')
.first()
.prop('aria-label')
).toEqual(i18n.VIEW_INCIDENT(basicPush.externalTitle));
});
});

View file

@ -13,13 +13,19 @@ import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import { SiemPageName } from '../../../home/types';
import { PropertyActions } from '../property_actions';
import { Case } from '../../../../containers/case/types';
import { CaseService } from '../../../../containers/case/use_get_case_user_actions';
interface CaseViewActions {
caseData: Case;
currentExternalIncident: CaseService | null;
disabled?: boolean;
}
const CaseViewActionsComponent: React.FC<CaseViewActions> = ({ caseData, disabled = false }) => {
const CaseViewActionsComponent: React.FC<CaseViewActions> = ({
caseData,
currentExternalIncident,
disabled = false,
}) => {
// Delete case
const {
handleToggleModal,
@ -48,17 +54,17 @@ const CaseViewActionsComponent: React.FC<CaseViewActions> = ({ caseData, disable
label: i18n.DELETE_CASE,
onClick: handleToggleModal,
},
...(caseData.externalService != null && !isEmpty(caseData.externalService?.externalUrl)
...(currentExternalIncident != null && !isEmpty(currentExternalIncident?.externalUrl)
? [
{
iconType: 'popout',
label: i18n.VIEW_INCIDENT(caseData.externalService?.externalTitle ?? ''),
onClick: () => window.open(caseData.externalService?.externalUrl, '_blank'),
label: i18n.VIEW_INCIDENT(currentExternalIncident?.externalTitle ?? ''),
onClick: () => window.open(currentExternalIncident?.externalUrl, '_blank'),
},
]
: []),
],
[disabled, handleToggleModal, caseData]
[disabled, handleToggleModal, currentExternalIncident]
);
if (isDeleted) {

View file

@ -70,6 +70,7 @@ describe('CaseView ', () => {
const defaultUseGetCaseUserActions = {
caseUserActions,
caseServices: {},
fetchCaseUserActions,
firstIndexPushToService: -1,
hasDataToPush: false,

View file

@ -164,6 +164,15 @@ export const CaseComponent = React.memo<CaseProps>(
() => connectors.find(c => c.id === caseData.connectorId)?.name ?? 'none',
[connectors, caseData.connectorId]
);
const currentExternalIncident = useMemo(
() =>
caseServices != null && caseServices[caseData.connectorId] != null
? caseServices[caseData.connectorId]
: null,
[caseServices, caseData.connectorId]
);
const { pushButton, pushCallouts } = usePushToService({
caseConnectorId: caseData.connectorId,
caseConnectorName,
@ -254,6 +263,7 @@ export const CaseComponent = React.memo<CaseProps>(
title={caseData.title}
>
<CaseStatus
currentExternalIncident={currentExternalIncident}
caseData={caseData}
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'status'}

View file

@ -32,7 +32,7 @@ export const getKibanaConfigError = () => ({
title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE,
description: (
<FormattedMessage
defaultMessage="The kibana.yml file is configured to only allow specific connectors. To enable opening a case in external systems, add .servicenow to the xpack.actions.enabledActiontypes setting. For more information, see {link}."
defaultMessage="The kibana.yml file is configured to only allow specific connectors. To enable opening a case in external systems, add .[actionTypeId] (ex: .servicenow | .jira) to the xpack.actions.enabledActiontypes setting. For more information, see {link}."
id="xpack.siem.case.caseView.pushToServiceDisableByConfigDescription"
values={{
link: (

View file

@ -33,6 +33,7 @@ describe('usePushToService', () => {
...basicPush,
firstPushIndex: 0,
lastPushIndex: 0,
commentsToUpdate: [],
hasDataToPush: true,
},
};

View file

@ -60,7 +60,7 @@ export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate(
export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate(
'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle',
{
defaultMessage: 'Enable ServiceNow in Kibana configuration file',
defaultMessage: 'Enable external service in Kibana configuration file',
}
);

View file

@ -86,6 +86,7 @@ describe('UserActionTree ', () => {
...basicPush,
firstPushIndex: 0,
lastPushIndex: 0,
commentsToUpdate: [`${ourActions[ourActions.length - 1].commentId}`],
hasDataToPush: true,
},
},
@ -111,6 +112,7 @@ describe('UserActionTree ', () => {
...basicPush,
firstPushIndex: 0,
lastPushIndex: 0,
commentsToUpdate: [],
hasDataToPush: false,
},
},

View file

@ -13281,8 +13281,6 @@
"xpack.siem.containers.anomalies.stackByJobId": "ジョブ",
"xpack.siem.containers.anomalies.title": "異常",
"xpack.siem.containers.case.errorTitle": "データの取得中にエラーが発生",
"xpack.siem.containers.case.pushToExterService": "ServiceNow への送信が正常に完了しました",
"xpack.siem.containers.case.tagFetchFailDescription": "タグを取得できませんでした",
"xpack.siem.containers.detectionEngine.addRuleFailDescription": "ルールを追加できませんでした",
"xpack.siem.containers.detectionEngine.createPrePackagedRuleFailDescription": "Elasticから事前にパッケージ化されているルールをインストールすることができませんでした",
"xpack.siem.containers.detectionEngine.createPrePackagedRuleSuccesDescription": "Elasticから事前にパッケージ化されているルールをインストールしました",

View file

@ -13288,8 +13288,6 @@
"xpack.siem.containers.anomalies.stackByJobId": "作业",
"xpack.siem.containers.anomalies.title": "异常",
"xpack.siem.containers.case.errorTitle": "提取数据时出错",
"xpack.siem.containers.case.pushToExterService": "已成功发送到 ServiceNow",
"xpack.siem.containers.case.tagFetchFailDescription": "无法提取标记",
"xpack.siem.containers.detectionEngine.addRuleFailDescription": "无法添加规则",
"xpack.siem.containers.detectionEngine.createPrePackagedRuleFailDescription": "无法安装 elastic 的预打包规则",
"xpack.siem.containers.detectionEngine.createPrePackagedRuleSuccesDescription": "已安装 elastic 的预打包规则",