diff --git a/x-pack/plugins/cases/common/api/cases/comment/files.ts b/x-pack/plugins/cases/common/api/cases/comment/files.ts new file mode 100644 index 0000000000000..58fee11997c74 --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/comment/files.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const FileAttachmentMetadataRt = rt.type({ + files: rt.array( + rt.type({ + name: rt.string, + extension: rt.string, + mimeType: rt.string, + createdAt: rt.string, + }) + ), +}); + +export type FileAttachmentMetadata = rt.TypeOf; + +export const FILE_ATTACHMENT_TYPE = '.files'; diff --git a/x-pack/plugins/cases/common/api/cases/comment.ts b/x-pack/plugins/cases/common/api/cases/comment/index.ts similarity index 98% rename from x-pack/plugins/cases/common/api/cases/comment.ts rename to x-pack/plugins/cases/common/api/cases/comment/index.ts index 2788d1c4022d2..7729673410a71 100644 --- a/x-pack/plugins/cases/common/api/cases/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/comment/index.ts @@ -6,10 +6,12 @@ */ import * as rt from 'io-ts'; -import { jsonValueRt } from '../runtime_types'; -import { SavedObjectFindOptionsRt } from '../saved_object'; +import { jsonValueRt } from '../../runtime_types'; +import { SavedObjectFindOptionsRt } from '../../saved_object'; -import { UserRt } from '../user'; +import { UserRt } from '../../user'; + +export * from './files'; export const CommentAttributesBasicRt = rt.type({ created_at: rt.string, diff --git a/x-pack/plugins/cases/common/constants/files.ts b/x-pack/plugins/cases/common/constants/files.ts index 7cd7109137976..a2cdd00651614 100644 --- a/x-pack/plugins/cases/common/constants/files.ts +++ b/x-pack/plugins/cases/common/constants/files.ts @@ -7,6 +7,7 @@ import type { HttpApiTagOperation, Owner } from './types'; +export const MAX_FILES_PER_CASE = 100; export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB export const constructFilesHttpOperationTag = (owner: Owner, operation: HttpApiTagOperation) => { diff --git a/x-pack/plugins/cases/server/attachment_framework/types.ts b/x-pack/plugins/cases/server/attachment_framework/types.ts index 4b47332c124f5..ca3e14d6d69e7 100644 --- a/x-pack/plugins/cases/server/attachment_framework/types.ts +++ b/x-pack/plugins/cases/server/attachment_framework/types.ts @@ -25,6 +25,11 @@ export interface PersistableStateAttachmentTypeSetup export interface ExternalReferenceAttachmentType { id: string; + /** + * A function to validate data stored with the attachment type. This function should throw an error + * if the data is not in the form it expects. + */ + schemaValidator?: (data: unknown) => void; } export interface AttachmentFramework { diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index 102e82df5c4c7..03b228333fcfb 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -49,7 +49,7 @@ export const addComment = async ( externalReferenceAttachmentTypeRegistry, } = clientArgs; - decodeCommentRequest(comment); + decodeCommentRequest(comment, externalReferenceAttachmentTypeRegistry); try { const savedObjectID = SavedObjectsUtils.generateId(); diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_create.ts b/x-pack/plugins/cases/server/client/attachments/bulk_create.ts index 555a643a47e88..4087c2f071605 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_create.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_create.ts @@ -40,12 +40,12 @@ export const bulkCreate = async ( fold(throwErrors(Boom.badRequest), identity) ); + const { logger, authorization, externalReferenceAttachmentTypeRegistry } = clientArgs; + attachments.forEach((attachment) => { - decodeCommentRequest(attachment); + decodeCommentRequest(attachment, externalReferenceAttachmentTypeRegistry); }); - const { logger, authorization } = clientArgs; - try { const [attachmentsWithIds, entities]: [Array<{ id: string } & CommentRequest>, OwnerEntity[]] = attachments.reduce<[Array<{ id: string } & CommentRequest>, OwnerEntity[]]>( diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts index f2ed100243b08..692e2f6b15204 100644 --- a/x-pack/plugins/cases/server/client/attachments/update.ts +++ b/x-pack/plugins/cases/server/client/attachments/update.ts @@ -30,6 +30,7 @@ export async function update( services: { attachmentService }, logger, authorization, + externalReferenceAttachmentTypeRegistry, } = clientArgs; try { @@ -39,7 +40,7 @@ export async function update( ...queryRestAttributes } = queryParams; - decodeCommentRequest(queryRestAttributes); + decodeCommentRequest(queryRestAttributes, externalReferenceAttachmentTypeRegistry); const myComment = await attachmentService.getter.get({ attachmentId: queryCommentId, diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index 80924c0df89de..a954dae4bb509 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -55,8 +55,12 @@ import { isCommentRequestTypeActions, assertUnreachable, } from '../common/utils'; +import type { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; -export const decodeCommentRequest = (comment: CommentRequest) => { +export const decodeCommentRequest = ( + comment: CommentRequest, + externalRefRegistry: ExternalReferenceAttachmentTypeRegistry +) => { if (isCommentRequestTypeUser(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); } else if (isCommentRequestTypeActions(comment)) { @@ -106,7 +110,7 @@ export const decodeCommentRequest = (comment: CommentRequest) => { ); } } else if (isCommentRequestTypeExternalReference(comment)) { - decodeExternalReferenceAttachment(comment); + decodeExternalReferenceAttachment(comment, externalRefRegistry); } else if (isCommentRequestTypePersistableState(comment)) { pipe( excess(PersistableStateAttachmentRt).decode(comment), @@ -122,7 +126,10 @@ export const decodeCommentRequest = (comment: CommentRequest) => { } }; -const decodeExternalReferenceAttachment = (attachment: CommentRequestExternalReferenceType) => { +const decodeExternalReferenceAttachment = ( + attachment: CommentRequestExternalReferenceType, + externalRefRegistry: ExternalReferenceAttachmentTypeRegistry +) => { if (attachment.externalReferenceStorage.type === ExternalReferenceStorageType.savedObject) { pipe(excess(ExternalReferenceSORt).decode(attachment), fold(throwErrors(badRequest), identity)); } else { @@ -131,6 +138,13 @@ const decodeExternalReferenceAttachment = (attachment: CommentRequestExternalRef fold(throwErrors(badRequest), identity) ); } + + const metadata = attachment.externalReferenceMetadata; + if (externalRefRegistry.has(attachment.externalReferenceAttachmentTypeId)) { + const attachmentType = externalRefRegistry.get(attachment.externalReferenceAttachmentTypeId); + + attachmentType.schemaValidator?.(metadata); + } }; /** diff --git a/x-pack/plugins/cases/server/common/limiter_checker/base_limiter.ts b/x-pack/plugins/cases/server/common/limiter_checker/base_limiter.ts new file mode 100644 index 0000000000000..e71970d2d0c1f --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/base_limiter.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { KueryNode } from '@kbn/es-query'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants'; +import type { CommentRequest, CommentType } from '../../../common/api'; +import type { AttachmentService } from '../../services'; +import type { Limiter } from './types'; + +interface LimiterParams { + limit: number; + attachmentType: CommentType; + field: string; + attachmentNoun: string; + filter?: KueryNode; +} + +export abstract class BaseLimiter implements Limiter { + public readonly limit: number; + public readonly errorMessage: string; + + private readonly limitAggregation: Record; + private readonly params: LimiterParams; + + constructor(params: LimiterParams) { + this.params = params; + this.limit = params.limit; + this.errorMessage = makeErrorMessage(this.limit, params.attachmentNoun); + + this.limitAggregation = { + limiter: { + value_count: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.${params.field}`, + }, + }, + }; + } + + public async countOfItemsWithinCase( + attachmentService: AttachmentService, + caseId: string + ): Promise { + const itemsAttachedToCase = await attachmentService.executeCaseAggregations<{ + limiter: { value: number }; + }>({ + caseId, + aggregations: this.limitAggregation, + attachmentType: this.params.attachmentType, + filter: this.params.filter, + }); + + return itemsAttachedToCase?.limiter?.value ?? 0; + } + + public abstract countOfItemsInRequest(requests: CommentRequest[]): number; +} + +const makeErrorMessage = (limit: number, noun: string) => { + return `Case has reached the maximum allowed number (${limit}) of attached ${noun}.`; +}; diff --git a/x-pack/plugins/cases/server/common/limiter_checker/index.test.ts b/x-pack/plugins/cases/server/common/limiter_checker/index.test.ts new file mode 100644 index 0000000000000..fce1cfe3ee781 --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/index.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttachmentLimitChecker } from '.'; +import { createAttachmentServiceMock } from '../../services/mocks'; +import { createAlertRequests, createFileRequests, createUserRequests } from './test_utils'; + +describe('AttachmentLimitChecker', () => { + const mockAttachmentService = createAttachmentServiceMock(); + const checker = new AttachmentLimitChecker(mockAttachmentService, 'id'); + + beforeEach(() => { + jest.clearAllMocks(); + + mockAttachmentService.executeCaseAggregations.mockImplementation(async () => { + return { + limiter: { + value: 5, + }, + }; + }); + }); + + describe('validate', () => { + it('does not throw when called with an empty array', async () => { + expect.assertions(1); + + await expect(checker.validate([])).resolves.not.toThrow(); + }); + + it('does not throw when none of the requests are alerts or files', async () => { + expect.assertions(1); + + await expect(checker.validate(createUserRequests(2))).resolves.not.toThrow(); + }); + + describe('files', () => { + it('throws when the files in a single request are greater than 100', async () => { + expect.assertions(1); + + await expect( + checker.validate(createFileRequests({ numRequests: 1, numFiles: 101 })) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (100) of attached files.]` + ); + }); + + it('throws when there are 101 requests with a single file', async () => { + expect.assertions(1); + + await expect( + checker.validate(createFileRequests({ numRequests: 101, numFiles: 1 })) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (100) of attached files.]` + ); + }); + + it('does not throw when the sum of the file requests and files within the case are only 100', async () => { + expect.assertions(1); + + await expect( + checker.validate(createFileRequests({ numFiles: 1, numRequests: 95 })) + ).resolves.not.toThrow(); + }); + + it('throws when the sum of the file requests and files within the case are 101', async () => { + expect.assertions(1); + + await expect( + checker.validate(createFileRequests({ numFiles: 1, numRequests: 96 })) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (100) of attached files.]` + ); + }); + + it('throws when the sum of the file requests and files within the case are 101 with a single file request', async () => { + expect.assertions(1); + + await expect( + checker.validate(createFileRequests({ numFiles: 96, numRequests: 1 })) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (100) of attached files.]` + ); + }); + }); + + describe('alerts', () => { + it('throws when the alerts in a single request are greater than 1000', async () => { + expect.assertions(1); + + const alertIds = [...Array(1001).keys()].map((key) => `${key}`); + await expect( + checker.validate(createAlertRequests(1, alertIds)) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (1000) of attached alerts.]` + ); + }); + + it('throws when there are 1001 requests with a single alert id', async () => { + expect.assertions(1); + + await expect( + checker.validate(createAlertRequests(1001, 'alertId')) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (1000) of attached alerts.]` + ); + }); + + it('does not throw when the sum of the alert requests and alerts within the case are only 1000', async () => { + expect.assertions(1); + + await expect(checker.validate(createAlertRequests(995, 'alertId'))).resolves.not.toThrow(); + }); + + it('throws when the sum of the alert requests and alerts within the case are 1001', async () => { + expect.assertions(1); + + await expect( + checker.validate(createAlertRequests(996, 'alertId')) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (1000) of attached alerts.]` + ); + }); + + it('throws when the sum of the alert requests and alerts within the case are 1001 with a single request', async () => { + expect.assertions(1); + + const alertIds = [...Array(996).keys()].map((key) => `${key}`); + + await expect( + checker.validate(createAlertRequests(1, alertIds)) + ).rejects.toMatchInlineSnapshot( + `[Error: Case has reached the maximum allowed number (1000) of attached alerts.]` + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/common/limiter_checker/index.ts b/x-pack/plugins/cases/server/common/limiter_checker/index.ts new file mode 100644 index 0000000000000..b65a9da69d678 --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/index.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; + +import type { CommentRequest } from '../../../common/api'; +import type { AttachmentService } from '../../services'; +import type { Limiter } from './types'; +import { AlertLimiter } from './limiters/alerts'; +import { FileLimiter } from './limiters/files'; + +export class AttachmentLimitChecker { + private readonly limiters: Limiter[] = [new AlertLimiter(), new FileLimiter()]; + + constructor( + private readonly attachmentService: AttachmentService, + private readonly caseId: string + ) {} + + public async validate(requests: CommentRequest[]) { + for (const limiter of this.limiters) { + const itemsWithinRequests = limiter.countOfItemsInRequest(requests); + const hasItemsInRequests = itemsWithinRequests > 0; + + const totalAfterRequests = async () => { + const itemsWithinCase = await limiter.countOfItemsWithinCase( + this.attachmentService, + this.caseId + ); + + return itemsWithinRequests + itemsWithinCase; + }; + + /** + * The call to totalAfterRequests is intentionally performed after checking the limit. If the number in the + * requests is greater than the max then we can skip checking how many items exist within the case because it is + * guaranteed to exceed. + */ + if ( + hasItemsInRequests && + (itemsWithinRequests > limiter.limit || (await totalAfterRequests()) > limiter.limit) + ) { + throw Boom.badRequest(limiter.errorMessage); + } + } + } +} diff --git a/x-pack/plugins/cases/server/common/limiter_checker/limiters/alerts.test.ts b/x-pack/plugins/cases/server/common/limiter_checker/limiters/alerts.test.ts new file mode 100644 index 0000000000000..1cef61e02197b --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/limiters/alerts.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createAttachmentServiceMock } from '../../../services/mocks'; +import { AlertLimiter } from './alerts'; +import { createAlertRequests, createUserRequests } from '../test_utils'; + +describe('AlertLimiter', () => { + const alert = new AlertLimiter(); + + describe('public fields', () => { + it('sets the errorMessage to the 1k limit', () => { + expect(alert.errorMessage).toMatchInlineSnapshot( + `"Case has reached the maximum allowed number (1000) of attached alerts."` + ); + }); + + it('sets the limit to 1k', () => { + expect(alert.limit).toBe(1000); + }); + }); + + describe('countOfItemsInRequest', () => { + it('returns 0 when passed an empty array', () => { + expect(alert.countOfItemsInRequest([])).toBe(0); + }); + + it('returns 0 when the requests are not for alerts', () => { + expect(alert.countOfItemsInRequest(createUserRequests(2))).toBe(0); + }); + + it('returns 2 when there are 2 alert requests', () => { + expect(alert.countOfItemsInRequest(createAlertRequests(2, 'alert-id'))).toBe(2); + }); + + it('returns 2 when there is 1 request with 2 alert ids', () => { + expect(alert.countOfItemsInRequest(createAlertRequests(1, ['alert-id', 'alert-id2']))).toBe( + 2 + ); + }); + + it('returns 3 when there is 1 request with 2 alert ids and 1 request with 1 alert id', () => { + const requestWith2AlertIds = createAlertRequests(1, ['alert-id', 'alert-id2']); + const requestWith1AlertId = createAlertRequests(1, 'alert-id'); + expect(alert.countOfItemsInRequest([...requestWith2AlertIds, ...requestWith1AlertId])).toBe( + 3 + ); + }); + + it('returns 2 when there are 2 requests with an alert id and 1 user comment request', () => { + expect( + alert.countOfItemsInRequest([ + ...createUserRequests(1), + ...createAlertRequests(2, 'alert-id'), + ]) + ).toBe(2); + }); + }); + + describe('countOfItemsWithinCase', () => { + const attachmentService = createAttachmentServiceMock(); + attachmentService.executeCaseAggregations.mockImplementation(async () => { + return { + limiter: { + value: 5, + }, + }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the aggregation function with the correct arguments', async () => { + await alert.countOfItemsWithinCase(attachmentService, 'id'); + + expect(attachmentService.executeCaseAggregations.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "aggregations": Object { + "limiter": Object { + "value_count": Object { + "field": "cases-comments.attributes.alertId", + }, + }, + }, + "attachmentType": "alert", + "caseId": "id", + "filter": undefined, + }, + ] + `); + }); + + it('returns 5', async () => { + expect(await alert.countOfItemsWithinCase(attachmentService, 'id')).toBe(5); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/common/limiter_checker/limiters/alerts.ts b/x-pack/plugins/cases/server/common/limiter_checker/limiters/alerts.ts new file mode 100644 index 0000000000000..d7d90eb911011 --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/limiters/alerts.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CommentType } from '../../../../common/api'; +import type { CommentRequest, CommentRequestAlertType } from '../../../../common/api'; +import { MAX_ALERTS_PER_CASE } from '../../../../common/constants'; +import { isCommentRequestTypeAlert } from '../../utils'; +import { BaseLimiter } from '../base_limiter'; + +export class AlertLimiter extends BaseLimiter { + constructor() { + super({ + limit: MAX_ALERTS_PER_CASE, + attachmentType: CommentType.alert, + attachmentNoun: 'alerts', + field: 'alertId', + }); + } + + public countOfItemsInRequest(requests: CommentRequest[]): number { + const totalAlertsInReq = requests + .filter(isCommentRequestTypeAlert) + .reduce((count, attachment) => { + const ids = Array.isArray(attachment.alertId) ? attachment.alertId : [attachment.alertId]; + return count + ids.length; + }, 0); + + return totalAlertsInReq; + } +} diff --git a/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.test.ts b/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.test.ts new file mode 100644 index 0000000000000..e081369fafe17 --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createAttachmentServiceMock } from '../../../services/mocks'; +import { FileLimiter } from './files'; +import { createFileRequests, createUserRequests } from '../test_utils'; + +describe('FileLimiter', () => { + const file = new FileLimiter(); + + describe('public fields', () => { + it('sets the errorMessage to the 100 limit', () => { + expect(file.errorMessage).toMatchInlineSnapshot( + `"Case has reached the maximum allowed number (100) of attached files."` + ); + }); + + it('sets the limit to 100', () => { + expect(file.limit).toBe(100); + }); + }); + + describe('countOfItemsInRequest', () => { + it('returns 0 when passed an empty array', () => { + expect(file.countOfItemsInRequest([])).toBe(0); + }); + + it('returns 0 when the requests are not for files', () => { + expect(file.countOfItemsInRequest(createUserRequests(2))).toBe(0); + }); + + it('returns 2 when there are 2 file requests', () => { + expect(file.countOfItemsInRequest(createFileRequests({ numRequests: 2, numFiles: 1 }))).toBe( + 2 + ); + }); + + it('returns 2 when there is 1 request with 2 files', () => { + expect(file.countOfItemsInRequest(createFileRequests({ numRequests: 1, numFiles: 2 }))).toBe( + 2 + ); + }); + + it('returns 3 when there is 1 request with 2 files and 1 request with 1 file', () => { + const requestWith2Files = createFileRequests({ numRequests: 1, numFiles: 2 }); + const requestWith1File = createFileRequests({ numRequests: 1, numFiles: 1 }); + expect(file.countOfItemsInRequest([...requestWith2Files, ...requestWith1File])).toBe(3); + }); + + it('returns 2 when there are 2 requests with a file and 1 user comment request', () => { + expect( + file.countOfItemsInRequest([ + ...createUserRequests(1), + ...createFileRequests({ numRequests: 2, numFiles: 1 }), + ]) + ).toBe(2); + }); + }); + + describe('countOfItemsWithinCase', () => { + const attachmentService = createAttachmentServiceMock(); + attachmentService.executeCaseAggregations.mockImplementation(async () => { + return { + limiter: { + value: 5, + }, + }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the aggregation function with the correct arguments', async () => { + await file.countOfItemsWithinCase(attachmentService, 'id'); + + expect(attachmentService.executeCaseAggregations.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "aggregations": Object { + "limiter": Object { + "value_count": Object { + "field": "cases-comments.attributes.externalReferenceAttachmentTypeId", + }, + }, + }, + "attachmentType": "externalReference", + "caseId": "id", + "filter": Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-comments.attributes.externalReferenceAttachmentTypeId", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": ".files", + }, + ], + "function": "is", + "type": "function", + }, + }, + ] + `); + }); + + it('returns 5', async () => { + expect(await file.countOfItemsWithinCase(attachmentService, 'id')).toBe(5); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.ts b/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.ts new file mode 100644 index 0000000000000..dac2e124b669a --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildFilter } from '../../../client/utils'; +import { CommentType, FILE_ATTACHMENT_TYPE } from '../../../../common/api'; +import type { CommentRequest } from '../../../../common/api'; +import { CASE_COMMENT_SAVED_OBJECT, MAX_FILES_PER_CASE } from '../../../../common/constants'; +import { isFileAttachmentRequest } from '../../utils'; +import { BaseLimiter } from '../base_limiter'; + +export class FileLimiter extends BaseLimiter { + constructor() { + super({ + limit: MAX_FILES_PER_CASE, + attachmentType: CommentType.externalReference, + field: 'externalReferenceAttachmentTypeId', + filter: createFileFilter(), + attachmentNoun: 'files', + }); + } + + public countOfItemsInRequest(requests: CommentRequest[]): number { + let fileRequests = 0; + + for (const request of requests) { + if (isFileAttachmentRequest(request)) { + fileRequests += request.externalReferenceMetadata.files.length; + } + } + + return fileRequests; + } +} + +const createFileFilter = () => + buildFilter({ + filters: FILE_ATTACHMENT_TYPE, + field: 'externalReferenceAttachmentTypeId', + operator: 'or', + type: CASE_COMMENT_SAVED_OBJECT, + }); diff --git a/x-pack/plugins/cases/server/common/limiter_checker/test_utils.ts b/x-pack/plugins/cases/server/common/limiter_checker/test_utils.ts new file mode 100644 index 0000000000000..6fa5e0cbbe561 --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/test_utils.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CommentType, + ExternalReferenceStorageType, + FILE_ATTACHMENT_TYPE, +} from '../../../common/api'; +import type { + CommentRequestUserType, + CommentRequestAlertType, + FileAttachmentMetadata, +} from '../../../common/api'; +import type { FileAttachmentRequest } from '../types'; + +export const createUserRequests = (num: number): CommentRequestUserType[] => { + const requests = [...Array(num).keys()].map((value) => { + return { + comment: `${value}`, + type: CommentType.user as const, + owner: 'test', + }; + }); + + return requests; +}; + +export const createFileRequests = ({ + numRequests, + numFiles, +}: { + numRequests: number; + numFiles: number; +}): FileAttachmentRequest[] => { + const files: FileAttachmentMetadata['files'] = [...Array(numFiles).keys()].map((value) => { + return { + name: `${value}`, + createdAt: '2023-02-27T20:26:54.345Z', + extension: 'png', + mimeType: 'image/png', + }; + }); + + const requests: FileAttachmentRequest[] = [...Array(numRequests).keys()].map((value) => { + return { + type: CommentType.externalReference as const, + externalReferenceAttachmentTypeId: FILE_ATTACHMENT_TYPE, + externalReferenceId: 'so-id', + externalReferenceMetadata: { files }, + externalReferenceStorage: { + soType: `${value}`, + type: ExternalReferenceStorageType.savedObject, + }, + owner: 'test', + }; + }); + + return requests; +}; + +export const createAlertRequests = ( + numberOfRequests: number, + alertIds: string | string[] +): CommentRequestAlertType[] => { + const requests = [...Array(numberOfRequests).keys()].map((value) => { + return { + type: CommentType.alert as const, + alertId: alertIds, + index: alertIds, + rule: { + id: null, + name: null, + }, + owner: `${value}`, + }; + }); + + return requests; +}; diff --git a/x-pack/plugins/cases/server/common/limiter_checker/types.ts b/x-pack/plugins/cases/server/common/limiter_checker/types.ts new file mode 100644 index 0000000000000..7b7be0c12a5a5 --- /dev/null +++ b/x-pack/plugins/cases/server/common/limiter_checker/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CommentRequest } from '../../../common/api'; +import type { AttachmentService } from '../../services'; + +export interface Limiter { + readonly limit: number; + readonly errorMessage: string; + countOfItemsWithinCase(attachmentService: AttachmentService, caseId: string): Promise; + countOfItemsInRequest: (requests: CommentRequest[]) => number; +} diff --git a/x-pack/plugins/cases/server/common/models/case_with_comments.ts b/x-pack/plugins/cases/server/common/models/case_with_comments.ts index cba3d461af45d..7ffe35997ee67 100644 --- a/x-pack/plugins/cases/server/common/models/case_with_comments.ts +++ b/x-pack/plugins/cases/server/common/models/case_with_comments.ts @@ -27,14 +27,11 @@ import { ActionTypes, Actions, } from '../../../common/api'; -import { - CASE_SAVED_OBJECT, - MAX_ALERTS_PER_CASE, - MAX_DOCS_PER_PAGE, -} from '../../../common/constants'; +import { CASE_SAVED_OBJECT, MAX_DOCS_PER_PAGE } from '../../../common/constants'; import type { CasesClientArgs } from '../../client'; import type { RefreshSetting } from '../../services/types'; import { createCaseError } from '../error'; +import { AttachmentLimitChecker } from '../limiter_checker'; import type { AlertInfo, CaseSavedObject } from '../types'; import { countAlertsForID, @@ -47,8 +44,6 @@ import { type CaseCommentModelParams = Omit; -const ALERT_LIMIT_MSG = `Case has reached the maximum allowed number (${MAX_ALERTS_PER_CASE}) of attached alerts.`; - /** * This class represents a case that can have a comment attached to it. */ @@ -92,6 +87,13 @@ export class CaseCommentModel { const { id, version, ...queryRestAttributes } = updateRequest; const options: SavedObjectsUpdateOptions = { version, + /** + * This is to handle a scenario where an update occurs for an attachment framework style comment. + * The code that extracts the reference information from the attributes doesn't know about the reference to the case + * and therefore will accidentally remove that reference and we'll lose the connection between the comment and the + * case. + */ + references: [...this.buildRefsToCase()], refresh: false, }; @@ -105,6 +107,11 @@ export class CaseCommentModel { queryRestAttributes.comment, currentComment ); + + /** + * The call to getOrUpdateLensReferences already handles retrieving the reference to the case and ensuring that is + * also included here so it's ok to overwrite what was set before. + */ options.references = updatedReferences; } @@ -239,16 +246,9 @@ export class CaseCommentModel { } private async validateCreateCommentRequest(req: CommentRequest[]) { - const totalAlertsInReq = req - .filter(isCommentRequestTypeAlert) - .reduce((count, attachment) => { - const ids = Array.isArray(attachment.alertId) ? attachment.alertId : [attachment.alertId]; - return count + ids.length; - }, 0); - - const reqHasAlerts = totalAlertsInReq > 0; + const hasAlertsInRequest = req.some((request) => isCommentRequestTypeAlert(request)); - if (reqHasAlerts && this.caseInfo.attributes.status === CaseStatuses.closed) { + if (hasAlertsInRequest && this.caseInfo.attributes.status === CaseStatuses.closed) { throw Boom.badRequest('Alert cannot be attached to a closed case'); } @@ -256,30 +256,12 @@ export class CaseCommentModel { throw Boom.badRequest('The owner field of the comment must match the case'); } - if (reqHasAlerts) { - /** - * This check is for optimization reasons. - * It saves one aggregation if the total number - * of alerts of the request is already greater than - * MAX_ALERTS_PER_CASE - */ - if (totalAlertsInReq > MAX_ALERTS_PER_CASE) { - throw Boom.badRequest(ALERT_LIMIT_MSG); - } - - await this.validateAlertsLimitOnCase(totalAlertsInReq); - } - } - - private async validateAlertsLimitOnCase(totalAlertsInReq: number) { - const alertsValueCount = - await this.params.services.attachmentService.valueCountAlertsAttachedToCase({ - caseId: this.caseInfo.id, - }); + const limitChecker = new AttachmentLimitChecker( + this.params.services.attachmentService, + this.caseInfo.id + ); - if (alertsValueCount + totalAlertsInReq > MAX_ALERTS_PER_CASE) { - throw Boom.badRequest(ALERT_LIMIT_MSG); - } + await limitChecker.validate(req); } private buildRefsToCase(): SavedObjectReference[] { diff --git a/x-pack/plugins/cases/server/common/types.ts b/x-pack/plugins/cases/server/common/types.ts index 6a9eaed578a8f..8b43cc77480c5 100644 --- a/x-pack/plugins/cases/server/common/types.ts +++ b/x-pack/plugins/cases/server/common/types.ts @@ -7,7 +7,12 @@ import type { SavedObject } from '@kbn/core-saved-objects-server'; import type { KueryNode } from '@kbn/es-query'; -import type { CaseAttributes, SavedObjectFindOptions } from '../../common/api'; +import type { + CaseAttributes, + CommentRequestExternalReferenceSOType, + FileAttachmentMetadata, + SavedObjectFindOptions, +} from '../../common/api'; /** * This structure holds the alert ID and index from an alert comment @@ -22,3 +27,10 @@ export type SavedObjectFindOptionsKueryNode = Omit; + +export type FileAttachmentRequest = Omit< + CommentRequestExternalReferenceSOType, + 'externalReferenceMetadata' +> & { + externalReferenceMetadata: FileAttachmentMetadata; +}; diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index 2e8b0299ba5c5..8949e01aceca7 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -24,7 +24,7 @@ import { OWNER_INFO, } from '../../common/constants'; import type { CASE_VIEW_PAGE_TABS } from '../../common/types'; -import type { AlertInfo, CaseSavedObject } from './types'; +import type { AlertInfo, CaseSavedObject, FileAttachmentRequest } from './types'; import type { CaseAttributes, @@ -47,6 +47,8 @@ import { CommentType, ConnectorTypes, ExternalReferenceStorageType, + ExternalReferenceSORt, + FileAttachmentMetadataRt, } from '../../common/api'; import type { UpdateAlertStatusRequest } from '../client/alerts/types'; import { @@ -264,6 +266,18 @@ export const isCommentRequestTypeExternalReferenceSO = ( ); }; +/** + * A type narrowing function for file attachments. + */ +export const isFileAttachmentRequest = ( + context: Partial +): context is FileAttachmentRequest => { + return ( + ExternalReferenceSORt.is(context) && + FileAttachmentMetadataRt.is(context.externalReferenceMetadata) + ); +}; + /** * Adds the ids and indices to a map of statuses */ diff --git a/x-pack/plugins/cases/server/internal_attachments/index.ts b/x-pack/plugins/cases/server/internal_attachments/index.ts new file mode 100644 index 0000000000000..975e8fbad99f9 --- /dev/null +++ b/x-pack/plugins/cases/server/internal_attachments/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { badRequest } from '@hapi/boom'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { + excess, + FileAttachmentMetadataRt, + FILE_ATTACHMENT_TYPE, + throwErrors, +} from '../../common/api'; +import type { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; + +export const registerInternalAttachments = ( + externalRefRegistry: ExternalReferenceAttachmentTypeRegistry +) => { + externalRefRegistry.register({ id: FILE_ATTACHMENT_TYPE, schemaValidator }); +}; + +const schemaValidator = (data: unknown): void => { + pipe(excess(FileAttachmentMetadataRt).decode(data), fold(throwErrors(badRequest), identity)); +}; diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 8d2036921d84b..4f98d8604cefc 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -57,6 +57,7 @@ import { PersistableStateAttachmentTypeRegistry } from './attachment_framework/p import { ExternalReferenceAttachmentTypeRegistry } from './attachment_framework/external_reference_registry'; import { UserProfileService } from './services'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from './common/constants'; +import { registerInternalAttachments } from './internal_attachments'; import { registerCaseFileKinds } from './files'; export interface PluginsSetup { @@ -107,6 +108,7 @@ export class CasePlugin { )}] and plugins [${Object.keys(plugins)}]` ); + registerInternalAttachments(this.externalReferenceAttachmentTypeRegistry); registerCaseFileKinds(plugins.files); this.securityPluginSetup = plugins.security; diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts index b0192e8b1b8ee..865a28d763e6c 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -94,7 +94,7 @@ export class AttachmentService { const res = await this.executeCaseAggregations<{ alerts: { value: number } }>({ ...params, attachmentType: CommentType.alert, - aggregations: this.buildAlertsAggs('cardinality'), + aggregations: this.buildAlertsAggs(), }); return res?.alerts?.value; @@ -104,34 +104,16 @@ export class AttachmentService { } } - private buildAlertsAggs(agg: string): Record { + private buildAlertsAggs(): Record { return { alerts: { - [agg]: { + cardinality: { field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, }, }, }; } - public async valueCountAlertsAttachedToCase(params: AlertsAttachedToCaseArgs): Promise { - try { - this.context.log.debug(`Attempting to value count alerts for case id ${params.caseId}`); - const res = await this.executeCaseAggregations<{ alerts: { value: number } }>({ - ...params, - attachmentType: CommentType.alert, - aggregations: this.buildAlertsAggs('value_count'), - }); - - return res?.alerts?.value ?? 0; - } catch (error) { - this.context.log.error( - `Error while value counting alerts for case id ${params.caseId}: ${error}` - ); - throw error; - } - } - /** * Executes the aggregations against a type of attachment attached to a case. */ diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index 59fdc9565fdd6..380e10745b903 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -170,7 +170,6 @@ export const createAttachmentServiceMock = (): AttachmentServiceMock => { find: jest.fn(), countAlertsAttachedToCase: jest.fn(), executeCaseActionsAggregations: jest.fn(), - valueCountAlertsAttachedToCase: jest.fn(), executeCaseAggregations: jest.fn(), }; diff --git a/x-pack/test/cases_api_integration/common/lib/mock.ts b/x-pack/test/cases_api_integration/common/lib/mock.ts index f81d7e45cce9e..149db8b0e316a 100644 --- a/x-pack/test/cases_api_integration/common/lib/mock.ts +++ b/x-pack/test/cases_api_integration/common/lib/mock.ts @@ -22,6 +22,8 @@ import { CommentRequestExternalReferenceSOType, CommentRequestExternalReferenceNoSOType, CommentRequestPersistableStateType, + FILE_ATTACHMENT_TYPE, + FileAttachmentMetadata, } from '@kbn/cases-plugin/common/api'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; @@ -122,6 +124,28 @@ export const postExternalReferenceSOReq: CommentRequestExternalReferenceSOType = externalReferenceStorage: { type: ExternalReferenceStorageType.savedObject, soType: 'test-type' }, }; +export const fileMetadata = () => ({ + name: 'test_file', + extension: 'png', + mimeType: 'image/png', + createdAt: '2023-02-27T20:26:54.345Z', +}); + +export const fileAttachmentMetadata: FileAttachmentMetadata = { + files: [fileMetadata()], +}; + +export const getFilesAttachmentReq = ( + req?: Partial +): CommentRequestExternalReferenceSOType => { + return { + ...postExternalReferenceSOReq, + externalReferenceAttachmentTypeId: FILE_ATTACHMENT_TYPE, + externalReferenceMetadata: { ...fileAttachmentMetadata }, + ...req, + }; +}; + export const persistableStateAttachment: CommentRequestPersistableStateType = { type: CommentType.persistableState, owner: 'securitySolutionFixture', diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts index c682896af4442..fc7d86306fc41 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { set } from '@kbn/safer-lodash-set'; import { omit } from 'lodash/fp'; import expect from '@kbn/expect'; import { @@ -20,6 +21,8 @@ import { postCommentUserReq, postCommentAlertReq, getPostCaseRequest, + getFilesAttachmentReq, + fileAttachmentMetadata, } from '../../../../common/lib/mock'; import { deleteAllCaseItems, @@ -30,6 +33,7 @@ import { createComment, updateComment, superUserSpace1Auth, + removeServerGeneratedPropertiesFromSavedObject, } from '../../../../common/lib/api'; import { globalRead, @@ -328,6 +332,89 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + describe('files', () => { + it('should update the file attachment name to superfile', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq(), + }); + + const attachmentWithUpdatedFileName = set( + getFilesAttachmentReq(), + 'externalReferenceMetadata.files[0].name', + 'superfile' + ); + + const updatedCase = await updateComment({ + supertest, + caseId: postedCase.id, + req: { + ...attachmentWithUpdatedFileName, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + }, + }); + + const comparableAttachmentFields = removeServerGeneratedPropertiesFromSavedObject( + updatedCase.comments![0] + ); + + expect(comparableAttachmentFields).to.eql({ + ...attachmentWithUpdatedFileName, + pushed_at: null, + pushed_by: null, + updated_by: defaultUser, + created_by: defaultUser, + owner: 'securitySolutionFixture', + }); + }); + + it('should return a 400 when attempting to update a file attachment with empty metadata', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq(), + }); + + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + ...getFilesAttachmentReq({ externalReferenceMetadata: {} }), + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + }, + expectedHttpCode: 400, + }); + }); + + it('should return a 400 when attempting to update a file attachment with invalid metadata', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq(), + }); + + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + ...getFilesAttachmentReq({ + // intentionally creating an invalid format here by using foo instead of files + externalReferenceMetadata: { foo: fileAttachmentMetadata.files }, + }), + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + }, + expectedHttpCode: 400, + }); + }); + }); + describe('alert format', () => { type AlertComment = CommentType.alert; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts index b5a0ceb223375..d3e864374d31e 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts @@ -14,6 +14,7 @@ import { AttributesTypeUser, AttributesTypeAlerts, CaseStatuses, + CommentRequestExternalReferenceSOType, } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { @@ -22,6 +23,9 @@ import { postCommentUserReq, postCommentAlertReq, getPostCaseRequest, + getFilesAttachmentReq, + fileAttachmentMetadata, + fileMetadata, } from '../../../../common/lib/mock'; import { deleteAllCaseItems, @@ -35,6 +39,7 @@ import { updateCase, getCaseUserActions, removeServerGeneratedPropertiesFromUserAction, + bulkCreateAttachments, } from '../../../../common/lib/api'; import { createSignalsIndex, @@ -158,9 +163,117 @@ export default ({ getService }: FtrProviderContext): void => { owner: 'securitySolutionFixture', }); }); + + describe('files', () => { + it('should create a file attachment', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const caseWithAttachments = await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq(), + }); + + const fileAttachment = + caseWithAttachments.comments![0] as CommentRequestExternalReferenceSOType; + + expect(caseWithAttachments.totalComment).to.be(1); + expect(fileAttachment.externalReferenceMetadata).to.eql(fileAttachmentMetadata); + }); + + it('should create a single file attachment with multiple file objects within it', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const files = [fileMetadata(), fileMetadata()]; + + const caseWithAttachments = await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq({ + externalReferenceMetadata: { + files, + }, + }), + }); + + const firstFileAttachment = + caseWithAttachments.comments![0] as CommentRequestExternalReferenceSOType; + + expect(caseWithAttachments.totalComment).to.be(1); + expect(firstFileAttachment.externalReferenceMetadata).to.eql({ files }); + }); + + it('should create a file attachments when there are 99 attachments already within the case', async () => { + const fileRequests = [...Array(99).keys()].map(() => getFilesAttachmentReq()); + + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + }); + + const caseWith100Files = await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq(), + }); + + expect(caseWith100Files.comments?.length).to.be(100); + }); + }); }); describe('unhappy path', () => { + describe('files', () => { + it('should return a 400 when attaching a file with metadata that is missing the file field', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq({ + externalReferenceMetadata: { + // intentionally structuring the data in a way that is invalid (using foo instead of files) + foo: fileAttachmentMetadata.files, + }, + }), + expectedHttpCode: 400, + }); + }); + + it('should return a 400 when attaching a file with an empty metadata', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq({ + externalReferenceMetadata: {}, + }), + expectedHttpCode: 400, + }); + }); + + it('should return a 400 when attempting to create a file attachment when the case already has 100 files attached', async () => { + const fileRequests = [...Array(100).keys()].map(() => getFilesAttachmentReq()); + + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + }); + + await createComment({ + supertest, + caseId: postedCase.id, + params: getFilesAttachmentReq(), + expectedHttpCode: 400, + }); + }); + }); + it('400s when attempting to create a comment with a different owner than the case', async () => { const postedCase = await createCase( supertest, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts index de39145878b6b..859f1922e375f 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts @@ -13,6 +13,7 @@ import { BulkCreateCommentRequest, CaseResponse, CaseStatuses, + CommentRequestExternalReferenceSOType, CommentType, } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -22,6 +23,10 @@ import { postCommentUserReq, postCommentAlertReq, getPostCaseRequest, + getFilesAttachmentReq, + fileAttachmentMetadata, + postExternalReferenceSOReq, + fileMetadata, } from '../../../../common/lib/mock'; import { deleteAllCaseItems, @@ -147,9 +152,175 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }); + + describe('files', () => { + it('should bulk create multiple file attachments', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const caseWithAttachments = await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [getFilesAttachmentReq(), getFilesAttachmentReq()], + }); + + const firstFileAttachment = + caseWithAttachments.comments![0] as CommentRequestExternalReferenceSOType; + const secondFileAttachment = + caseWithAttachments.comments![1] as CommentRequestExternalReferenceSOType; + + expect(caseWithAttachments.totalComment).to.be(2); + expect(firstFileAttachment.externalReferenceMetadata).to.eql(fileAttachmentMetadata); + expect(secondFileAttachment.externalReferenceMetadata).to.eql(fileAttachmentMetadata); + }); + + it('should create a single file attachment with multiple file objects within it', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const files = [fileMetadata(), fileMetadata()]; + + const caseWithAttachments = await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + getFilesAttachmentReq({ + externalReferenceMetadata: { + files, + }, + }), + ], + }); + + const firstFileAttachment = + caseWithAttachments.comments![0] as CommentRequestExternalReferenceSOType; + + expect(caseWithAttachments.totalComment).to.be(1); + expect(firstFileAttachment.externalReferenceMetadata).to.eql({ files }); + }); + + it('should bulk create 100 file attachments', async () => { + const fileRequests = [...Array(100).keys()].map(() => getFilesAttachmentReq()); + + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + }); + }); + + it('should bulk create 100 file attachments when there is another attachment type already associated with the case', async () => { + const fileRequests = [...Array(100).keys()].map(() => getFilesAttachmentReq()); + + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postExternalReferenceSOReq], + }); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + }); + }); + + it('should bulk create 100 file attachments when there is another attachment type in the request', async () => { + const fileRequests = [...Array(100).keys()].map(() => getFilesAttachmentReq()); + + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postExternalReferenceSOReq, ...fileRequests], + }); + }); + }); }); describe('errors', () => { + describe('files', () => { + it('400s when attaching a file with metadata that is missing the file field', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + postCommentUserReq, + getFilesAttachmentReq({ + externalReferenceMetadata: { + // intentionally structure the data in a way that is invalid + food: fileAttachmentMetadata.files, + }, + }), + ], + expectedHttpCode: 400, + }); + }); + + it('should return a 400 when attaching a file with an empty metadata', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + getFilesAttachmentReq({ + externalReferenceMetadata: {}, + }), + ], + expectedHttpCode: 400, + }); + }); + + it('400s when attempting to add more than 100 files to a case', async () => { + const fileRequests = [...Array(101).keys()].map(() => getFilesAttachmentReq()); + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + expectedHttpCode: 400, + }); + }); + + it('400s when attempting to add a file to a case that already has 100 files', async () => { + const fileRequests = [...Array(100).keys()].map(() => getFilesAttachmentReq()); + + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + }); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [getFilesAttachmentReq()], + expectedHttpCode: 400, + }); + }); + + it('400s when the case already has files and the sum of existing and new files exceed 100', async () => { + const fileRequests = [...Array(51).keys()].map(() => getFilesAttachmentReq()); + const postedCase = await createCase(supertest, postCaseReq); + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + }); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: fileRequests, + expectedHttpCode: 400, + }); + }); + }); + it('400s when attempting to create a comment with a different owner than the case', async () => { const postedCase = await createCase( supertest, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/get_connectors.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/get_connectors.ts index 4987ab5b307ec..fb48391547b99 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/get_connectors.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/get_connectors.ts @@ -787,7 +787,7 @@ export default ({ getService }: FtrProviderContext): void => { } }); - it('should not get a case in a space the user does not have permissions to', async () => { + it('should not get connectors in a space the user does not have permissions to', async () => { const { postedCase } = await createCaseWithConnector({ supertest: supertestWithoutAuth, serviceNowSimulatorURL,