Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Cases] Add duration #130448

Merged
merged 11 commits into from
Apr 27, 2022
1 change: 1 addition & 0 deletions x-pack/plugins/cases/common/api/cases/case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, r
export const CaseAttributesRt = rt.intersection([
CaseBasicRt,
rt.type({
duration: rt.union([rt.number, rt.null]),
closed_at: rt.union([rt.string, rt.null]),
closed_by: rt.union([UserRT, rt.null]),
created_at: rt.string,
Expand Down
29 changes: 2 additions & 27 deletions x-pack/plugins/cases/common/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

import type { SavedObjectsResolveResponse } from '@kbn/core/public';
import {
CaseAttributes,
CaseConnector,
CasePatchRequest,
CaseStatuses,
User,
Expand All @@ -17,6 +15,7 @@ import {
CaseUserActionResponse,
CaseMetricsResponse,
CommentResponse,
CaseResponse,
CommentResponseAlertsType,
} from '../api';
import { SnakeToCamelCase } from '../types';
Expand Down Expand Up @@ -61,31 +60,7 @@ export type Comment = SnakeToCamelCase<CommentResponse>;
export type AlertComment = SnakeToCamelCase<CommentResponseAlertsType>;
export type CaseUserActions = SnakeToCamelCase<CaseUserActionResponse>;
export type CaseExternalService = SnakeToCamelCase<CaseExternalServiceBasic>;

interface BasicCase {
id: string;
owner: string;
closedAt: string | null;
closedBy: ElasticUser | null;
comments: Comment[];
createdAt: string;
createdBy: ElasticUser;
status: CaseStatuses;
title: string;
totalAlerts: number;
totalComment: number;
updatedAt: string | null;
updatedBy: ElasticUser | null;
version: string;
}

export interface Case extends BasicCase {
connector: CaseConnector;
description: string;
externalService: CaseExternalService | null;
settings: CaseAttributes['settings'];
tags: string[];
}
export type Case = Omit<SnakeToCamelCase<CaseResponse>, 'comments'> & { comments: Comment[] };

export interface ResolvedCase {
case: Case;
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/cases/public/containers/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const basicCase: Case = {
fields: null,
},
description: 'Security banana Issue',
duration: null,
externalService: null,
status: CaseStatuses.open,
tags,
Expand Down Expand Up @@ -245,6 +246,7 @@ export const mockCase: Case = {
type: ConnectorTypes.none,
fields: null,
},
duration: null,
description: 'Security banana Issue',
externalService: null,
status: CaseStatuses.open,
Expand Down Expand Up @@ -383,6 +385,7 @@ export const basicCaseSnake: CaseResponse = {
connector: { id: 'none', name: 'My Connector', type: ConnectorTypes.none, fields: null },
created_at: basicCreatedAt,
created_by: elasticUserSnake,
duration: null,
external_service: null,
updated_at: basicUpdatedAt,
updated_by: elasticUserSnake,
Expand Down
33 changes: 13 additions & 20 deletions x-pack/plugins/cases/server/client/cases/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import { UpdateAlertRequest } from '../alerts/types';
import { CasesClientArgs } from '..';
import { Operations, OwnerEntity } from '../../authorization';
import { getClosedInfoForUpdate, getDurationForUpdate } from './utils';

/**
* Throws an error if any of the requests attempt to update the owner of a case.
Expand Down Expand Up @@ -311,37 +312,29 @@ export const update = async (
throwIfUpdateOwner(updateCases);
throwIfTitleIsInvalid(updateCases);

// eslint-disable-next-line @typescript-eslint/naming-convention
const { username, full_name, email } = user;
const updatedDt = new Date().toISOString();
const updatedCases = await caseService.patchCases({
cases: updateCases.map(({ updateReq, originalCase }) => {
// intentionally removing owner from the case so that we don't accidentally allow it to be updated
const { id: caseId, version, owner, ...updateCaseAttributes } = updateReq;
let closedInfo = {};
if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a helper function getClosedInfoForUpdate and move the logic there.

closedInfo = {
closed_at: updatedDt,
closed_by: { email, full_name, username },
};
} else if (
updateCaseAttributes.status &&
(updateCaseAttributes.status === CaseStatuses.open ||
updateCaseAttributes.status === CaseStatuses['in-progress'])
) {
closedInfo = {
closed_at: null,
closed_by: null,
};
}

return {
caseId,
originalCase,
updatedAttributes: {
...updateCaseAttributes,
...closedInfo,
...getClosedInfoForUpdate({
user,
closedDate: updatedDt,
status: updateCaseAttributes.status,
}),
...getDurationForUpdate({
status: updateCaseAttributes.status,
closedAt: updatedDt,
createdAt: originalCase.attributes.created_at,
}),
updated_at: updatedDt,
updated_by: { email, full_name, username },
updated_by: user,
},
version,
};
Expand Down
120 changes: 119 additions & 1 deletion x-pack/plugins/cases/server/client/cases/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ import {

import {
createIncident,
getClosedInfoForUpdate,
getDurationForUpdate,
getLatestPushInfo,
prepareFieldsForTransformation,
transformComments,
transformers,
transformFields,
} from './utils';
import { Actions } from '../../../common/api';
import { Actions, CaseStatuses } from '../../../common/api';
import { flattenCaseSavedObject } from '../../common/utils';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { casesConnectors } from '../../connectors';
Expand Down Expand Up @@ -836,5 +838,121 @@ describe('utils', () => {
});
});
});

describe('getClosedInfoForUpdate', () => {
const date = '2021-02-03T17:41:26.108Z';
const user = { full_name: 'Elastic', username: 'elastic', email: 'elastic@elastic.co' };

it('returns the correct closed info when the case closes', async () => {
expect(
getClosedInfoForUpdate({ status: CaseStatuses.closed, closedDate: date, user })
).toEqual({
closed_at: date,
closed_by: user,
});
});

it.each([[CaseStatuses.open], [CaseStatuses['in-progress']]])(
'returns the correct closed info when the case %s',
async (status) => {
expect(getClosedInfoForUpdate({ status, closedDate: date, user })).toEqual({
closed_at: null,
closed_by: null,
});
}
);

it('returns undefined if the status is not provided', async () => {
expect(getClosedInfoForUpdate({ closedDate: date, user })).toBe(undefined);
});
});

describe('getDurationForUpdate', () => {
const createdAt = '2021-11-23T19:00:00Z';
const closedAt = '2021-11-23T19:02:00Z';

it('returns the correct duration when the case closes', () => {
expect(getDurationForUpdate({ status: CaseStatuses.closed, closedAt, createdAt })).toEqual({
duration: 120,
});
});

it.each([[CaseStatuses.open], [CaseStatuses['in-progress']]])(
'returns the correct duration when the case %s',
(status) => {
expect(
getDurationForUpdate({ status: CaseStatuses.closed, closedAt, createdAt })
).toEqual({
duration: 120,
});
}
);

it('returns undefined if the status is not provided', async () => {
expect(getDurationForUpdate({ closedAt, createdAt })).toBe(undefined);
});

it.each([['invalid'], [null]])(
'returns undefined if the createdAt date is %s',
(createdAtInvalid) => {
expect(
getDurationForUpdate({
status: CaseStatuses.closed,
closedAt,
// @ts-expect-error
createdAt: createdAtInvalid,
})
).toBe(undefined);
}
);

it.each([['invalid'], [null]])(
'returns undefined if the createdAt date is %s',
(closedAtInvalid) => {
expect(
getDurationForUpdate({
status: CaseStatuses.closed,
// @ts-expect-error
closedAt: closedAtInvalid,
createdAt,
})
).toBe(undefined);
}
);

it('returns undefined if if created_at > closed_at', async () => {
expect(
getDurationForUpdate({
status: CaseStatuses.closed,
closedAt: '2021-11-23T19:00:00Z',
createdAt: '2021-11-23T19:05:00Z',
})
).toBe(undefined);
});

it('rounds the seconds correctly', () => {
expect(
getDurationForUpdate({
status: CaseStatuses.closed,
createdAt: '2022-04-11T15:56:00.087Z',
closedAt: '2022-04-11T15:58:56.187Z',
})
).toEqual({
duration: 176,
});
});

it('rounds the zero correctly', () => {
expect(
getDurationForUpdate({
status: CaseStatuses.closed,
createdAt: '2022-04-11T15:56:00.087Z',
closedAt: '2022-04-11T15:56:00.187Z',
})
).toEqual({
duration: 0,
});
});
});
});
});
62 changes: 62 additions & 0 deletions x-pack/plugins/cases/server/client/cases/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import {
CommentRequestAlertType,
CommentRequestActionsType,
ActionTypes,
CaseStatuses,
User,
CaseAttributes,
} from '../../../common/api';
import { CasesClientGetAlertsResponse } from '../alerts/types';
import {
Expand Down Expand Up @@ -405,3 +408,62 @@ export const getCommentContextFromAttributes = (
};
}
};

export const getClosedInfoForUpdate = ({
user,
status,
closedDate,
}: {
closedDate: string;
user: User;
status?: CaseStatuses;
}): Pick<CaseAttributes, 'closed_at' | 'closed_by'> | undefined => {
if (status && status === CaseStatuses.closed) {
return {
closed_at: closedDate,
closed_by: user,
};
}

if (status && (status === CaseStatuses.open || status === CaseStatuses['in-progress'])) {
return {
closed_at: null,
closed_by: null,
};
}
};

export const getDurationForUpdate = ({
status,
closedAt,
createdAt,
}: {
closedAt: string;
createdAt: CaseAttributes['created_at'];
status?: CaseStatuses;
}): Pick<CaseAttributes, 'duration'> | undefined => {
if (status && status === CaseStatuses.closed) {
try {
if (createdAt != null && closedAt != null) {
const createdAtMillis = new Date(createdAt).getTime();
const closedAtMillis = new Date(closedAt).getTime();

if (
!isNaN(createdAtMillis) &&
!isNaN(closedAtMillis) &&
closedAtMillis >= createdAtMillis
) {
return { duration: Math.floor((closedAtMillis - createdAtMillis) / 1000) };
}
}
} catch (err) {
// Silence date errors
}
}

if (status && (status === CaseStatuses.open || status === CaseStatuses['in-progress'])) {
return {
duration: null,
};
}
};
1 change: 1 addition & 0 deletions x-pack/plugins/cases/server/client/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('utils', () => {
"username": "elastic",
},
"description": "A description",
"duration": null,
"external_service": null,
"owner": "securitySolution",
"settings": Object {
Expand Down
Loading