mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Cases] Total number of user actions on a case. (#161848)
Connected to https://github.com/elastic/kibana/issues/146945 ## Summary | Description | Limit | Done? | Documented? | ------------- | ---- | :---: | ---- | | Total number of user actions and comments combined on a case | 10000 | ✅ | No | ### Checklist Delete any items that are not applicable to this PR. - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### Release Notes Updating a case will now fail if the operation makes it reach more than 10000 user actions.
This commit is contained in:
parent
a487ad77bd
commit
33195fb7df
17 changed files with 1285 additions and 221 deletions
|
@ -127,6 +127,7 @@ export const MAX_DELETE_IDS_LENGTH = 100 as const;
|
|||
export const MAX_SUGGESTED_PROFILES = 10 as const;
|
||||
export const MAX_CASES_TO_UPDATE = 100 as const;
|
||||
export const MAX_BULK_CREATE_ATTACHMENTS = 100 as const;
|
||||
export const MAX_USER_ACTIONS_PER_CASE = 10000 as const;
|
||||
export const MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES = 100 as const;
|
||||
|
||||
/**
|
||||
|
|
|
@ -7,3 +7,4 @@
|
|||
|
||||
export * from './connectors_api';
|
||||
export * from './capabilities';
|
||||
export * from './validators';
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { MAX_ASSIGNEES_PER_CASE } from '../constants';
|
||||
import { areTotalAssigneesInvalid } from './validators';
|
||||
import { createUserActionServiceMock } from '../../server/services/mocks';
|
||||
import { MAX_ASSIGNEES_PER_CASE, MAX_USER_ACTIONS_PER_CASE } from '../constants';
|
||||
import { areTotalAssigneesInvalid, validateMaxUserActions } from './validators';
|
||||
|
||||
describe('validators', () => {
|
||||
describe('areTotalAssigneesInvalid', () => {
|
||||
|
@ -31,4 +32,37 @@ describe('validators', () => {
|
|||
expect(areTotalAssigneesInvalid(generateAssignees(MAX_ASSIGNEES_PER_CASE + 1))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMaxUserActions', () => {
|
||||
const caseId = 'test-case';
|
||||
const userActionService = createUserActionServiceMock();
|
||||
|
||||
userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({
|
||||
[caseId]: MAX_USER_ACTIONS_PER_CASE - 1,
|
||||
});
|
||||
|
||||
it('does not throw if the limit is not reached', async () => {
|
||||
await expect(
|
||||
validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 1 })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('throws if the max user actions per case limit is reached', async () => {
|
||||
await expect(
|
||||
validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 2 })
|
||||
).rejects.toThrow(
|
||||
`The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.`
|
||||
);
|
||||
});
|
||||
|
||||
it('the caseId does not exist in the response', async () => {
|
||||
userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({
|
||||
foobar: MAX_USER_ACTIONS_PER_CASE - 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 1 })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { MAX_ASSIGNEES_PER_CASE } from '../constants';
|
||||
import Boom from '@hapi/boom';
|
||||
|
||||
import type { CaseUserActionService } from '../../server/services';
|
||||
import { MAX_ASSIGNEES_PER_CASE, MAX_USER_ACTIONS_PER_CASE } from '../constants';
|
||||
import type { CaseAssignees } from '../types/domain';
|
||||
|
||||
export const areTotalAssigneesInvalid = (assignees?: CaseAssignees): boolean => {
|
||||
|
@ -15,3 +18,25 @@ export const areTotalAssigneesInvalid = (assignees?: CaseAssignees): boolean =>
|
|||
|
||||
return assignees.length > MAX_ASSIGNEES_PER_CASE;
|
||||
};
|
||||
|
||||
export const validateMaxUserActions = async ({
|
||||
caseId,
|
||||
userActionService,
|
||||
userActionsToAdd,
|
||||
}: {
|
||||
caseId: string;
|
||||
userActionService: CaseUserActionService;
|
||||
userActionsToAdd: number;
|
||||
}) => {
|
||||
const result = await userActionService.getMultipleCasesUserActionsTotal({
|
||||
caseIds: [caseId],
|
||||
});
|
||||
|
||||
const totalUserActions = result[caseId] ?? 0;
|
||||
|
||||
if (totalUserActions + userActionsToAdd > MAX_USER_ACTIONS_PER_CASE) {
|
||||
throw Boom.badRequest(
|
||||
`The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -5,13 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { MAX_COMMENT_LENGTH, MAX_USER_ACTIONS_PER_CASE } from '../../../common/constants';
|
||||
import { comment } from '../../mocks';
|
||||
import { createUserActionServiceMock } from '../../services/mocks';
|
||||
import { createCasesClientMockArgs } from '../mocks';
|
||||
import { MAX_COMMENT_LENGTH } from '../../../common/constants';
|
||||
import { addComment } from './add';
|
||||
|
||||
describe('addComment', () => {
|
||||
const caseId = 'test-case';
|
||||
|
||||
const clientArgs = createCasesClientMockArgs();
|
||||
const userActionService = createUserActionServiceMock();
|
||||
|
||||
clientArgs.services.userActionService = userActionService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -20,7 +26,7 @@ describe('addComment', () => {
|
|||
it('throws with excess fields', async () => {
|
||||
await expect(
|
||||
// @ts-expect-error: excess attribute
|
||||
addComment({ comment: { ...comment, foo: 'bar' }, caseId: 'test-case' }, clientArgs)
|
||||
addComment({ comment: { ...comment, foo: 'bar' }, caseId }, clientArgs)
|
||||
).rejects.toThrow('invalid keys "foo"');
|
||||
});
|
||||
|
||||
|
@ -28,7 +34,7 @@ describe('addComment', () => {
|
|||
const longComment = 'x'.repeat(MAX_COMMENT_LENGTH + 1);
|
||||
|
||||
await expect(
|
||||
addComment({ comment: { ...comment, comment: longComment }, caseId: 'test-case' }, clientArgs)
|
||||
addComment({ comment: { ...comment, comment: longComment }, caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
`Failed while adding a comment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.`
|
||||
);
|
||||
|
@ -36,7 +42,7 @@ describe('addComment', () => {
|
|||
|
||||
it('should throw an error if the comment is an empty string', async () => {
|
||||
await expect(
|
||||
addComment({ comment: { ...comment, comment: '' }, caseId: 'test-case' }, clientArgs)
|
||||
addComment({ comment: { ...comment, comment: '' }, caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
'Failed while adding a comment to case id: test-case error: Error: The comment field cannot be an empty string.'
|
||||
);
|
||||
|
@ -44,9 +50,19 @@ describe('addComment', () => {
|
|||
|
||||
it('should throw an error if the description is a string with empty characters', async () => {
|
||||
await expect(
|
||||
addComment({ comment: { ...comment, comment: ' ' }, caseId: 'test-case' }, clientArgs)
|
||||
addComment({ comment: { ...comment, comment: ' ' }, caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
'Failed while adding a comment to case id: test-case error: Error: The comment field cannot be an empty string.'
|
||||
);
|
||||
});
|
||||
|
||||
it(`throws error when the case user actions become > ${MAX_USER_ACTIONS_PER_CASE}`, async () => {
|
||||
userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({
|
||||
[caseId]: MAX_USER_ACTIONS_PER_CASE,
|
||||
});
|
||||
|
||||
await expect(addComment({ comment, caseId }, clientArgs)).rejects.toThrow(
|
||||
`The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { SavedObjectsUtils } from '@kbn/core/server';
|
||||
|
||||
import { validateMaxUserActions } from '../../../common/utils';
|
||||
import { AttachmentRequestRt } from '../../../common/types/api';
|
||||
import type { Case } from '../../../common/types/domain';
|
||||
import { decodeWithExcessOrThrow } from '../../../common/api';
|
||||
|
@ -31,11 +32,13 @@ export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs):
|
|||
authorization,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
services: { userActionService },
|
||||
} = clientArgs;
|
||||
|
||||
try {
|
||||
const query = decodeWithExcessOrThrow(AttachmentRequestRt)(comment);
|
||||
|
||||
await validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 1 });
|
||||
decodeCommentRequest(comment, externalReferenceAttachmentTypeRegistry);
|
||||
|
||||
const savedObjectID = SavedObjectsUtils.generateId();
|
||||
|
|
|
@ -7,11 +7,21 @@
|
|||
|
||||
import { comment, actionComment } from '../../mocks';
|
||||
import { createCasesClientMockArgs } from '../mocks';
|
||||
import { MAX_COMMENT_LENGTH, MAX_BULK_CREATE_ATTACHMENTS } from '../../../common/constants';
|
||||
import {
|
||||
MAX_COMMENT_LENGTH,
|
||||
MAX_BULK_CREATE_ATTACHMENTS,
|
||||
MAX_USER_ACTIONS_PER_CASE,
|
||||
} from '../../../common/constants';
|
||||
import { bulkCreate } from './bulk_create';
|
||||
import { createUserActionServiceMock } from '../../services/mocks';
|
||||
|
||||
describe('bulkCreate', () => {
|
||||
const caseId = 'test-case';
|
||||
|
||||
const clientArgs = createCasesClientMockArgs();
|
||||
const userActionService = createUserActionServiceMock();
|
||||
|
||||
clientArgs.services.userActionService = userActionService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -20,18 +30,30 @@ describe('bulkCreate', () => {
|
|||
it('throws with excess fields', async () => {
|
||||
await expect(
|
||||
// @ts-expect-error: excess attribute
|
||||
bulkCreate({ attachments: [{ ...comment, foo: 'bar' }], caseId: 'test-case' }, clientArgs)
|
||||
bulkCreate({ attachments: [{ ...comment, foo: 'bar' }], caseId }, clientArgs)
|
||||
).rejects.toThrow('invalid keys "foo"');
|
||||
});
|
||||
|
||||
it(`throws error when attachments are more than ${MAX_BULK_CREATE_ATTACHMENTS}`, async () => {
|
||||
const attachments = Array(MAX_BULK_CREATE_ATTACHMENTS + 1).fill(comment);
|
||||
|
||||
await expect(bulkCreate({ attachments, caseId: 'test-case' }, clientArgs)).rejects.toThrow(
|
||||
await expect(bulkCreate({ attachments, caseId }, clientArgs)).rejects.toThrow(
|
||||
`The length of the field attachments is too long. Array must be of length <= ${MAX_BULK_CREATE_ATTACHMENTS}.`
|
||||
);
|
||||
});
|
||||
|
||||
it(`throws error when the case user actions become > ${MAX_USER_ACTIONS_PER_CASE}`, async () => {
|
||||
userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({
|
||||
[caseId]: MAX_USER_ACTIONS_PER_CASE - 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bulkCreate({ attachments: [comment, comment], caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
`The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.`
|
||||
);
|
||||
});
|
||||
|
||||
describe('comments', () => {
|
||||
it('should throw an error if the comment length is too long', async () => {
|
||||
const longComment = Array(MAX_COMMENT_LENGTH + 1)
|
||||
|
@ -39,10 +61,7 @@ describe('bulkCreate', () => {
|
|||
.toString();
|
||||
|
||||
await expect(
|
||||
bulkCreate(
|
||||
{ attachments: [{ ...comment, comment: longComment }], caseId: 'test-case' },
|
||||
clientArgs
|
||||
)
|
||||
bulkCreate({ attachments: [{ ...comment, comment: longComment }], caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
`Failed while bulk creating attachment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.`
|
||||
);
|
||||
|
@ -50,7 +69,7 @@ describe('bulkCreate', () => {
|
|||
|
||||
it('should throw an error if the comment is an empty string', async () => {
|
||||
await expect(
|
||||
bulkCreate({ attachments: [{ ...comment, comment: '' }], caseId: 'test-case' }, clientArgs)
|
||||
bulkCreate({ attachments: [{ ...comment, comment: '' }], caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
|
||||
);
|
||||
|
@ -58,10 +77,7 @@ describe('bulkCreate', () => {
|
|||
|
||||
it('should throw an error if the description is a string with empty characters', async () => {
|
||||
await expect(
|
||||
bulkCreate(
|
||||
{ attachments: [{ ...comment, comment: ' ' }], caseId: 'test-case' },
|
||||
clientArgs
|
||||
)
|
||||
bulkCreate({ attachments: [{ ...comment, comment: ' ' }], caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
|
||||
);
|
||||
|
@ -76,7 +92,7 @@ describe('bulkCreate', () => {
|
|||
|
||||
await expect(
|
||||
bulkCreate(
|
||||
{ attachments: [{ ...actionComment, comment: longComment }], caseId: 'test-case' },
|
||||
{ attachments: [{ ...actionComment, comment: longComment }], caseId },
|
||||
clientArgs
|
||||
)
|
||||
).rejects.toThrow(
|
||||
|
@ -86,10 +102,7 @@ describe('bulkCreate', () => {
|
|||
|
||||
it('should throw an error if the comment is an empty string', async () => {
|
||||
await expect(
|
||||
bulkCreate(
|
||||
{ attachments: [{ ...actionComment, comment: '' }], caseId: 'test-case' },
|
||||
clientArgs
|
||||
)
|
||||
bulkCreate({ attachments: [{ ...actionComment, comment: '' }], caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
|
||||
);
|
||||
|
@ -97,10 +110,7 @@ describe('bulkCreate', () => {
|
|||
|
||||
it('should throw an error if the description is a string with empty characters', async () => {
|
||||
await expect(
|
||||
bulkCreate(
|
||||
{ attachments: [{ ...actionComment, comment: ' ' }], caseId: 'test-case' },
|
||||
clientArgs
|
||||
)
|
||||
bulkCreate({ attachments: [{ ...actionComment, comment: ' ' }], caseId }, clientArgs)
|
||||
).rejects.toThrow(
|
||||
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
|
||||
);
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { SavedObjectsUtils } from '@kbn/core/server';
|
||||
|
||||
import { validateMaxUserActions } from '../../../common/utils';
|
||||
import type { AttachmentRequest } from '../../../common/types/api';
|
||||
import { BulkCreateAttachmentsRequestRt } from '../../../common/types/api';
|
||||
import type { Case } from '../../../common/types/domain';
|
||||
|
@ -33,10 +34,16 @@ export const bulkCreate = async (
|
|||
authorization,
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
services: { userActionService },
|
||||
} = clientArgs;
|
||||
|
||||
try {
|
||||
decodeWithExcessOrThrow(BulkCreateAttachmentsRequestRt)(attachments);
|
||||
await validateMaxUserActions({
|
||||
caseId,
|
||||
userActionService,
|
||||
userActionsToAdd: attachments.length,
|
||||
});
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
decodeCommentRequest(attachment, externalReferenceAttachmentTypeRegistry);
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
MAX_LENGTH_PER_TAG,
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_CASES_TO_UPDATE,
|
||||
MAX_USER_ACTIONS_PER_CASE,
|
||||
} from '../../../common/constants';
|
||||
import { mockCases } from '../../mocks';
|
||||
import { createCasesClientMockArgs } from '../mocks';
|
||||
|
@ -737,5 +738,116 @@ describe('update', () => {
|
|||
'Error: The length of the field cases is too short. Array must be of length >= 1.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('Validate max user actions per page', () => {
|
||||
const casesClient = createCasesClientMockArgs();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
casesClient.services.caseService.getCases.mockResolvedValue({
|
||||
saved_objects: [{ ...mockCases[0] }, { ...mockCases[1] }],
|
||||
});
|
||||
casesClient.services.caseService.getAllCaseComments.mockResolvedValue({
|
||||
saved_objects: [],
|
||||
total: 0,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes validation if max user actions per case is not reached', async () => {
|
||||
casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({
|
||||
[mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE - 1,
|
||||
});
|
||||
|
||||
// @ts-ignore: only the array length matters here
|
||||
casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({
|
||||
[mockCases[0].id]: [1],
|
||||
});
|
||||
|
||||
casesClient.services.caseService.patchCases.mockResolvedValue({
|
||||
saved_objects: [{ ...mockCases[0] }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
update(
|
||||
{
|
||||
cases: [
|
||||
{
|
||||
id: mockCases[0].id,
|
||||
version: mockCases[0].version ?? '',
|
||||
title: 'This is a test case!!',
|
||||
},
|
||||
],
|
||||
},
|
||||
casesClient
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it(`throws an error when the user actions to be created will reach ${MAX_USER_ACTIONS_PER_CASE}`, async () => {
|
||||
casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({
|
||||
[mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE,
|
||||
});
|
||||
|
||||
// @ts-ignore: only the array length matters here
|
||||
casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({
|
||||
[mockCases[0].id]: [1, 2, 3],
|
||||
});
|
||||
|
||||
await expect(
|
||||
update(
|
||||
{
|
||||
cases: [
|
||||
{
|
||||
id: mockCases[0].id,
|
||||
version: mockCases[0].version ?? '',
|
||||
title: 'This is a test case!!',
|
||||
},
|
||||
],
|
||||
},
|
||||
casesClient
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when trying to update multiple cases and one of them is expected to fail', async () => {
|
||||
casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({
|
||||
[mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE,
|
||||
[mockCases[1].id]: 0,
|
||||
});
|
||||
|
||||
// @ts-ignore: only the array length matters here
|
||||
casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({
|
||||
[mockCases[0].id]: [1, 2, 3],
|
||||
[mockCases[1].id]: [1],
|
||||
});
|
||||
|
||||
await expect(
|
||||
update(
|
||||
{
|
||||
cases: [
|
||||
{
|
||||
id: mockCases[0].id,
|
||||
version: mockCases[0].version ?? '',
|
||||
title: 'This is supposed to fail',
|
||||
},
|
||||
|
||||
{
|
||||
id: mockCases[1].id,
|
||||
version: mockCases[1].version ?? '',
|
||||
title: 'This is supposed to pass',
|
||||
},
|
||||
],
|
||||
},
|
||||
casesClient
|
||||
)
|
||||
).rejects.toThrow(
|
||||
`Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,28 +17,29 @@ import type {
|
|||
|
||||
import { nodeBuilder } from '@kbn/es-query';
|
||||
|
||||
import type { AlertService, CasesService, CaseUserActionService } from '../../services';
|
||||
import type { UpdateAlertStatusRequest } from '../alerts/types';
|
||||
import type { CasesClientArgs } from '..';
|
||||
import type { OwnerEntity } from '../../authorization';
|
||||
import type { PatchCasesArgs } from '../../services/cases/types';
|
||||
import type { UserActionEvent, UserActionsDict } from '../../services/user_actions/types';
|
||||
|
||||
import type { CasePatchRequest, CasesPatchRequest } from '../../../common/types/api';
|
||||
import { areTotalAssigneesInvalid } from '../../../common/utils/validators';
|
||||
import { decodeWithExcessOrThrow } from '../../../common/api';
|
||||
import {
|
||||
CASE_COMMENT_SAVED_OBJECT,
|
||||
CASE_SAVED_OBJECT,
|
||||
MAX_ASSIGNEES_PER_CASE,
|
||||
MAX_USER_ACTIONS_PER_CASE,
|
||||
} from '../../../common/constants';
|
||||
|
||||
import { arraysDifference, getCaseToUpdate } from '../utils';
|
||||
|
||||
import type { AlertService, CasesService } from '../../services';
|
||||
import { Operations } from '../../authorization';
|
||||
import { createCaseError } from '../../common/error';
|
||||
import {
|
||||
createAlertUpdateStatusRequest,
|
||||
flattenCaseSavedObject,
|
||||
isCommentRequestTypeAlert,
|
||||
} from '../../common/utils';
|
||||
import type { UpdateAlertStatusRequest } from '../alerts/types';
|
||||
import type { CasesClientArgs } from '..';
|
||||
import type { OwnerEntity } from '../../authorization';
|
||||
import { Operations } from '../../authorization';
|
||||
import { arraysDifference, getCaseToUpdate } from '../utils';
|
||||
import { dedupAssignees, getClosedInfoForUpdate, getDurationForUpdate } from './utils';
|
||||
import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants';
|
||||
import type { LicensingService } from '../../services/licensing';
|
||||
|
@ -53,6 +54,7 @@ import type {
|
|||
AttachmentAttributes,
|
||||
} from '../../../common/types/domain';
|
||||
import { CasesPatchRequestRt } from '../../../common/types/api';
|
||||
import { decodeWithExcessOrThrow } from '../../../common/api';
|
||||
import { CasesRt, CaseStatuses, AttachmentType } from '../../../common/types/domain';
|
||||
|
||||
/**
|
||||
|
@ -67,6 +69,36 @@ function throwIfUpdateOwner(requests: UpdateRequestWithOriginalCase[]) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error if any of the requests attempt to create a number of user actions that would put
|
||||
* it's case over the limit.
|
||||
*/
|
||||
async function throwIfMaxUserActionsReached({
|
||||
userActionsDict,
|
||||
userActionService,
|
||||
}: {
|
||||
userActionsDict: UserActionsDict;
|
||||
userActionService: CaseUserActionService;
|
||||
}) {
|
||||
if (userActionsDict == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTotals = await userActionService.getMultipleCasesUserActionsTotal({
|
||||
caseIds: Object.keys(userActionsDict),
|
||||
});
|
||||
|
||||
Object.keys(currentTotals).forEach((caseId) => {
|
||||
const totalToAdd = userActionsDict?.[caseId]?.length ?? 0;
|
||||
|
||||
if (currentTotals[caseId] + totalToAdd > MAX_USER_ACTIONS_PER_CASE) {
|
||||
throw Boom.badRequest(
|
||||
`The case with case id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error if any of the requests attempt to update the assignees of the case
|
||||
* without the appropriate license
|
||||
|
@ -364,9 +396,16 @@ export const update = async (
|
|||
throwIfUpdateAssigneesWithoutValidLicense(casesToUpdate, hasPlatinumLicense);
|
||||
throwIfTotalAssigneesAreInvalid(casesToUpdate);
|
||||
|
||||
const patchCasesPayload = createPatchCasesPayload({ user, casesToUpdate });
|
||||
const userActionsDict = userActionService.creator.buildUserActions({
|
||||
updatedCases: patchCasesPayload,
|
||||
user,
|
||||
});
|
||||
|
||||
await throwIfMaxUserActionsReached({ userActionsDict, userActionService });
|
||||
notifyPlatinumUsage(licensingService, casesToUpdate);
|
||||
|
||||
const updatedCases = await patchCases({ caseService, user, casesToUpdate });
|
||||
const updatedCases = await patchCases({ caseService, patchCasesPayload });
|
||||
|
||||
// If a status update occurred and the case is synced then we need to update all alerts' status
|
||||
// attached to the case to the new status.
|
||||
|
@ -413,10 +452,15 @@ export const update = async (
|
|||
return flattenCases;
|
||||
}, [] as Case[]);
|
||||
|
||||
const builtUserActions =
|
||||
userActionsDict != null
|
||||
? Object.keys(userActionsDict).reduce<UserActionEvent[]>((acc, key) => {
|
||||
return [...acc, ...userActionsDict[key]];
|
||||
}, [])
|
||||
: [];
|
||||
|
||||
await userActionService.creator.bulkCreateUpdateCase({
|
||||
originalCases: myCases.saved_objects,
|
||||
updatedCases: updatedCases.saved_objects,
|
||||
user,
|
||||
builtUserActions,
|
||||
});
|
||||
|
||||
const casesAndAssigneesToNotifyForAssignment = getCasesAndAssigneesToNotifyForAssignment(
|
||||
|
@ -442,18 +486,16 @@ export const update = async (
|
|||
}
|
||||
};
|
||||
|
||||
const patchCases = async ({
|
||||
caseService,
|
||||
const createPatchCasesPayload = ({
|
||||
casesToUpdate,
|
||||
user,
|
||||
}: {
|
||||
caseService: CasesService;
|
||||
casesToUpdate: UpdateRequestWithOriginalCase[];
|
||||
user: User;
|
||||
}) => {
|
||||
}): PatchCasesArgs => {
|
||||
const updatedDt = new Date().toISOString();
|
||||
|
||||
const updatedCases = await caseService.patchCases({
|
||||
return {
|
||||
cases: casesToUpdate.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, assignees, ...updateCaseAttributes } = updateReq;
|
||||
|
@ -483,9 +525,17 @@ const patchCases = async ({
|
|||
};
|
||||
}),
|
||||
refresh: false,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return updatedCases;
|
||||
const patchCases = async ({
|
||||
caseService,
|
||||
patchCasesPayload,
|
||||
}: {
|
||||
caseService: CasesService;
|
||||
patchCasesPayload: PatchCasesArgs;
|
||||
}) => {
|
||||
return caseService.patchCases(patchCasesPayload);
|
||||
};
|
||||
|
||||
const getCasesAndAssigneesToNotifyForAssignment = (
|
||||
|
|
|
@ -97,6 +97,7 @@ const createUserActionPersisterServiceMock = (): CaseUserActionPersisterServiceM
|
|||
const service: PublicMethodsOf<UserActionPersister> = {
|
||||
bulkAuditLogCaseDeletion: jest.fn(),
|
||||
bulkCreateUpdateCase: jest.fn(),
|
||||
buildUserActions: jest.fn(),
|
||||
bulkCreateAttachmentDeletion: jest.fn(),
|
||||
bulkCreateAttachmentCreation: jest.fn(),
|
||||
createUserAction: jest.fn(),
|
||||
|
@ -126,6 +127,7 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => {
|
|||
getAll: jest.fn(),
|
||||
getUniqueConnectors: jest.fn(),
|
||||
getUserActionIdsForCases: jest.fn(),
|
||||
getMultipleCasesUserActionsTotal: jest.fn(),
|
||||
getCaseUserActionStats: jest.fn(),
|
||||
getUsers: jest.fn(),
|
||||
};
|
||||
|
|
|
@ -12,27 +12,27 @@ import type {
|
|||
SavedObject,
|
||||
SavedObjectsBulkCreateObject,
|
||||
SavedObjectsFindResponse,
|
||||
SavedObjectsUpdateResponse,
|
||||
} from '@kbn/core/server';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import {
|
||||
CaseSeverity,
|
||||
CaseStatuses,
|
||||
UserActionActions,
|
||||
UserActionTypes,
|
||||
} from '../../../common/types/domain';
|
||||
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
|
||||
import type { CaseUserActionWithoutReferenceIds } from '../../../common/types/domain';
|
||||
import type { UserActionEvent } from './types';
|
||||
|
||||
import { createCaseSavedObjectResponse, createSOFindResponse } from '../test_utils';
|
||||
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
|
||||
import { createSOFindResponse } from '../test_utils';
|
||||
import {
|
||||
casePayload,
|
||||
externalService,
|
||||
originalCases,
|
||||
updatedCases,
|
||||
attachments,
|
||||
updatedAssigneesCases,
|
||||
originalCasesWithAssignee,
|
||||
updatedTagsCases,
|
||||
patchRemoveAssigneesCasesRequest,
|
||||
patchCasesRequest,
|
||||
patchAssigneesCasesRequest,
|
||||
patchAddRemoveAssigneesCasesRequest,
|
||||
patchTagsCasesRequest,
|
||||
getBuiltUserActions,
|
||||
getAssigneesAddedUserActions,
|
||||
getAssigneesRemovedUserActions,
|
||||
getAssigneesAddedRemovedUserActions,
|
||||
getTagsAddedRemovedUserActions,
|
||||
} from './mocks';
|
||||
import { CaseUserActionService } from '.';
|
||||
import { createPersistableStateAttachmentTypeRegistryMock } from '../../attachment_framework/mocks';
|
||||
|
@ -44,9 +44,11 @@ import {
|
|||
pushConnectorUserAction,
|
||||
} from './test_utils';
|
||||
import { comment } from '../../mocks';
|
||||
import type {
|
||||
CaseUserActionWithoutReferenceIds,
|
||||
CaseAttributes,
|
||||
import {
|
||||
UserActionActions,
|
||||
UserActionTypes,
|
||||
CaseSeverity,
|
||||
CaseStatuses,
|
||||
} from '../../../common/types/domain';
|
||||
|
||||
describe('CaseUserActionService', () => {
|
||||
|
@ -506,13 +508,73 @@ describe('CaseUserActionService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('buildUserActions', () => {
|
||||
it('creates the correct user actions when bulk updating cases', async () => {
|
||||
expect(
|
||||
await service.creator.buildUserActions({
|
||||
updatedCases: patchCasesRequest,
|
||||
user: commonArgs.user,
|
||||
})
|
||||
).toEqual(getBuiltUserActions({ isMock: false }));
|
||||
});
|
||||
|
||||
it('creates the correct user actions when an assignee is added', async () => {
|
||||
expect(
|
||||
await service.creator.buildUserActions({
|
||||
updatedCases: patchAssigneesCasesRequest,
|
||||
user: commonArgs.user,
|
||||
})
|
||||
).toEqual(getAssigneesAddedUserActions({ isMock: false }));
|
||||
});
|
||||
|
||||
it('creates the correct user actions when an assignee is removed', async () => {
|
||||
expect(
|
||||
await service.creator.buildUserActions({
|
||||
updatedCases: patchRemoveAssigneesCasesRequest,
|
||||
user: commonArgs.user,
|
||||
})
|
||||
).toEqual(getAssigneesRemovedUserActions({ isMock: false }));
|
||||
});
|
||||
|
||||
it('creates the correct user actions when assignees are added and removed', async () => {
|
||||
expect(
|
||||
await service.creator.buildUserActions({
|
||||
updatedCases: patchAddRemoveAssigneesCasesRequest,
|
||||
user: commonArgs.user,
|
||||
})
|
||||
).toEqual(
|
||||
getAssigneesAddedRemovedUserActions({
|
||||
isMock: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the correct user actions when tags are added and removed', async () => {
|
||||
expect(
|
||||
await service.creator.buildUserActions({
|
||||
updatedCases: patchTagsCasesRequest,
|
||||
user: commonArgs.user,
|
||||
})
|
||||
).toEqual(
|
||||
getTagsAddedRemovedUserActions({
|
||||
isMock: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkCreateUpdateCase', () => {
|
||||
const mockBuiltUserActions = getBuiltUserActions({ isMock: true });
|
||||
const builtUserActions = Object.keys(mockBuiltUserActions).reduce<UserActionEvent[]>(
|
||||
(acc, key) => {
|
||||
return [...acc, ...mockBuiltUserActions[key]];
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
it('creates the correct user actions when bulk updating cases', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases,
|
||||
updatedCases,
|
||||
user: commonArgs.user,
|
||||
builtUserActions,
|
||||
});
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(
|
||||
|
@ -582,6 +644,22 @@ describe('CaseUserActionService', () => {
|
|||
],
|
||||
type: 'cases-user-actions',
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
action: UserActionActions.update,
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
type: 'category',
|
||||
owner: 'securitySolution',
|
||||
payload: { category: 'pizza toppings' },
|
||||
},
|
||||
references: [{ id: '1', name: 'associated-cases', type: 'cases' }],
|
||||
type: 'cases-user-actions',
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
action: UserActionActions.update,
|
||||
|
@ -677,13 +755,10 @@ describe('CaseUserActionService', () => {
|
|||
|
||||
it('logs the correct user actions when bulk updating cases', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases,
|
||||
updatedCases,
|
||||
user: commonArgs.user,
|
||||
builtUserActions,
|
||||
});
|
||||
|
||||
expect(mockAuditLogger.log).toBeCalledTimes(8);
|
||||
expect(mockAuditLogger.log).toBeCalledTimes(9);
|
||||
expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
|
@ -704,7 +779,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User updated the title for case id: 1 - user action id: 0",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -725,7 +800,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User updated the status for case id: 1 - user action id: 1",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -746,7 +821,28 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User changed the case connector to id: 456 for case id: 1 - user action id: 2",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
Object {
|
||||
"event": Object {
|
||||
"action": "case_user_action_update_case_category",
|
||||
"category": Array [
|
||||
"database",
|
||||
],
|
||||
"outcome": "success",
|
||||
"type": Array [
|
||||
"change",
|
||||
],
|
||||
},
|
||||
"kibana": Object {
|
||||
"saved_object": Object {
|
||||
"id": "1",
|
||||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -767,7 +863,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User updated the description for case id: 2 - user action id: 3",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -788,7 +884,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User added tags to case id: 2 - user action id: 4",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -809,7 +905,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User deleted tags in case id: 2 - user action id: 5",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -830,7 +926,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User updated the settings for case id: 2 - user action id: 6",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -851,19 +947,23 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User updated the severity for case id: 2 - user action id: 7",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
const mockAssigneesAddedUserActions = getAssigneesAddedUserActions({ isMock: true });
|
||||
const assigneesAddedUserActions = Object.keys(mockAssigneesAddedUserActions).reduce<
|
||||
UserActionEvent[]
|
||||
>((acc, key) => {
|
||||
return [...acc, ...mockAssigneesAddedUserActions[key]];
|
||||
}, []);
|
||||
|
||||
it('creates the correct user actions when an assignee is added', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases,
|
||||
updatedCases: updatedAssigneesCases,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: assigneesAddedUserActions,
|
||||
});
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
|
@ -907,10 +1007,7 @@ describe('CaseUserActionService', () => {
|
|||
|
||||
it('logs the correct user actions when an assignee is added', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases,
|
||||
updatedCases: updatedAssigneesCases,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: assigneesAddedUserActions,
|
||||
});
|
||||
|
||||
expect(mockAuditLogger.log).toBeCalledTimes(1);
|
||||
|
@ -934,29 +1031,23 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User assigned uids: [1] to case id: 1 - user action id: 0",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('creates the correct user actions when an assignee is removed', async () => {
|
||||
const casesWithAssigneeRemoved: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '1',
|
||||
attributes: {
|
||||
assignees: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
const mockAssigneesRemovedUserActions = getAssigneesRemovedUserActions({ isMock: true });
|
||||
const assigneesRemovedUserActions = Object.keys(mockAssigneesRemovedUserActions).reduce<
|
||||
UserActionEvent[]
|
||||
>((acc, key) => {
|
||||
return [...acc, ...mockAssigneesRemovedUserActions[key]];
|
||||
}, []);
|
||||
|
||||
it('creates the correct user actions when an assignee is removed', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases: originalCasesWithAssignee,
|
||||
updatedCases: casesWithAssigneeRemoved,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: assigneesRemovedUserActions,
|
||||
});
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
|
@ -999,21 +1090,8 @@ describe('CaseUserActionService', () => {
|
|||
});
|
||||
|
||||
it('logs the correct user actions when an assignee is removed', async () => {
|
||||
const casesWithAssigneeRemoved: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '1',
|
||||
attributes: {
|
||||
assignees: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases: originalCasesWithAssignee,
|
||||
updatedCases: casesWithAssigneeRemoved,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: assigneesRemovedUserActions,
|
||||
});
|
||||
|
||||
expect(mockAuditLogger.log).toBeCalledTimes(1);
|
||||
|
@ -1037,29 +1115,25 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User unassigned uids: [1] from case id: 1 - user action id: 0",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('creates the correct user actions when assignees are added and removed', async () => {
|
||||
const caseAssignees: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '1',
|
||||
attributes: {
|
||||
assignees: [{ uid: '2' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
const mockAssigneesAddedRemovedUserActions = getAssigneesAddedRemovedUserActions({
|
||||
isMock: true,
|
||||
});
|
||||
const assigneesAddedRemovedUserActions = Object.keys(
|
||||
mockAssigneesAddedRemovedUserActions
|
||||
).reduce<UserActionEvent[]>((acc, key) => {
|
||||
return [...acc, ...mockAssigneesAddedRemovedUserActions[key]];
|
||||
}, []);
|
||||
|
||||
it('creates the correct user actions when assignees are added and removed', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases: originalCasesWithAssignee,
|
||||
updatedCases: caseAssignees,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: assigneesAddedRemovedUserActions,
|
||||
});
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
|
@ -1130,21 +1204,8 @@ describe('CaseUserActionService', () => {
|
|||
});
|
||||
|
||||
it('logs the correct user actions when assignees are added and removed', async () => {
|
||||
const caseAssignees: Array<SavedObjectsUpdateResponse<CaseAttributes>> = [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '1',
|
||||
attributes: {
|
||||
assignees: [{ uid: '2' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases: originalCasesWithAssignee,
|
||||
updatedCases: caseAssignees,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: assigneesAddedRemovedUserActions,
|
||||
});
|
||||
|
||||
expect(mockAuditLogger.log).toBeCalledTimes(2);
|
||||
|
@ -1168,7 +1229,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User assigned uids: [2] to case id: 1 - user action id: 0",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -1189,19 +1250,25 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User unassigned uids: [1] from case id: 1 - user action id: 1",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
const mockTagsAddedRemovedUserActions = getTagsAddedRemovedUserActions({
|
||||
isMock: true,
|
||||
});
|
||||
const tagsAddedRemovedUserActions = Object.keys(mockTagsAddedRemovedUserActions).reduce<
|
||||
UserActionEvent[]
|
||||
>((acc, key) => {
|
||||
return [...acc, ...mockTagsAddedRemovedUserActions[key]];
|
||||
}, []);
|
||||
|
||||
it('creates the correct user actions when tags are added and removed', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases,
|
||||
updatedCases: updatedTagsCases,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: tagsAddedRemovedUserActions,
|
||||
});
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
|
@ -1270,10 +1337,7 @@ describe('CaseUserActionService', () => {
|
|||
|
||||
it('logs the correct user actions when tags are added and removed', async () => {
|
||||
await service.creator.bulkCreateUpdateCase({
|
||||
...commonArgs,
|
||||
originalCases,
|
||||
updatedCases: updatedTagsCases,
|
||||
user: commonArgs.user,
|
||||
builtUserActions: tagsAddedRemovedUserActions,
|
||||
});
|
||||
|
||||
expect(mockAuditLogger.log).toBeCalledTimes(2);
|
||||
|
@ -1297,7 +1361,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User added tags to case id: 1 - user action id: 0",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
Array [
|
||||
|
@ -1318,7 +1382,7 @@ describe('CaseUserActionService', () => {
|
|||
"type": "cases",
|
||||
},
|
||||
},
|
||||
"message": "User deleted tags in case id: 1 - user action id: 1",
|
||||
"message": undefined,
|
||||
},
|
||||
],
|
||||
]
|
||||
|
|
|
@ -28,6 +28,7 @@ import type {
|
|||
ConnectorActivityAggsResult,
|
||||
ConnectorFieldsBeforePushAggsResult,
|
||||
GetUsersResponse,
|
||||
MultipleCasesUserActionsTotalAggsResult,
|
||||
ParticipantsAggsResult,
|
||||
PushInfo,
|
||||
PushTimeFrameInfo,
|
||||
|
@ -652,6 +653,55 @@ export class CaseUserActionService {
|
|||
};
|
||||
}
|
||||
|
||||
public async getMultipleCasesUserActionsTotal({
|
||||
caseIds,
|
||||
}: {
|
||||
caseIds: string[];
|
||||
}): Promise<Record<string, number>> {
|
||||
const response = await this.context.unsecuredSavedObjectsClient.find<
|
||||
unknown,
|
||||
MultipleCasesUserActionsTotalAggsResult
|
||||
>({
|
||||
type: CASE_USER_ACTION_SAVED_OBJECT,
|
||||
hasReference: caseIds.map((id) => ({ type: CASE_SAVED_OBJECT, id })),
|
||||
hasReferenceOperator: 'OR',
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
sortField: defaultSortField,
|
||||
aggs: CaseUserActionService.buildMultipleCasesUserActionsTotalAgg(caseIds.length),
|
||||
});
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
response?.aggregations?.references.caseUserActions.buckets.forEach(
|
||||
({ key, doc_count: totalUserActions }: { key: string; doc_count: number }) => {
|
||||
result[key] = totalUserActions;
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static buildMultipleCasesUserActionsTotalAgg(
|
||||
idsLength: number
|
||||
): Record<string, estypes.AggregationsAggregationContainer> {
|
||||
return {
|
||||
references: {
|
||||
nested: {
|
||||
path: `${CASE_USER_ACTION_SAVED_OBJECT}.references`,
|
||||
},
|
||||
aggregations: {
|
||||
caseUserActions: {
|
||||
terms: {
|
||||
field: `${CASE_USER_ACTION_SAVED_OBJECT}.references.id`,
|
||||
size: idsLength,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async getCaseUserActionStats({ caseId }: { caseId: string }) {
|
||||
const response = await this.context.unsecuredSavedObjectsClient.find<
|
||||
unknown,
|
||||
|
|
|
@ -11,6 +11,7 @@ import type { CasePostRequest } from '../../../common/types/api';
|
|||
import { createCaseSavedObjectResponse } from '../test_utils';
|
||||
import { transformSavedObjectToExternalModel } from '../cases/transform';
|
||||
import { alertComment, comment } from '../../mocks';
|
||||
import type { UserActionsDict } from './types';
|
||||
import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/types/domain';
|
||||
|
||||
export const casePayload: CasePostRequest = {
|
||||
|
@ -53,57 +54,648 @@ export const originalCases = [
|
|||
{ ...createCaseSavedObjectResponse(), id: '2' },
|
||||
].map((so) => transformSavedObjectToExternalModel(so));
|
||||
|
||||
export const updatedCases = [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '1',
|
||||
type: CASE_SAVED_OBJECT,
|
||||
attributes: {
|
||||
title: 'updated title',
|
||||
status: CaseStatuses.closed,
|
||||
connector: casePayload.connector,
|
||||
export const patchCasesRequest = {
|
||||
cases: [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
caseId: '1',
|
||||
type: CASE_SAVED_OBJECT,
|
||||
updatedAttributes: {
|
||||
title: 'updated title',
|
||||
status: CaseStatuses.closed,
|
||||
connector: casePayload.connector,
|
||||
category: 'pizza toppings',
|
||||
},
|
||||
originalCase: originalCases[0],
|
||||
references: [],
|
||||
},
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '2',
|
||||
type: CASE_SAVED_OBJECT,
|
||||
attributes: {
|
||||
description: 'updated desc',
|
||||
tags: ['one', 'two'],
|
||||
settings: { syncAlerts: false },
|
||||
severity: CaseSeverity.CRITICAL,
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
caseId: '2',
|
||||
type: CASE_SAVED_OBJECT,
|
||||
updatedAttributes: {
|
||||
description: 'updated desc',
|
||||
tags: ['one', 'two'],
|
||||
settings: { syncAlerts: false },
|
||||
severity: CaseSeverity.CRITICAL,
|
||||
},
|
||||
originalCase: originalCases[1],
|
||||
references: [],
|
||||
},
|
||||
references: [],
|
||||
},
|
||||
];
|
||||
],
|
||||
};
|
||||
|
||||
export const originalCasesWithAssignee = [
|
||||
const originalCasesWithAssignee = [
|
||||
{ ...createCaseSavedObjectResponse({ overrides: { assignees: [{ uid: '1' }] } }), id: '1' },
|
||||
].map((so) => transformSavedObjectToExternalModel(so));
|
||||
|
||||
export const updatedAssigneesCases = [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '1',
|
||||
attributes: {
|
||||
assignees: [{ uid: '1' }],
|
||||
export const patchAssigneesCasesRequest = {
|
||||
cases: [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
caseId: '1',
|
||||
updatedAttributes: {
|
||||
assignees: [{ uid: '1' }],
|
||||
},
|
||||
originalCase: originalCases[0],
|
||||
},
|
||||
},
|
||||
];
|
||||
],
|
||||
};
|
||||
|
||||
export const updatedTagsCases = [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
id: '1',
|
||||
attributes: {
|
||||
tags: ['a', 'b'],
|
||||
export const patchRemoveAssigneesCasesRequest = {
|
||||
cases: [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
caseId: '1',
|
||||
updatedAttributes: {
|
||||
assignees: [],
|
||||
},
|
||||
originalCase: originalCasesWithAssignee[0],
|
||||
},
|
||||
},
|
||||
];
|
||||
],
|
||||
};
|
||||
|
||||
export const patchAddRemoveAssigneesCasesRequest = {
|
||||
cases: [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
caseId: '1',
|
||||
updatedAttributes: {
|
||||
assignees: [{ uid: '2' }],
|
||||
},
|
||||
originalCase: originalCasesWithAssignee[0],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const patchTagsCasesRequest = {
|
||||
cases: [
|
||||
{
|
||||
...createCaseSavedObjectResponse(),
|
||||
caseId: '1',
|
||||
updatedAttributes: {
|
||||
tags: ['a', 'b'],
|
||||
},
|
||||
originalCase: originalCases[0],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const attachments = [
|
||||
{ id: '1', attachment: { ...comment }, owner: SECURITY_SOLUTION_OWNER },
|
||||
{ id: '2', attachment: { ...alertComment }, owner: SECURITY_SOLUTION_OWNER },
|
||||
];
|
||||
|
||||
export const getBuiltUserActions = ({ isMock }: { isMock: boolean }): UserActionsDict => ({
|
||||
'1': [
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'update',
|
||||
descriptiveAction: 'case_user_action_update_case_title',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'update',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
title: 'updated title',
|
||||
},
|
||||
type: 'title',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'update',
|
||||
descriptiveAction: 'case_user_action_update_case_status',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'update',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
status: 'closed',
|
||||
},
|
||||
type: 'status',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'update',
|
||||
descriptiveAction: 'case_user_action_update_case_connector',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'update',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
connector: {
|
||||
fields: {
|
||||
category: 'Denial of Service',
|
||||
destIp: true,
|
||||
malwareHash: true,
|
||||
malwareUrl: true,
|
||||
priority: '2',
|
||||
sourceIp: true,
|
||||
subcategory: '45',
|
||||
},
|
||||
name: 'ServiceNow SN',
|
||||
type: '.servicenow-sir',
|
||||
},
|
||||
},
|
||||
type: 'connector',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
name: 'connectorId',
|
||||
type: 'action',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'update',
|
||||
descriptiveAction: 'case_user_action_update_case_category',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'update',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
category: 'pizza toppings',
|
||||
},
|
||||
type: 'category',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
'2': [
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'update',
|
||||
descriptiveAction: 'case_user_action_update_case_description',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '2',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'update',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
description: 'updated desc',
|
||||
},
|
||||
type: 'description',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '2',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'add',
|
||||
descriptiveAction: 'case_user_action_add_case_tags',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '2',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'add',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
tags: ['one', 'two'],
|
||||
},
|
||||
type: 'tags',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '2',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'delete',
|
||||
descriptiveAction: 'case_user_action_delete_case_tags',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '2',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'delete',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
tags: ['defacement'],
|
||||
},
|
||||
type: 'tags',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '2',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'update',
|
||||
descriptiveAction: 'case_user_action_update_case_settings',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '2',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'update',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
settings: {
|
||||
syncAlerts: false,
|
||||
},
|
||||
},
|
||||
type: 'settings',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '2',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'update',
|
||||
descriptiveAction: 'case_user_action_update_case_severity',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '2',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'update',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
severity: 'critical',
|
||||
},
|
||||
type: 'severity',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '2',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const getAssigneesAddedUserActions = ({ isMock }: { isMock: boolean }): UserActionsDict => ({
|
||||
'1': [
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'add',
|
||||
descriptiveAction: 'case_user_action_add_case_assignees',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'add',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
assignees: [
|
||||
{
|
||||
uid: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'assignees',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const getAssigneesRemovedUserActions = ({
|
||||
isMock,
|
||||
}: {
|
||||
isMock: boolean;
|
||||
}): UserActionsDict => ({
|
||||
'1': [
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'delete',
|
||||
descriptiveAction: 'case_user_action_delete_case_assignees',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'delete',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
assignees: [
|
||||
{
|
||||
uid: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'assignees',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const getAssigneesAddedRemovedUserActions = ({
|
||||
isMock,
|
||||
}: {
|
||||
isMock: boolean;
|
||||
}): UserActionsDict => ({
|
||||
'1': [
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'add',
|
||||
descriptiveAction: 'case_user_action_add_case_assignees',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'add',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
assignees: [
|
||||
{
|
||||
uid: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'assignees',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'delete',
|
||||
descriptiveAction: 'case_user_action_delete_case_assignees',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'delete',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
assignees: [
|
||||
{
|
||||
uid: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'assignees',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const getTagsAddedRemovedUserActions = ({
|
||||
isMock,
|
||||
}: {
|
||||
isMock: boolean;
|
||||
}): UserActionsDict => ({
|
||||
'1': [
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'add',
|
||||
descriptiveAction: 'case_user_action_add_case_tags',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'add',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
tags: ['a', 'b'],
|
||||
},
|
||||
type: 'tags',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventDetails: {
|
||||
action: 'delete',
|
||||
descriptiveAction: 'case_user_action_delete_case_tags',
|
||||
getMessage: isMock ? jest.fn() : expect.any(Function),
|
||||
savedObjectId: '1',
|
||||
savedObjectType: 'cases',
|
||||
},
|
||||
parameters: {
|
||||
attributes: {
|
||||
action: 'delete',
|
||||
created_at: '2022-01-09T22:00:00.000Z',
|
||||
created_by: {
|
||||
email: 'elastic@elastic.co',
|
||||
full_name: 'Elastic User',
|
||||
username: 'elastic',
|
||||
},
|
||||
owner: 'securitySolution',
|
||||
payload: {
|
||||
tags: ['defacement'],
|
||||
},
|
||||
type: 'tags',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'associated-cases',
|
||||
type: 'cases',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -17,6 +17,18 @@ import { UserActionPersister } from './create';
|
|||
import { createUserActionSO } from '../test_utils';
|
||||
import type { BulkCreateAttachmentUserAction, CreateUserActionClient } from '../types';
|
||||
import type { UserActionPersistedAttributes } from '../../../common/types/user_actions';
|
||||
import {
|
||||
getAssigneesAddedRemovedUserActions,
|
||||
getAssigneesAddedUserActions,
|
||||
getAssigneesRemovedUserActions,
|
||||
getBuiltUserActions,
|
||||
getTagsAddedRemovedUserActions,
|
||||
patchAddRemoveAssigneesCasesRequest,
|
||||
patchAssigneesCasesRequest,
|
||||
patchCasesRequest,
|
||||
patchRemoveAssigneesCasesRequest,
|
||||
patchTagsCasesRequest,
|
||||
} from '../mocks';
|
||||
import { AttachmentType } from '../../../../common/types/domain';
|
||||
|
||||
describe('UserActionPersister', () => {
|
||||
|
@ -28,6 +40,11 @@ describe('UserActionPersister', () => {
|
|||
|
||||
let persister: UserActionPersister;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2022-01-09T22:00:00.000Z'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
persister = new UserActionPersister({
|
||||
|
@ -39,6 +56,10 @@ describe('UserActionPersister', () => {
|
|||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const getRequest = () =>
|
||||
({
|
||||
action: 'update' as const,
|
||||
|
@ -62,6 +83,8 @@ describe('UserActionPersister', () => {
|
|||
user: { email: '', full_name: '', username: '' },
|
||||
});
|
||||
|
||||
const testUser = { full_name: 'Elastic User', username: 'elastic', email: 'elastic@elastic.co' };
|
||||
|
||||
describe('Decoding requests', () => {
|
||||
describe('createUserAction', () => {
|
||||
beforeEach(() => {
|
||||
|
@ -141,4 +164,59 @@ describe('UserActionPersister', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserActions', () => {
|
||||
it('creates the correct user actions when bulk updating cases', async () => {
|
||||
expect(
|
||||
persister.buildUserActions({
|
||||
updatedCases: patchCasesRequest,
|
||||
user: testUser,
|
||||
})
|
||||
).toEqual(getBuiltUserActions({ isMock: false }));
|
||||
});
|
||||
|
||||
it('creates the correct user actions when an assignee is added', async () => {
|
||||
expect(
|
||||
persister.buildUserActions({
|
||||
updatedCases: patchAssigneesCasesRequest,
|
||||
user: testUser,
|
||||
})
|
||||
).toEqual(getAssigneesAddedUserActions({ isMock: false }));
|
||||
});
|
||||
|
||||
it('creates the correct user actions when an assignee is removed', async () => {
|
||||
expect(
|
||||
persister.buildUserActions({
|
||||
updatedCases: patchRemoveAssigneesCasesRequest,
|
||||
user: testUser,
|
||||
})
|
||||
).toEqual(getAssigneesRemovedUserActions({ isMock: false }));
|
||||
});
|
||||
|
||||
it('creates the correct user actions when assignees are added and removed', async () => {
|
||||
expect(
|
||||
persister.buildUserActions({
|
||||
updatedCases: patchAddRemoveAssigneesCasesRequest,
|
||||
user: testUser,
|
||||
})
|
||||
).toEqual(
|
||||
getAssigneesAddedRemovedUserActions({
|
||||
isMock: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the correct user actions when tags are added and removed', async () => {
|
||||
expect(
|
||||
persister.buildUserActions({
|
||||
updatedCases: patchTagsCasesRequest,
|
||||
user: testUser,
|
||||
})
|
||||
).toEqual(
|
||||
getTagsAddedRemovedUserActions({
|
||||
isMock: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,6 +22,7 @@ import { isUserActionType } from '../../../../common/utils/user_actions';
|
|||
import { decodeOrThrow } from '../../../../common/api';
|
||||
import { BuilderFactory } from '../builder_factory';
|
||||
import type {
|
||||
BuildUserActionsDictParams,
|
||||
BuilderParameters,
|
||||
BulkCreateAttachmentUserAction,
|
||||
BulkCreateBulkUpdateCaseUserActions,
|
||||
|
@ -34,6 +35,7 @@ import type {
|
|||
ServiceContext,
|
||||
TypedUserActionDiffedItems,
|
||||
UserActionEvent,
|
||||
UserActionsDict,
|
||||
} from '../types';
|
||||
import { isAssigneesArray, isStringArray } from '../type_guards';
|
||||
import type { IndexRefresh } from '../../types';
|
||||
|
@ -55,30 +57,25 @@ export class UserActionPersister {
|
|||
this.auditLogger = new UserActionAuditLogger(this.context.auditLogger);
|
||||
}
|
||||
|
||||
public async bulkCreateUpdateCase({
|
||||
originalCases,
|
||||
updatedCases,
|
||||
user,
|
||||
refresh,
|
||||
}: BulkCreateBulkUpdateCaseUserActions): Promise<void> {
|
||||
const builtUserActions = updatedCases.reduce<UserActionEvent[]>((acc, updatedCase) => {
|
||||
const originalCase = originalCases.find(({ id }) => id === updatedCase.id);
|
||||
public buildUserActions({ updatedCases, user }: BuildUserActionsDictParams): UserActionsDict {
|
||||
return updatedCases.cases.reduce<UserActionsDict>((acc, updatedCase) => {
|
||||
const originalCase = updatedCase.originalCase;
|
||||
|
||||
if (originalCase == null) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const caseId = updatedCase.id;
|
||||
const caseId = updatedCase.caseId;
|
||||
const owner = originalCase.attributes.owner;
|
||||
|
||||
const userActions: UserActionEvent[] = [];
|
||||
const updatedFields = Object.keys(updatedCase.attributes);
|
||||
const updatedFields = Object.keys(updatedCase.updatedAttributes);
|
||||
|
||||
updatedFields
|
||||
.filter((field) => UserActionPersister.userActionFieldsAllowed.has(field))
|
||||
.forEach((field) => {
|
||||
const originalValue = get(originalCase, ['attributes', field]);
|
||||
const newValue = get(updatedCase, ['attributes', field]);
|
||||
const newValue = get(updatedCase, ['updatedAttributes', field]);
|
||||
userActions.push(
|
||||
...this.getUserActionItemByDifference({
|
||||
field,
|
||||
|
@ -91,9 +88,15 @@ export class UserActionPersister {
|
|||
);
|
||||
});
|
||||
|
||||
return [...acc, ...userActions];
|
||||
}, []);
|
||||
acc[caseId] = userActions;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
public async bulkCreateUpdateCase({
|
||||
builtUserActions,
|
||||
refresh,
|
||||
}: BulkCreateBulkUpdateCaseUserActions): Promise<void> {
|
||||
await this.bulkCreateAndLog({
|
||||
userActions: builtUserActions,
|
||||
refresh,
|
||||
|
@ -368,6 +371,7 @@ export class UserActionPersister {
|
|||
userAction: UserActionEvent;
|
||||
} & IndexRefresh): Promise<void> {
|
||||
const createdUserAction = await this.create({ ...userAction.parameters, refresh });
|
||||
|
||||
this.auditLogger.log(userAction.eventDetails, createdUserAction.id);
|
||||
}
|
||||
|
||||
|
@ -381,7 +385,7 @@ export class UserActionPersister {
|
|||
|
||||
const decodedAttributes = decodeOrThrow(UserActionPersistedAttributesRt)(attributes);
|
||||
|
||||
return await this.context.unsecuredSavedObjectsClient.create<T>(
|
||||
const res = await this.context.unsecuredSavedObjectsClient.create<T>(
|
||||
CASE_USER_ACTION_SAVED_OBJECT,
|
||||
decodedAttributes as unknown as T,
|
||||
{
|
||||
|
@ -389,6 +393,7 @@ export class UserActionPersister {
|
|||
refresh,
|
||||
}
|
||||
);
|
||||
return res;
|
||||
} catch (error) {
|
||||
this.context.log.error(`Error on POST a new case user action: ${error}`);
|
||||
throw error;
|
||||
|
|
|
@ -11,7 +11,6 @@ import type {
|
|||
Logger,
|
||||
ISavedObjectsSerializer,
|
||||
SavedObjectsRawDoc,
|
||||
SavedObjectsUpdateResponse,
|
||||
} from '@kbn/core/server';
|
||||
import type { KueryNode } from '@kbn/es-query';
|
||||
import type { AuditLogger } from '@kbn/security-plugin/server';
|
||||
|
@ -22,7 +21,6 @@ import type {
|
|||
ConnectorUserAction,
|
||||
PushedUserAction,
|
||||
UserActionType,
|
||||
CaseAttributes,
|
||||
CaseSettings,
|
||||
CaseSeverity,
|
||||
CaseStatuses,
|
||||
|
@ -35,7 +33,7 @@ import type {
|
|||
UserActionSavedObjectTransformed,
|
||||
} from '../../common/types/user_actions';
|
||||
import type { IndexRefresh } from '../types';
|
||||
import type { CaseSavedObjectTransformed } from '../../common/types/case';
|
||||
import type { PatchCasesArgs } from '../cases/types';
|
||||
import type {
|
||||
AttachmentRequest,
|
||||
CasePostRequest,
|
||||
|
@ -237,6 +235,17 @@ export interface UserActionsStatsAggsResult {
|
|||
};
|
||||
}
|
||||
|
||||
export interface MultipleCasesUserActionsTotalAggsResult {
|
||||
references: {
|
||||
caseUserActions: {
|
||||
buckets: Array<{
|
||||
key: string;
|
||||
doc_count: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParticipantsAggsResult {
|
||||
participants: {
|
||||
buckets: Array<{
|
||||
|
@ -282,12 +291,17 @@ export type CreatePayloadFunction<Item, ActionType extends UserActionType> = (
|
|||
items: Item[]
|
||||
) => UserActionParameters<ActionType>['payload'];
|
||||
|
||||
export interface BulkCreateBulkUpdateCaseUserActions extends IndexRefresh {
|
||||
originalCases: CaseSavedObjectTransformed[];
|
||||
updatedCases: Array<SavedObjectsUpdateResponse<CaseAttributes>>;
|
||||
export interface BuildUserActionsDictParams {
|
||||
updatedCases: PatchCasesArgs;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export type UserActionsDict = Record<string, UserActionEvent[]>;
|
||||
|
||||
export interface BulkCreateBulkUpdateCaseUserActions extends IndexRefresh {
|
||||
builtUserActions: UserActionEvent[];
|
||||
}
|
||||
|
||||
export interface BulkCreateAttachmentUserAction
|
||||
extends Omit<CommonUserActionArgs, 'owner'>,
|
||||
IndexRefresh {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue