[ResponseOps][Cases] Populate total alerts and comments in the cases saved objects (#223992)

## Summary

This is a farewell PR to Cases. Probably my last PR to the cases
codebase. It was quite a journey, and I learned a lot. I hope the best
for the feature of Cases.

## Decisions

Just before Cases was forbidden to do migrations, we did a last
migration to all cases to persist `total_alerts: -1` and
`total_comments: -1`. We did that so that in the future, when we would
want to populate the fields, we would know which cases have their fields
populated and which do not. In this PR, due to time constraints and
criticality of the feature, I took the following decisions:

- Cases return from their APIs the total comments and alerts of each
case. They do that by doing an aggregation, getting the counts, and
merging them with the response. I did not change that behavior. In
following PRs, it can be optimized and fetch the stats only for cases
that do not yet have their stats populated (cases with -1 in the counts)
- When a case is created, the counts are zero.
- When a comment or alert is added, I do an aggregation to get the stats
(total alerts and comments) of the current case, and then update the
counters with the number of the newly created attachments. The case is
updated without version checks. In race conditions, where an attachment
is being added before updating the case, the numbers could be off. This
is a deliberate choice. It can be fixed later with retries and version
concurrency control.
- The case service will continue to not return the `total_alerts` and
`total_comments`.
- The case service will accept the `total_alerts` and `total_comments`
attributes to be able to set them.

Fixes: https://github.com/elastic/kibana/issues/217636

cc @michaelolo24 

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [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
This commit is contained in:
Christos Nasikas 2025-06-23 19:56:01 +03:00 committed by GitHub
parent 0c419c97ac
commit f30335ac3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 834 additions and 90 deletions

View file

@ -15,12 +15,30 @@ describe('delete', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.resetAllMocks();
clientArgs.services.attachmentService.getter.get.mockResolvedValue(mockCaseComments[0]);
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
});
it('refreshes when deleting', async () => {
await deleteComment({ caseID: 'mock-id-1', attachmentID: 'mock-comment-1' }, clientArgs);
expect(clientArgs.services.attachmentService.bulkDelete).toHaveBeenCalledWith({
attachmentIds: ['mock-comment-1'],
refresh: true,
});
}); });
describe('Alerts', () => { describe('Alerts', () => {
const commentSO = mockCaseComments[0]; const commentSO = mockCaseComments[0];
const alertsSO = mockCaseComments[3]; const alertsSO = mockCaseComments[3];
clientArgs.services.attachmentService.getter.get.mockResolvedValue(alertsSO);
beforeEach(() => {
clientArgs.services.attachmentService.getter.get.mockResolvedValue(alertsSO);
});
it('delete alerts correctly', async () => { it('delete alerts correctly', async () => {
await deleteComment({ caseID: 'mock-id-4', attachmentID: 'mock-comment-4' }, clientArgs); await deleteComment({ caseID: 'mock-id-4', attachmentID: 'mock-comment-4' }, clientArgs);
@ -38,10 +56,41 @@ describe('delete', () => {
expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).not.toHaveBeenCalledWith(); expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).not.toHaveBeenCalledWith();
}); });
}); });
describe('Attachment stats', () => {
it('updates attachment stats correctly', async () => {
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map([
[
'mock-id-1',
{
userComments: 2,
alerts: 2,
},
],
])
);
await deleteComment({ caseID: 'mock-id-1', attachmentID: 'mock-comment-1' }, clientArgs);
const args = clientArgs.services.caseService.patchCase.mock.calls[0][0];
expect(args.updatedAttributes.total_comments).toEqual(2);
expect(args.updatedAttributes.total_alerts).toEqual(2);
expect(args.updatedAttributes.updated_at).toBeDefined();
expect(args.updatedAttributes.updated_by).toEqual({
email: 'damaged_raccoon@elastic.co',
full_name: 'Damaged Raccoon',
profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
username: 'damaged_raccoon',
});
});
});
}); });
describe('deleteAll', () => { describe('deleteAll', () => {
const clientArgs = createCasesClientMockArgs(); const clientArgs = createCasesClientMockArgs();
const getAllCaseCommentsResponse = { const getAllCaseCommentsResponse = {
saved_objects: mockCaseComments.map((so) => ({ ...so, score: 0 })), saved_objects: mockCaseComments.map((so) => ({ ...so, score: 0 })),
total: mockCaseComments.length, total: mockCaseComments.length,
@ -51,13 +100,36 @@ describe('delete', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); jest.resetAllMocks();
clientArgs.services.attachmentService.getter.get.mockResolvedValue(mockCaseComments[0]);
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
describe('Alerts', () => {
clientArgs.services.caseService.getAllCaseComments.mockResolvedValue( clientArgs.services.caseService.getAllCaseComments.mockResolvedValue(
getAllCaseCommentsResponse getAllCaseCommentsResponse
); );
});
it('refreshes when deleting', async () => {
await deleteAll({ caseID: 'mock-id-1' }, clientArgs);
expect(clientArgs.services.attachmentService.bulkDelete).toHaveBeenCalledWith({
attachmentIds: [
'mock-comment-1',
'mock-comment-2',
'mock-comment-3',
'mock-comment-4',
'mock-comment-5',
'mock-comment-6',
'mock-comment-7',
],
refresh: true,
});
});
describe('Alerts', () => {
it('delete alerts correctly', async () => { it('delete alerts correctly', async () => {
await deleteAll({ caseID: 'mock-id-4' }, clientArgs); await deleteAll({ caseID: 'mock-id-4' }, clientArgs);
@ -82,5 +154,35 @@ describe('delete', () => {
expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).not.toHaveBeenCalledWith(); expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).not.toHaveBeenCalledWith();
}); });
}); });
describe('Attachment stats', () => {
it('updates attachment stats correctly', async () => {
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map([
[
'mock-id-1',
{
userComments: 0,
alerts: 0,
},
],
])
);
await deleteAll({ caseID: 'mock-id-1' }, clientArgs);
const args = clientArgs.services.caseService.patchCase.mock.calls[0][0];
expect(args.updatedAttributes.total_comments).toEqual(0);
expect(args.updatedAttributes.total_alerts).toEqual(0);
expect(args.updatedAttributes.updated_at).toBeDefined();
expect(args.updatedAttributes.updated_by).toEqual({
email: 'damaged_raccoon@elastic.co',
full_name: 'Damaged Raccoon',
profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
username: 'damaged_raccoon',
});
});
});
}); });
}); });

View file

@ -52,7 +52,14 @@ export async function deleteAll(
await attachmentService.bulkDelete({ await attachmentService.bulkDelete({
attachmentIds: comments.saved_objects.map((so) => so.id), attachmentIds: comments.saved_objects.map((so) => so.id),
refresh: false, refresh: true,
});
await updateCaseAttachmentStats({
caseService: clientArgs.services.caseService,
attachmentService: clientArgs.services.attachmentService,
caseId: caseID,
user,
}); });
await userActionService.creator.bulkCreateAttachmentDeletion({ await userActionService.creator.bulkCreateAttachmentDeletion({
@ -115,7 +122,14 @@ export async function deleteComment(
await attachmentService.bulkDelete({ await attachmentService.bulkDelete({
attachmentIds: [attachmentID], attachmentIds: [attachmentID],
refresh: false, refresh: true,
});
await updateCaseAttachmentStats({
caseService: clientArgs.services.caseService,
attachmentService: clientArgs.services.attachmentService,
caseId: caseID,
user,
}); });
// we only want to store the fields related to the original request of the attachment, not fields like // we only want to store the fields related to the original request of the attachment, not fields like
@ -163,3 +177,42 @@ const handleAlerts = async ({ alertsService, attachments, caseId }: HandleAlerts
const alerts = getAlertInfoFromComments(alertAttachments); const alerts = getAlertInfoFromComments(alertAttachments);
await alertsService.removeCaseIdFromAlerts({ alerts, caseId }); await alertsService.removeCaseIdFromAlerts({ alerts, caseId });
}; };
interface UpdateCaseAttachmentStats {
caseService: CasesClientArgs['services']['caseService'];
attachmentService: CasesClientArgs['services']['attachmentService'];
caseId: string;
user: CasesClientArgs['user'];
}
const updateCaseAttachmentStats = async ({
caseService,
attachmentService,
caseId,
user,
}: UpdateCaseAttachmentStats) => {
const originalCase = await caseService.getCase({
id: caseId,
});
const date = new Date().toISOString();
const attachmentStats = await attachmentService.getter.getCaseAttatchmentStats({
caseIds: [caseId],
});
const totalComments = attachmentStats.get(caseId)?.userComments ?? 0;
const totalAlerts = attachmentStats.get(caseId)?.alerts ?? 0;
await caseService.patchCase({
originalCase,
caseId,
updatedAttributes: {
updated_at: date,
updated_by: user,
total_comments: totalComments,
total_alerts: totalAlerts,
},
refresh: false,
});
};

View file

@ -21,7 +21,9 @@ describe('bulkGet', () => {
unauthorized: [], unauthorized: [],
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();

View file

@ -49,7 +49,7 @@ export const bulkGet = async (
operation: Operations.bulkGetCases, operation: Operations.bulkGetCases,
}); });
const commentTotals = await attachmentService.getter.getCaseCommentStats({ const commentTotals = await attachmentService.getter.getCaseAttatchmentStats({
caseIds: authorizedCases.map((theCase) => theCase.id), caseIds: authorizedCases.map((theCase) => theCase.id),
}); });

View file

@ -53,7 +53,9 @@ describe('update', () => {
saved_objects: [{ ...mockCases[0], attributes: { assignees: cases.cases[0].assignees } }], saved_objects: [{ ...mockCases[0], attributes: { assignees: cases.cases[0].assignees } }],
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
}); });
it('notifies an assignee', async () => { it('notifies an assignee', async () => {
@ -437,7 +439,9 @@ describe('update', () => {
per_page: 10, per_page: 10,
page: 1, page: 1,
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
}); });
it(`does not throw error when category is non empty string less than ${MAX_CATEGORY_LENGTH} characters`, async () => { it(`does not throw error when category is non empty string less than ${MAX_CATEGORY_LENGTH} characters`, async () => {
@ -571,7 +575,9 @@ describe('update', () => {
per_page: 10, per_page: 10,
page: 1, page: 1,
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
}); });
it(`does not throw error when title is non empty string less than ${MAX_TITLE_LENGTH} characters`, async () => { it(`does not throw error when title is non empty string less than ${MAX_TITLE_LENGTH} characters`, async () => {
@ -706,7 +712,9 @@ describe('update', () => {
per_page: 10, per_page: 10,
page: 1, page: 1,
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
}); });
it(`does not throw error when description is non empty string less than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { it(`does not throw error when description is non empty string less than ${MAX_DESCRIPTION_LENGTH} characters`, async () => {
@ -848,7 +856,7 @@ describe('update', () => {
const caseCommentsStats = new Map(); const caseCommentsStats = new Map();
caseCommentsStats.set(mockCases[0].id, { userComments: 1, alerts: 2 }); caseCommentsStats.set(mockCases[0].id, { userComments: 1, alerts: 2 });
caseCommentsStats.set(mockCases[1].id, { userComments: 3, alerts: 4 }); caseCommentsStats.set(mockCases[1].id, { userComments: 3, alerts: 4 });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue( clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
caseCommentsStats caseCommentsStats
); );
}); });
@ -972,7 +980,9 @@ describe('update', () => {
] ]
`); `);
expect(clientArgs.services.attachmentService.getter.getCaseCommentStats).toHaveBeenCalledWith( expect(
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats
).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
caseIds: [mockCases[0].id, mockCases[1].id], caseIds: [mockCases[0].id, mockCases[1].id],
}) })
@ -992,7 +1002,9 @@ describe('update', () => {
per_page: 10, per_page: 10,
page: 1, page: 1,
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
}); });
it('does not throw error when tags array is empty', async () => { it('does not throw error when tags array is empty', async () => {
@ -1197,7 +1209,9 @@ describe('update', () => {
customFields: defaultCustomFieldsConfiguration, customFields: defaultCustomFieldsConfiguration,
}, },
]); ]);
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
}); });
it('can update customFields', async () => { it('can update customFields', async () => {
@ -1587,7 +1601,7 @@ describe('update', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
clientArgsMock.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue( clientArgsMock.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map() new Map()
); );
}); });
@ -1807,7 +1821,7 @@ describe('update', () => {
per_page: 10, per_page: 10,
page: 1, page: 1,
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue( clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map() new Map()
); );
}); });
@ -1915,7 +1929,9 @@ describe('update', () => {
saved_objects: mockCases, saved_objects: mockCases,
}); });
clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map()
);
}); });
it('calculates metrics correctly', async () => { it('calculates metrics correctly', async () => {

View file

@ -516,7 +516,7 @@ export const bulkUpdate = async (
alertsService, alertsService,
}); });
const commentsMap = await attachmentService.getter.getCaseCommentStats({ const commentsMap = await attachmentService.getter.getCaseAttatchmentStats({
caseIds, caseIds,
}); });

View file

@ -103,7 +103,7 @@ export const getCasesByAlertID = async (
return []; return [];
} }
const commentStats = await attachmentService.getter.getCaseCommentStats({ const commentStats = await attachmentService.getter.getCaseAttatchmentStats({
caseIds, caseIds,
}); });
@ -188,7 +188,7 @@ export const get = async (
}); });
if (!includeComments) { if (!includeComments) {
const commentStats = await attachmentService.getter.getCaseCommentStats({ const commentStats = await attachmentService.getter.getCaseAttatchmentStats({
caseIds: [theCase.id], caseIds: [theCase.id],
}); });
return decodeOrThrow(CaseRt)( return decodeOrThrow(CaseRt)(

View file

@ -33,6 +33,7 @@ describe('CaseCommentModel', () => {
clientArgs.services.attachmentService.bulkCreate.mockResolvedValue({ clientArgs.services.attachmentService.bulkCreate.mockResolvedValue({
saved_objects: mockCaseComments, saved_objects: mockCaseComments,
}); });
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(new Map());
const alertIdsAttachedToCase = new Set(['test-id-4']); const alertIdsAttachedToCase = new Set(['test-id-4']);
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValue( clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValue(
@ -85,7 +86,7 @@ describe('CaseCommentModel', () => {
"type": "cases", "type": "cases",
}, },
], ],
"refresh": false, "refresh": true,
}, },
], ],
] ]
@ -136,7 +137,7 @@ describe('CaseCommentModel', () => {
"type": "cases", "type": "cases",
}, },
], ],
"refresh": false, "refresh": true,
}, },
], ],
] ]
@ -189,7 +190,7 @@ describe('CaseCommentModel', () => {
"type": "cases", "type": "cases",
}, },
], ],
"refresh": false, "refresh": true,
}, },
], ],
] ]
@ -244,7 +245,7 @@ describe('CaseCommentModel', () => {
"type": "cases", "type": "cases",
}, },
], ],
"refresh": false, "refresh": true,
}, },
], ],
] ]
@ -291,6 +292,62 @@ describe('CaseCommentModel', () => {
expect(args.version).toBeUndefined(); expect(args.version).toBeUndefined();
}); });
it('updates the total number of comments correctly', async () => {
// user comment
clientArgs.services.attachmentService.create.mockResolvedValue(mockCaseComments[0]);
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map([
[
'mock-id-1',
{
userComments: 2,
alerts: 2,
},
],
])
);
await model.createComment({
id: 'comment-1',
commentReq: comment,
createdDate,
});
const args = clientArgs.services.caseService.patchCase.mock.calls[0][0];
expect(args.updatedAttributes.total_comments).toEqual(2);
expect(args.updatedAttributes.total_alerts).toEqual(2);
});
it('updates the total number of alerts correctly', async () => {
// alert comment
clientArgs.services.attachmentService.create.mockResolvedValue(mockCaseComments[3]);
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map([
[
'mock-id-1',
{
userComments: 1,
alerts: 3,
},
],
])
);
await model.createComment({
id: 'comment-1',
commentReq: alertComment,
createdDate,
});
const args = clientArgs.services.caseService.patchCase.mock.calls[0][0];
expect(args.updatedAttributes.total_alerts).toEqual(3);
expect(args.updatedAttributes.total_comments).toEqual(1);
});
describe('validation', () => { describe('validation', () => {
clientArgs.services.attachmentService.countPersistableStateAndExternalReferenceAttachments.mockResolvedValue( clientArgs.services.attachmentService.countPersistableStateAndExternalReferenceAttachments.mockResolvedValue(
MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES
@ -611,6 +668,58 @@ describe('CaseCommentModel', () => {
expect(args.version).toBeUndefined(); expect(args.version).toBeUndefined();
}); });
it('updates the total number of comments and alerts correctly', async () => {
clientArgs.services.attachmentService.bulkCreate.mockResolvedValue({
saved_objects: mockCaseComments,
});
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map([
[
'mock-id-1',
{
userComments: 4,
alerts: 5,
},
],
])
);
await model.bulkCreate({
attachments: [
{
id: 'mock-comment-1',
...comment,
},
{
id: 'mock-comment-2',
...comment,
},
{
id: 'mock-comment-3',
...comment,
},
{
id: 'mock-comment-4',
...alertComment,
},
{
id: 'mock-comment-5',
...alertComment,
},
{
id: 'mock-comment-6',
...alertComment,
},
],
});
const args = clientArgs.services.caseService.patchCase.mock.calls[0][0];
expect(args.updatedAttributes.total_alerts).toEqual(5);
expect(args.updatedAttributes.total_comments).toEqual(4);
});
describe('validation', () => { describe('validation', () => {
clientArgs.services.attachmentService.countPersistableStateAndExternalReferenceAttachments.mockResolvedValue( clientArgs.services.attachmentService.countPersistableStateAndExternalReferenceAttachments.mockResolvedValue(
MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES
@ -670,5 +779,71 @@ describe('CaseCommentModel', () => {
expect(args.version).toBeUndefined(); expect(args.version).toBeUndefined();
}); });
it('does not increase the counters when updating a user comment', async () => {
// the case has 1 user comment and 2 alert comments
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map([
[
'mock-id-1',
{
userComments: 1,
alerts: 2,
},
],
])
);
await model.updateComment({
updateRequest: {
id: 'comment-id',
version: 'comment-version',
type: AttachmentType.user,
comment: 'my updated comment',
owner: SECURITY_SOLUTION_OWNER,
},
updatedAt: createdDate,
owner: SECURITY_SOLUTION_OWNER,
});
const args = clientArgs.services.caseService.patchCase.mock.calls[0][0];
expect(args.updatedAttributes.total_alerts).toEqual(2);
expect(args.updatedAttributes.total_comments).toEqual(1);
});
it('does not increase the counters when updating an alert', async () => {
// the case has 1 user comment and 2 alert comments
clientArgs.services.attachmentService.getter.getCaseAttatchmentStats.mockResolvedValue(
new Map([
[
'mock-id-1',
{
userComments: 1,
alerts: 2,
},
],
])
);
await model.updateComment({
updateRequest: {
id: 'comment-id',
version: 'comment-version',
type: AttachmentType.alert,
alertId: ['alert-id-1'],
index: ['alert-index-1'],
rule: { id: 'rule-id-1', name: 'rule-name-1' },
owner: SECURITY_SOLUTION_OWNER,
},
updatedAt: createdDate,
owner: SECURITY_SOLUTION_OWNER,
});
const args = clientArgs.services.caseService.patchCase.mock.calls[0][0];
expect(args.updatedAttributes.total_alerts).toEqual(2);
expect(args.updatedAttributes.total_comments).toEqual(1);
});
}); });
}); });

View file

@ -129,7 +129,7 @@ export class CaseCommentModel {
}, },
options, options,
}), }),
this.partialUpdateCaseUserAndDateSkipRefresh(updatedAt), this.partialUpdateCaseWithAttachmentDataSkipRefresh({ date: updatedAt }),
]); ]);
await commentableCase.createUpdateCommentUserAction(comment, updateRequest, owner); await commentableCase.createUpdateCommentUserAction(comment, updateRequest, owner);
@ -144,21 +144,35 @@ export class CaseCommentModel {
} }
} }
private async partialUpdateCaseUserAndDateSkipRefresh(date: string) { private async partialUpdateCaseWithAttachmentDataSkipRefresh({
return this.partialUpdateCaseUserAndDate(date, false); date,
}: {
date: string;
}): Promise<CaseCommentModel> {
return this.partialUpdateCaseWithAttachmentData({
date,
refresh: false,
});
} }
private async partialUpdateCaseUserAndDate( private async partialUpdateCaseWithAttachmentData({
date: string, date,
refresh: RefreshSetting refresh,
): Promise<CaseCommentModel> { }: {
date: string;
refresh: RefreshSetting;
}): Promise<CaseCommentModel> {
try { try {
const { totalComments, totalAlerts } = await this.getAttachmentStats();
const updatedCase = await this.params.services.caseService.patchCase({ const updatedCase = await this.params.services.caseService.patchCase({
originalCase: this.caseInfo, originalCase: this.caseInfo,
caseId: this.caseInfo.id, caseId: this.caseInfo.id,
updatedAttributes: { updatedAttributes: {
updated_at: date, updated_at: date,
updated_by: { ...this.params.user }, updated_by: { ...this.params.user },
total_comments: totalComments,
total_alerts: totalAlerts,
}, },
refresh, refresh,
}); });
@ -180,6 +194,21 @@ export class CaseCommentModel {
} }
} }
private async getAttachmentStats() {
const attachmentStats =
await this.params.services.attachmentService.getter.getCaseAttatchmentStats({
caseIds: [this.caseInfo.id],
});
const totalComments = attachmentStats.get(this.caseInfo.id)?.userComments ?? 0;
const totalAlerts = attachmentStats.get(this.caseInfo.id)?.alerts ?? 0;
return {
totalComments,
totalAlerts,
};
}
private newObjectWithInfo(caseInfo: CaseSavedObjectTransformed): CaseCommentModel { private newObjectWithInfo(caseInfo: CaseSavedObjectTransformed): CaseCommentModel {
return new CaseCommentModel(caseInfo, this.params); return new CaseCommentModel(caseInfo, this.params);
} }
@ -230,19 +259,20 @@ export class CaseCommentModel {
const references = [...this.buildRefsToCase(), ...this.getCommentReferences(attachment)]; const references = [...this.buildRefsToCase(), ...this.getCommentReferences(attachment)];
const [comment, commentableCase] = await Promise.all([ const comment = await this.params.services.attachmentService.create({
this.params.services.attachmentService.create({ attributes: transformNewComment({
attributes: transformNewComment({ createdDate,
createdDate, ...attachment,
...attachment, ...this.params.user,
...this.params.user,
}),
references,
id,
refresh: false,
}), }),
this.partialUpdateCaseUserAndDateSkipRefresh(createdDate), references,
]); id,
refresh: true,
});
const commentableCase = await this.partialUpdateCaseWithAttachmentDataSkipRefresh({
date: createdDate,
});
await Promise.all([ await Promise.all([
commentableCase.handleAlertComments([attachment]), commentableCase.handleAlertComments([attachment]),
@ -486,23 +516,24 @@ export class CaseCommentModel {
const caseReference = this.buildRefsToCase(); const caseReference = this.buildRefsToCase();
const [newlyCreatedAttachments, commentableCase] = await Promise.all([ const newlyCreatedAttachments = await this.params.services.attachmentService.bulkCreate({
this.params.services.attachmentService.bulkCreate({ attachments: attachmentWithoutDuplicateAlerts.map(({ id, ...attachment }) => {
attachments: attachmentWithoutDuplicateAlerts.map(({ id, ...attachment }) => { return {
return { attributes: transformNewComment({
attributes: transformNewComment({ createdDate: new Date().toISOString(),
createdDate: new Date().toISOString(), ...attachment,
...attachment, ...this.params.user,
...this.params.user, }),
}), references: [...caseReference, ...this.getCommentReferences(attachment)],
references: [...caseReference, ...this.getCommentReferences(attachment)], id,
id, };
};
}),
refresh: false,
}), }),
this.partialUpdateCaseUserAndDateSkipRefresh(new Date().toISOString()), refresh: true,
]); });
const commentableCase = await this.partialUpdateCaseWithAttachmentDataSkipRefresh({
date: new Date().toISOString(),
});
const savedObjectsWithoutErrors = newlyCreatedAttachments.saved_objects.filter( const savedObjectsWithoutErrors = newlyCreatedAttachments.saved_objects.filter(
(attachment) => attachment.error == null (attachment) => attachment.error == null

View file

@ -6,6 +6,7 @@
*/ */
import { omit } from 'lodash'; import { omit } from 'lodash';
import { number } from 'io-ts';
import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common'; import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common';
import { import {
CaseTransformedAttributesRt, CaseTransformedAttributesRt,
@ -52,8 +53,9 @@ describe('case types', () => {
assignees: [], assignees: [],
observables: [], observables: [],
}; };
const caseTransformedAttributesProps = CaseTransformedAttributesRt.types.reduce( const caseTransformedAttributesProps = CaseTransformedAttributesRt.types.reduce(
(acc, type) => ({ ...acc, ...type.type.props }), (acc, type) => ({ ...acc, ...type.type.props, total_comments: number, total_alerts: number }),
{} {}
); );
@ -78,6 +80,18 @@ describe('case types', () => {
// @ts-expect-error: the check above ensures that right exists // @ts-expect-error: the check above ensures that right exists
expect(decodedRes.right).toEqual({ description: 'test' }); expect(decodedRes.right).toEqual({ description: 'test' });
}); });
it('does not remove the attachment stats', () => {
const decodedRes = type.decode({
description: 'test',
total_alerts: 0,
total_comments: 0,
});
expect(decodedRes._tag).toEqual('Right');
// @ts-expect-error: the check above ensures that right exists
expect(decodedRes.right).toEqual({ description: 'test', total_alerts: 0, total_comments: 0 });
});
}); });
describe('OwnerRt', () => { describe('OwnerRt', () => {

View file

@ -7,7 +7,7 @@
import type { SavedObject } from '@kbn/core-saved-objects-server'; import type { SavedObject } from '@kbn/core-saved-objects-server';
import type { Type } from 'io-ts'; import type { Type } from 'io-ts';
import { exact, partial, strict, string } from 'io-ts'; import { exact, partial, strict, string, number } from 'io-ts';
import type { CaseAttributes, Observable } from '../../../common/types/domain'; import type { CaseAttributes, Observable } from '../../../common/types/domain';
import { CaseAttributesRt } from '../../../common/types/domain'; import { CaseAttributesRt } from '../../../common/types/domain';
import type { ConnectorPersisted } from './connectors'; import type { ConnectorPersisted } from './connectors';
@ -64,16 +64,28 @@ type CasePersistedCustomFields = Array<{
}>; }>;
export type CaseTransformedAttributes = CaseAttributes; export type CaseTransformedAttributes = CaseAttributes;
export type CaseTransformedAttributesWithAttachmentStats = CaseAttributes & {
total_comments: number;
total_alerts: number;
};
export const CaseTransformedAttributesRt = CaseAttributesRt; export const CaseTransformedAttributesRt = CaseAttributesRt;
export const getPartialCaseTransformedAttributesRt = (): Type<Partial<CaseAttributes>> => { export const getPartialCaseTransformedAttributesRt = (): Type<
Partial<CaseTransformedAttributesWithAttachmentStats>
> => {
const caseTransformedAttributesProps = CaseAttributesRt.types.reduce( const caseTransformedAttributesProps = CaseAttributesRt.types.reduce(
(acc, type) => Object.assign(acc, type.type.props), (acc, type) => Object.assign(acc, type.type.props),
{} {}
); );
return exact(partial({ ...caseTransformedAttributesProps })); return exact(
/**
* We add the `total_comments` and `total_alerts` properties to allow the
* attachments stats to be updated.
*/
partial({ ...caseTransformedAttributesProps, total_comments: number, total_alerts: number })
);
}; };
export type CaseSavedObject = SavedObject<CasePersistedAttributes>; export type CaseSavedObject = SavedObject<CasePersistedAttributes>;

View file

@ -249,7 +249,7 @@ export class AttachmentGetter {
} }
} }
public async getCaseCommentStats({ public async getCaseAttatchmentStats({
caseIds, caseIds,
}: { }: {
caseIds: string[]; caseIds: string[];

View file

@ -287,12 +287,16 @@ describe('CasesService', () => {
await service.patchCase({ await service.patchCase({
caseId: '1', caseId: '1',
updatedAttributes: createCasePostParams({ updatedAttributes: {
connector: createJiraConnector(), ...createCasePostParams({
externalService: createExternalService(), connector: createJiraConnector(),
severity: CaseSeverity.CRITICAL, externalService: createExternalService(),
status: CaseStatuses['in-progress'], severity: CaseSeverity.CRITICAL,
}), status: CaseStatuses['in-progress'],
}),
total_alerts: 10,
total_comments: 5,
},
originalCase: {} as CaseSavedObjectTransformed, originalCase: {} as CaseSavedObjectTransformed,
}); });
@ -327,6 +331,8 @@ describe('CasesService', () => {
"defacement", "defacement",
], ],
"title": "Super Bad Security Issue", "title": "Super Bad Security Issue",
"total_alerts": 10,
"total_comments": 5,
"updated_at": "2019-11-25T21:54:48.952Z", "updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object { "updated_by": Object {
"email": "testemail@elastic.co", "email": "testemail@elastic.co",
@ -661,6 +667,33 @@ describe('CasesService', () => {
expect(patchAttributes.status).toEqual(expectedStatus); expect(patchAttributes.status).toEqual(expectedStatus);
} }
); );
it('updates the total attachment stats', async () => {
unsecuredSavedObjectsClient.update.mockResolvedValue(
{} as SavedObjectsUpdateResponse<CasePersistedAttributes>
);
await service.patchCase({
caseId: '1',
updatedAttributes: {
...createCasePostParams({
connector: createJiraConnector(),
externalService: createExternalService(),
severity: CaseSeverity.CRITICAL,
status: CaseStatuses['in-progress'],
}),
total_alerts: 10,
total_comments: 5,
},
originalCase: {} as CaseSavedObjectTransformed,
});
const patchAttributes = unsecuredSavedObjectsClient.update.mock
.calls[0][2] as CasePersistedAttributes;
expect(patchAttributes.total_alerts).toEqual(10);
expect(patchAttributes.total_comments).toEqual(5);
});
}); });
describe('bulkPatch', () => { describe('bulkPatch', () => {
@ -765,6 +798,39 @@ describe('CasesService', () => {
expect(patchResults[1].attributes.status).toEqual(CasePersistedStatus.IN_PROGRESS); expect(patchResults[1].attributes.status).toEqual(CasePersistedStatus.IN_PROGRESS);
expect(patchResults[2].attributes.status).toEqual(CasePersistedStatus.CLOSED); expect(patchResults[2].attributes.status).toEqual(CasePersistedStatus.CLOSED);
}); });
it('updates the total attachment stats', async () => {
unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({
saved_objects: [
createCaseSavedObjectResponse({ caseId: '1' }),
createCaseSavedObjectResponse({ caseId: '2' }),
createCaseSavedObjectResponse({ caseId: '3' }),
],
});
await service.patchCases({
cases: [
{
caseId: '1',
updatedAttributes: {
...createCasePostParams({
connector: getNoneCaseConnector(),
status: CaseStatuses.open,
}),
total_alerts: 10,
total_comments: 5,
},
originalCase: {} as CaseSavedObjectTransformed,
},
],
});
const patchResults = unsecuredSavedObjectsClient.bulkUpdate.mock
.calls[0][0] as unknown as Array<SavedObject<CasePersistedAttributes>>;
expect(patchResults[0].attributes.total_alerts).toEqual(10);
expect(patchResults[0].attributes.total_comments).toEqual(5);
});
}); });
describe('createCase', () => { describe('createCase', () => {
@ -852,8 +918,8 @@ describe('CasesService', () => {
"defacement", "defacement",
], ],
"title": "Super Bad Security Issue", "title": "Super Bad Security Issue",
"total_alerts": -1, "total_alerts": 0,
"total_comments": -1, "total_comments": 0,
"updated_at": "2019-11-25T21:54:48.952Z", "updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object { "updated_by": Object {
"email": "testemail@elastic.co", "email": "testemail@elastic.co",
@ -895,8 +961,8 @@ describe('CasesService', () => {
const postAttributes = unsecuredSavedObjectsClient.create.mock const postAttributes = unsecuredSavedObjectsClient.create.mock
.calls[0][1] as CasePersistedAttributes; .calls[0][1] as CasePersistedAttributes;
expect(postAttributes.total_alerts).toEqual(-1); expect(postAttributes.total_alerts).toEqual(0);
expect(postAttributes.total_comments).toEqual(-1); expect(postAttributes.total_comments).toEqual(0);
}); });
it('moves the connector.id and connector_id to the references', async () => { it('moves the connector.id and connector_id to the references', async () => {
@ -1073,8 +1139,8 @@ describe('CasesService', () => {
"defacement", "defacement",
], ],
"title": "Super Bad Security Issue", "title": "Super Bad Security Issue",
"total_alerts": -1, "total_alerts": 0,
"total_comments": -1, "total_comments": 0,
"updated_at": "2019-11-25T21:54:48.952Z", "updated_at": "2019-11-25T21:54:48.952Z",
"updated_by": Object { "updated_by": Object {
"email": "testemail@elastic.co", "email": "testemail@elastic.co",
@ -1112,8 +1178,8 @@ describe('CasesService', () => {
const postAttributes = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0][0] const postAttributes = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0][0]
.attributes as CasePersistedAttributes; .attributes as CasePersistedAttributes;
expect(postAttributes.total_alerts).toEqual(-1); expect(postAttributes.total_alerts).toEqual(0);
expect(postAttributes.total_comments).toEqual(-1); expect(postAttributes.total_comments).toEqual(0);
}); });
}); });
@ -2200,7 +2266,10 @@ describe('CasesService', () => {
* - external_service * - external_service
* - category * - category
* *
* Decode is not expected to throw an error as they are defined. * The following fields can be undefined:
* - total_alerts
* - total_comments
* - incremental_id
*/ */
const attributesToValidateIfMissing = omit( const attributesToValidateIfMissing = omit(
caseTransformedAttributesProps, caseTransformedAttributesProps,
@ -2212,6 +2281,8 @@ describe('CasesService', () => {
'customFields', 'customFields',
'observables', 'observables',
'incremental_id', 'incremental_id',
'total_alerts',
'total_comments',
'in_progress_at', 'in_progress_at',
'time_to_acknowledge', 'time_to_acknowledge',
'time_to_resolve', 'time_to_resolve',

View file

@ -181,7 +181,7 @@ export class CasesService {
return accMap; return accMap;
}, new Map<string, SavedObjectsFindResult<CaseTransformedAttributes>>()); }, new Map<string, SavedObjectsFindResult<CaseTransformedAttributes>>());
const commentTotals = await this.attachmentService.getter.getCaseCommentStats({ const commentTotals = await this.attachmentService.getter.getCaseAttatchmentStats({
caseIds: Array.from(casesMap.keys()), caseIds: Array.from(casesMap.keys()),
}); });
@ -594,8 +594,8 @@ export class CasesService {
const decodedAttributes = decodeOrThrow(CaseTransformedAttributesRt)(attributes); const decodedAttributes = decodeOrThrow(CaseTransformedAttributesRt)(attributes);
const transformedAttributes = transformAttributesToESModel(decodedAttributes); const transformedAttributes = transformAttributesToESModel(decodedAttributes);
transformedAttributes.attributes.total_alerts = -1; transformedAttributes.attributes.total_alerts = 0;
transformedAttributes.attributes.total_comments = -1; transformedAttributes.attributes.total_comments = 0;
const createdCase = await this.unsecuredSavedObjectsClient.create<CasePersistedAttributes>( const createdCase = await this.unsecuredSavedObjectsClient.create<CasePersistedAttributes>(
CASE_SAVED_OBJECT, CASE_SAVED_OBJECT,
@ -626,8 +626,8 @@ export class CasesService {
const { attributes: transformedAttributes, referenceHandler } = const { attributes: transformedAttributes, referenceHandler } =
transformAttributesToESModel(decodedAttributes); transformAttributesToESModel(decodedAttributes);
transformedAttributes.total_alerts = -1; transformedAttributes.total_alerts = 0;
transformedAttributes.total_comments = -1; transformedAttributes.total_comments = 0;
return { return {
type: CASE_SAVED_OBJECT, type: CASE_SAVED_OBJECT,

View file

@ -243,6 +243,28 @@ describe('case transforms', () => {
expect(transformedAttributes.attributes.status).toBe(expectedStatusValue); expect(transformedAttributes.attributes.status).toBe(expectedStatusValue);
} }
); );
it('does not return the total alerts', () => {
expect(
transformUpdateResponseToExternalModel({
type: 'a',
id: '1',
attributes: { total_alerts: 2 },
references: undefined,
}).attributes
).not.toHaveProperty('total_alerts');
});
it('does not return the total comments', () => {
expect(
transformUpdateResponseToExternalModel({
type: 'a',
id: '1',
attributes: { total_comments: 2 },
references: undefined,
}).attributes
).not.toHaveProperty('total_comments');
});
}); });
describe('transformAttributesToESModel', () => { describe('transformAttributesToESModel', () => {
@ -438,6 +460,22 @@ describe('case transforms', () => {
'incremental_id' 'incremental_id'
); );
}); });
it('does not remove the total alerts', () => {
expect(transformAttributesToESModel({ total_alerts: 10 }).attributes).toMatchInlineSnapshot(`
Object {
"total_alerts": 10,
}
`);
});
it('does not remove the total comments', () => {
expect(transformAttributesToESModel({ total_comments: 5 }).attributes).toMatchInlineSnapshot(`
Object {
"total_comments": 5,
}
`);
});
}); });
describe('transformSavedObjectToExternalModel', () => { describe('transformSavedObjectToExternalModel', () => {
@ -656,5 +694,31 @@ describe('case transforms', () => {
transformSavedObjectToExternalModel(CaseSOResponseWithObservables).attributes.incremental_id transformSavedObjectToExternalModel(CaseSOResponseWithObservables).attributes.incremental_id
).not.toBeDefined(); ).not.toBeDefined();
}); });
it('does not return the total comments', () => {
const resWithTotalComments = createCaseSavedObjectResponse({
overrides: {
total_comments: 3,
},
});
expect(
// @ts-expect-error: total_comments is not defined in the attributes
transformSavedObjectToExternalModel(resWithTotalComments).attributes.total_comments
).not.toBeDefined();
});
it('does not return the total alerts', () => {
const resWithTotalAlerts = createCaseSavedObjectResponse({
overrides: {
total_alerts: 2,
},
});
expect(
// @ts-expect-error: total_alerts is not defined in the attributes
transformSavedObjectToExternalModel(resWithTotalAlerts).attributes.total_alerts
).not.toBeDefined();
});
}); });
}); });

View file

@ -32,7 +32,11 @@ import {
transformESConnectorToExternalModel, transformESConnectorToExternalModel,
} from '../transform'; } from '../transform';
import { ConnectorReferenceHandler } from '../connector_reference_handler'; import { ConnectorReferenceHandler } from '../connector_reference_handler';
import type { CasePersistedAttributes, CaseTransformedAttributes } from '../../common/types/case'; import type {
CasePersistedAttributes,
CaseTransformedAttributes,
CaseTransformedAttributesWithAttachmentStats,
} from '../../common/types/case';
import type { ExternalServicePersisted } from '../../common/types/external_service'; import type { ExternalServicePersisted } from '../../common/types/external_service';
export function transformUpdateResponseToExternalModel( export function transformUpdateResponseToExternalModel(
@ -89,10 +93,14 @@ export function transformAttributesToESModel(caseAttributes: CaseTransformedAttr
attributes: CasePersistedAttributes; attributes: CasePersistedAttributes;
referenceHandler: ConnectorReferenceHandler; referenceHandler: ConnectorReferenceHandler;
}; };
export function transformAttributesToESModel(caseAttributes: Partial<CaseTransformedAttributes>): {
export function transformAttributesToESModel(
caseAttributes: Partial<CaseTransformedAttributesWithAttachmentStats>
): {
attributes: Partial<CasePersistedAttributes>; attributes: Partial<CasePersistedAttributes>;
referenceHandler: ConnectorReferenceHandler; referenceHandler: ConnectorReferenceHandler;
}; };
export function transformAttributesToESModel(caseAttributes: Partial<CaseTransformedAttributes>): { export function transformAttributesToESModel(caseAttributes: Partial<CaseTransformedAttributes>): {
attributes: Partial<CasePersistedAttributes>; attributes: Partial<CasePersistedAttributes>;
referenceHandler: ConnectorReferenceHandler; referenceHandler: ConnectorReferenceHandler;

View file

@ -26,6 +26,11 @@ export interface PushedArgs {
pushed_by: User; pushed_by: User;
} }
export interface AttachmentStatsAttributes {
total_comments: number;
total_alerts: number;
}
export interface GetCaseArgs { export interface GetCaseArgs {
id: string; id: string;
} }
@ -57,7 +62,7 @@ export interface BulkCreateCasesArgs extends IndexRefresh {
export interface PatchCase extends IndexRefresh { export interface PatchCase extends IndexRefresh {
caseId: string; caseId: string;
updatedAttributes: Partial<CaseTransformedAttributes & PushedArgs>; updatedAttributes: Partial<CaseTransformedAttributes & PushedArgs & AttachmentStatsAttributes>;
originalCase: CaseSavedObjectTransformed; originalCase: CaseSavedObjectTransformed;
version?: string; version?: string;
} }

View file

@ -158,7 +158,7 @@ const createAttachmentGetterServiceMock = (): AttachmentGetterServiceMock => {
get: jest.fn(), get: jest.fn(),
bulkGet: jest.fn(), bulkGet: jest.fn(),
getAllAlertsAttachToCase: jest.fn(), getAllAlertsAttachToCase: jest.fn(),
getCaseCommentStats: jest.fn(), getCaseAttatchmentStats: jest.fn(),
getAttachmentIdsForCases: jest.fn(), getAttachmentIdsForCases: jest.fn(),
getFileAttachments: jest.fn(), getFileAttachments: jest.fn(),
getAllAlertIds: jest.fn(), getAllAlertIds: jest.fn(),

View file

@ -18,6 +18,7 @@ import {
getSpaceUrlPrefix, getSpaceUrlPrefix,
removeServerGeneratedPropertiesFromCase, removeServerGeneratedPropertiesFromCase,
removeServerGeneratedPropertiesFromUserAction, removeServerGeneratedPropertiesFromUserAction,
getCaseSavedObjectsFromES,
} from '../../../../common/lib/api'; } from '../../../../common/lib/api';
import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; import type { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { import {
@ -232,6 +233,28 @@ export default ({ getService }: FtrProviderContext): void => {
}); });
}); });
describe('attachment stats', () => {
it('should set the attachment stats to zero', async () => {
await bulkCreateCases({
data: {
cases: [getPostCaseRequest(), getPostCaseRequest({ severity: CaseSeverity.MEDIUM })],
},
});
const res = await getCaseSavedObjectsFromES({ es });
expect(res.body.hits.hits.length).to.eql(2);
const firstCase = res.body.hits.hits[0]._source?.cases!;
const secondCase = res.body.hits.hits[1]._source?.cases!;
expect(firstCase.total_alerts).to.eql(0);
expect(firstCase.total_comments).to.eql(0);
expect(secondCase.total_alerts).to.eql(0);
expect(secondCase.total_comments).to.eql(0);
});
});
describe('rbac', () => { describe('rbac', () => {
it('returns a 403 when attempting to create a case with an owner that was from a disabled feature in the space', async () => { it('returns a 403 when attempting to create a case with an owner that was from a disabled feature in the space', async () => {
const theCase = (await bulkCreateCases({ const theCase = (await bulkCreateCases({

View file

@ -24,6 +24,7 @@ import {
removeServerGeneratedPropertiesFromUserAction, removeServerGeneratedPropertiesFromUserAction,
createConfiguration, createConfiguration,
getConfigurationRequest, getConfigurationRequest,
getCaseSavedObjectsFromES,
} from '../../../../common/lib/api'; } from '../../../../common/lib/api';
import { import {
secOnly, secOnly,
@ -721,6 +722,21 @@ export default ({ getService }: FtrProviderContext): void => {
}); });
}); });
describe('attachment stats', () => {
it('should set the attachment stats to zero', async () => {
await createCase(supertest, getPostCaseRequest());
const res = await getCaseSavedObjectsFromES({ es });
expect(res.body.hits.hits.length).to.eql(1);
const theCase = res.body.hits.hits[0]._source?.cases!;
expect(theCase.total_alerts).to.eql(0);
expect(theCase.total_comments).to.eql(0);
});
});
describe('rbac', () => { describe('rbac', () => {
it('returns a 403 when attempting to create a case with an owner that was from a disabled feature in the space', async () => { it('returns a 403 when attempting to create a case with an owner that was from a disabled feature in the space', async () => {
const theCase = (await createCase( const theCase = (await createCase(

View file

@ -20,7 +20,12 @@ import {
} from '../../../../../common/utils/security_solution'; } from '../../../../../common/utils/security_solution';
import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; import type { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { getPostCaseRequest, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; import {
getPostCaseRequest,
postCaseReq,
postCommentAlertReq,
postCommentUserReq,
} from '../../../../common/lib/mock';
import { import {
deleteAllCaseItems, deleteAllCaseItems,
deleteCasesByESQuery, deleteCasesByESQuery,
@ -30,6 +35,9 @@ import {
createComment, createComment,
deleteComment, deleteComment,
superUserSpace1Auth, superUserSpace1Auth,
getCaseSavedObjectsFromES,
bulkCreateAttachments,
resolveCase,
} from '../../../../common/lib/api'; } from '../../../../common/lib/api';
import { import {
globalRead, globalRead,
@ -278,6 +286,50 @@ export default ({ getService }: FtrProviderContext): void => {
}); });
}); });
describe('attachment stats', () => {
it('should set the attachment stats correctly', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentUserReq, postCommentUserReq, postCommentAlertReq],
expectedHttpCode: 200,
});
const resolvedCase = await resolveCase({
supertest,
caseId: postedCase.id,
});
const caseComments = resolvedCase.case.comments!;
const userComment = caseComments?.find((comment) => comment.type === 'user');
const alertComment = caseComments?.find((comment) => comment.type === 'alert');
await deleteComment({
supertest,
caseId: postedCase.id,
commentId: userComment!.id,
});
await deleteComment({
supertest,
caseId: postedCase.id,
commentId: alertComment!.id,
});
const res = await getCaseSavedObjectsFromES({ es });
expect(res.body.hits.hits.length).to.eql(1);
const theCase = res.body.hits.hits[0]._source?.cases!;
expect(theCase.total_alerts).to.eql(0);
expect(theCase.total_comments).to.eql(1);
});
});
describe('rbac', () => { describe('rbac', () => {
afterEach(async () => { afterEach(async () => {
await deleteAllCaseItems(es); await deleteAllCaseItems(es);

View file

@ -42,6 +42,7 @@ import {
superUserSpace1Auth, superUserSpace1Auth,
bulkCreateAttachments, bulkCreateAttachments,
getAllComments, getAllComments,
getCaseSavedObjectsFromES,
} from '../../../../common/lib/api'; } from '../../../../common/lib/api';
import { import {
globalRead, globalRead,
@ -362,6 +363,33 @@ export default ({ getService }: FtrProviderContext): void => {
}); });
}); });
describe('attachment stats', () => {
it('should set the attachment stats correctly', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentUserReq, postCommentUserReq, postCommentAlertReq],
expectedHttpCode: 200,
});
await deleteAllComments({
supertest,
caseId: postedCase.id,
});
const res = await getCaseSavedObjectsFromES({ es });
expect(res.body.hits.hits.length).to.eql(1);
const theCase = res.body.hits.hits[0]._source?.cases!;
expect(theCase.total_alerts).to.eql(0);
expect(theCase.total_comments).to.eql(0);
});
});
describe('rbac', () => { describe('rbac', () => {
afterEach(async () => { afterEach(async () => {
await deleteAllCaseItems(es); await deleteAllCaseItems(es);

View file

@ -31,6 +31,7 @@ import {
fileAttachmentMetadata, fileAttachmentMetadata,
fileMetadata, fileMetadata,
postCommentAlertMultipleIdsReq, postCommentAlertMultipleIdsReq,
postCommentActionsReq,
} from '../../../../common/lib/mock'; } from '../../../../common/lib/mock';
import { import {
deleteAllCaseItems, deleteAllCaseItems,
@ -46,6 +47,7 @@ import {
removeServerGeneratedPropertiesFromUserAction, removeServerGeneratedPropertiesFromUserAction,
getAllComments, getAllComments,
bulkCreateAttachments, bulkCreateAttachments,
getCaseSavedObjectsFromES,
} from '../../../../common/lib/api'; } from '../../../../common/lib/api';
import { import {
createAlertsIndex, createAlertsIndex,
@ -1152,6 +1154,48 @@ export default ({ getService }: FtrProviderContext): void => {
expectedHttpCode: 200, expectedHttpCode: 200,
}); });
}); });
it('should set the attachment stats correctly', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentUserReq,
expectedHttpCode: 200,
});
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentUserReq,
expectedHttpCode: 200,
});
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentAlertReq,
expectedHttpCode: 200,
});
// an attachment that is not a comment or an alert should not affect the stats
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentActionsReq,
expectedHttpCode: 200,
});
const res = await getCaseSavedObjectsFromES({ es });
expect(res.body.hits.hits.length).to.eql(1);
const theCase = res.body.hits.hits[0]._source?.cases!;
expect(theCase.total_alerts).to.eql(1);
expect(theCase.total_comments).to.eql(2);
});
}); });
describe('rbac', () => { describe('rbac', () => {

View file

@ -26,6 +26,7 @@ import {
postExternalReferenceSOReq, postExternalReferenceSOReq,
fileMetadata, fileMetadata,
postCommentAlertMultipleIdsReq, postCommentAlertMultipleIdsReq,
postCommentActionsReq,
} from '../../../../common/lib/mock'; } from '../../../../common/lib/mock';
import { import {
deleteAllCaseItems, deleteAllCaseItems,
@ -41,6 +42,7 @@ import {
deleteAllFiles, deleteAllFiles,
getAllComments, getAllComments,
createComment, createComment,
getCaseSavedObjectsFromES,
} from '../../../../common/lib/api'; } from '../../../../common/lib/api';
import { import {
createAlertsIndex, createAlertsIndex,
@ -1537,6 +1539,32 @@ export default ({ getService }: FtrProviderContext): void => {
expectedHttpCode: 200, expectedHttpCode: 200,
}); });
}); });
it('should set the attachment stats correctly', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [
postCommentUserReq,
postCommentUserReq,
postCommentAlertReq,
// an attachment that is not a comment or an alert should not affect the stats
postCommentActionsReq,
],
expectedHttpCode: 200,
});
const res = await getCaseSavedObjectsFromES({ es });
expect(res.body.hits.hits.length).to.eql(1);
const theCase = res.body.hits.hits[0]._source?.cases!;
expect(theCase.total_alerts).to.eql(1);
expect(theCase.total_comments).to.eql(2);
});
}); });
describe('rbac', () => { describe('rbac', () => {