diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9db5f86bcf775..f949523225e57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24624,6 +24624,9 @@ packages: third-party-web@0.29.2: resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} + third-party-web@0.30.0: + resolution: {integrity: sha512-p+PfyL5U0ediGzvwPzMSdD9MnC8rHW6nWJJVLIQvqN7RsSqfaZ+7n4c9GILqH36EYR0eXB9CzAeCRnKFr/9uJg==} + thread-stream@2.7.0: resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} @@ -30727,7 +30730,7 @@ snapshots: '@paulirish/trace_engine@0.0.65': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.2 + third-party-web: 0.30.0 '@pdf-lib/standard-fonts@1.0.0': dependencies: @@ -44082,6 +44085,8 @@ snapshots: third-party-web@0.29.2: {} + third-party-web@0.30.0: {} + thread-stream@2.7.0: dependencies: real-require: 0.2.0 diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/attachment/latest.ts b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/latest.ts index c06466e24626a..d61212cbfff12 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/attachment/latest.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/latest.ts @@ -7,3 +7,4 @@ export * from './v1'; export * from './v2'; +export * from './v2_union'; diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2.ts b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2.ts index 361d043da70db..5d6908eceaf7f 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2.ts @@ -8,16 +8,7 @@ import * as rt from 'io-ts'; import { MAX_BULK_CREATE_ATTACHMENTS } from '../../../constants'; import type { BulkGetAttachmentsRequest } from './v1'; -import { - AttachmentPatchRequestRt, - AttachmentRequestRt, - AttachmentRequestWithoutRefsRt, -} from './v1'; -import { - AttachmentRtV2, - AttachmentsRtV2, - UnifiedAttachmentPayloadRt, -} from '../../domain/attachment/v2'; +import { UnifiedAttachmentPayloadRt } from '../../domain/attachment/v2'; import { limitedArraySchema } from '../../../schema'; export type { BulkGetAttachmentsRequest as BulkGetAttachmentsRequestV2 }; @@ -26,44 +17,13 @@ export const UnifiedAttachmentPatchRequestRt = rt.intersection([ rt.strict({ id: rt.string, version: rt.string }), ]); -export const AttachmentRequestRtV2 = rt.union([AttachmentRequestRt, UnifiedAttachmentPayloadRt]); -export const AttachmentRequestWithoutRefsRtV2 = rt.union([ - AttachmentRequestWithoutRefsRt, - UnifiedAttachmentPayloadRt, -]); -export const AttachmentPatchRequestRtV2 = rt.union([ - AttachmentPatchRequestRt, - UnifiedAttachmentPatchRequestRt, -]); - -export const AttachmentsFindResponseRtV2 = rt.strict({ - comments: rt.array(AttachmentRtV2), - page: rt.number, - per_page: rt.number, - total: rt.number, -}); - -export const BulkCreateAttachmentsRequestRtV2 = limitedArraySchema({ - codec: AttachmentRequestRtV2, +export const BulkCreateUnifiedAttachmentsRequestRt = limitedArraySchema({ + codec: UnifiedAttachmentPayloadRt, min: 0, max: MAX_BULK_CREATE_ATTACHMENTS, fieldName: 'attachments', }); -export const BulkGetAttachmentsResponseRtV2 = rt.strict({ - attachments: AttachmentsRtV2, - errors: rt.array( - rt.strict({ - error: rt.string, - message: rt.string, - status: rt.union([rt.undefined, rt.number]), - savedObjectId: rt.string, - }) - ), -}); - -export type AttachmentRequestV2 = rt.TypeOf; -export type AttachmentPatchRequestV2 = rt.TypeOf; -export type AttachmentsFindResponseV2 = rt.TypeOf; -export type BulkCreateAttachmentsRequestV2 = rt.TypeOf; -export type BulkGetAttachmentsResponseV2 = rt.TypeOf; +export type BulkCreateUnifiedAttachmentsRequest = rt.TypeOf< + typeof BulkCreateUnifiedAttachmentsRequestRt +>; diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2.test.ts b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2_union.test.ts similarity index 99% rename from x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2.test.ts rename to x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2_union.test.ts index b751de2c7a117..65314f2cc9057 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2.test.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2_union.test.ts @@ -6,7 +6,7 @@ */ import { AttachmentType } from '../../domain/attachment/v1'; -import { AttachmentRequestRtV2, BulkCreateAttachmentsRequestRtV2 } from './v2'; +import { AttachmentRequestRtV2, BulkCreateAttachmentsRequestRtV2 } from './v2_union'; import { AttachmentRequestSchemaV2, BulkCreateAttachmentsRequestSchemaV2, diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2_union.ts b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2_union.ts new file mode 100644 index 0000000000000..879ff9f922c0f --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/api/attachment/v2_union.ts @@ -0,0 +1,69 @@ +/* + * 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'; +import { MAX_BULK_CREATE_ATTACHMENTS } from '../../../constants'; +import { + AttachmentPatchRequestRt, + AttachmentRequestRt, + AttachmentRequestWithoutRefsRt, +} from './v1'; +import { + AttachmentRtV2, + AttachmentsRtV2, + UnifiedAttachmentPayloadRt, +} from '../../domain/attachment/v2'; +import { UnifiedAttachmentPatchRequestRt } from './v2'; +import { limitedArraySchema } from '../../../schema'; + +export const AttachmentRequestRtV2 = rt.union([AttachmentRequestRt, UnifiedAttachmentPayloadRt]); +export const AttachmentRequestWithoutRefsRtV2 = rt.union([ + AttachmentRequestWithoutRefsRt, + UnifiedAttachmentPayloadRt, +]); +export const AttachmentPatchRequestRtV2 = rt.union([ + AttachmentPatchRequestRt, + UnifiedAttachmentPatchRequestRt, +]); + +export const AttachmentsFindResponseRtV2 = rt.strict({ + comments: rt.array(AttachmentRtV2), + page: rt.number, + per_page: rt.number, + total: rt.number, +}); + +// Version-spanning bulk-create payload: the internal route still accepts both v1 +// legacy and unified wire shapes and converts to unified before the client call. +export const BulkCreateAttachmentsRequestRtV2 = limitedArraySchema({ + codec: AttachmentRequestRtV2, + min: 0, + max: MAX_BULK_CREATE_ATTACHMENTS, + fieldName: 'attachments', +}); + +// Bulk-get response tolerates legacy shapes: the getter (`AttachmentGetter.bulkGet`) +// is mixed until every attachment type is migrated (see `toUnifiedAttributes`), so an +// unmigrated type would fail a unified-only decode here and 500 the route. Narrow this +// to unified-only once the getter is unified-only too. +export const BulkGetAttachmentsResponseRtV2 = rt.strict({ + attachments: AttachmentsRtV2, + errors: rt.array( + rt.strict({ + error: rt.string, + message: rt.string, + status: rt.union([rt.undefined, rt.number]), + savedObjectId: rt.string, + }) + ), +}); + +export type AttachmentRequestV2 = rt.TypeOf; +export type AttachmentPatchRequestV2 = rt.TypeOf; +export type AttachmentsFindResponseV2 = rt.TypeOf; +export type BulkCreateAttachmentsRequestV2 = rt.TypeOf; +export type BulkGetAttachmentsResponseV2 = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/index.ts b/x-pack/platform/plugins/shared/cases/common/types/api/index.ts index c8f30d66d02f6..a34658d51ede3 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/index.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/index.ts @@ -40,3 +40,4 @@ export * as fieldDefinitionApiV1 from './field_definition/v1'; // V2 export * as attachmentApiV2 from './attachment/v2'; +export * as attachmentApiV2Union from './attachment/v2_union'; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/comment/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/comment/v1.ts index 582e216bbe820..95bf622e1b247 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/comment/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/comment/v1.ts @@ -10,7 +10,7 @@ import { UserActionTypes } from '../action/v1'; import { AttachmentRequestRtV2, AttachmentRequestWithoutRefsRtV2, -} from '../../../api/attachment/v2'; +} from '../../../api/attachment/v2_union'; export const CommentUserActionPayloadRt = rt.strict({ comment: AttachmentRequestRtV2 }); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_create_attachments.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_create_attachments.tsx index bd5d1f977b75d..116c642157a6d 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/use_create_attachments.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_create_attachments.tsx @@ -6,7 +6,7 @@ */ import { useMutation } from '@kbn/react-query'; -import type { attachmentApiV2 } from '../../common/types/api'; +import type { attachmentApiV2Union } from '../../common/types/api'; import { createAttachments } from './api'; import * as i18n from './translations'; import type { CaseAttachmentsWithoutOwner, ServerError } from '../types'; @@ -37,7 +37,7 @@ export const useCreateAttachments = () => { // bridges the two type systems at the wire boundary. Server-side // validation re-runs through the per-type Zod schemas. return createAttachments({ - attachments: attachments as unknown as attachmentApiV2.BulkCreateAttachmentsRequestV2, + attachments: attachments as unknown as attachmentApiV2Union.BulkCreateAttachmentsRequestV2, caseId: request.caseId, }); }, diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/add.test.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/add.test.ts index 5baaa12f9d92c..ae73ee54ec163 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/add.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/add.test.ts @@ -45,6 +45,15 @@ describe('addComment', () => { ).rejects.toThrow('invalid keys "foo"'); }); + it('rejects a legacy v1 body', async () => { + const v1Comment = { type: 'user', comment: 'a legacy comment', owner: SECURITY_SOLUTION_OWNER }; + + await expect( + // @ts-expect-error: legacy v1 shape is no longer accepted, client is unified-only + addComment({ comment: v1Comment, caseId }, clientArgs) + ).rejects.toThrow(); + }); + it(`throws error when the case user actions become > ${MAX_USER_ACTIONS_PER_CASE}`, async () => { userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ [caseId]: MAX_USER_ACTIONS_PER_CASE, diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/add.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/add.ts index aac367a81aa4e..2634062f540e9 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/add.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/add.ts @@ -13,10 +13,9 @@ import { decodeWithExcessOrThrow } from '../../common/runtime_types'; import { CaseCommentModel } from '../../common/models'; import { createCaseError } from '../../common/error'; import type { CasesClientArgs } from '..'; -import { decodeCommentRequestV2 } from '../utils'; import { Operations } from '../../authorization'; import type { AddArgs } from './types'; -import { validateRegisteredAttachments } from './validators'; +import { validateUnifiedAttachments } from './validators'; import { validateMaxUserActions } from '../../common/validators'; import { extractAndAddObservables } from './extract_observables'; import { emitAttachmentsAddedEvent } from './trigger_utils'; @@ -38,7 +37,6 @@ export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs): try { const query = decodeWithExcessOrThrow(UnifiedAttachmentPayloadRt)(comment); - decodeCommentRequestV2(query, unifiedAttachmentTypeRegistry); await validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 1 }); @@ -53,7 +51,7 @@ export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs): ], }); - validateRegisteredAttachments({ + validateUnifiedAttachments({ query, unifiedAttachmentTypeRegistry, }); diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/add_file.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/add_file.ts index ff66cb6b38a83..82f3be5377243 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/add_file.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/add_file.ts @@ -17,7 +17,7 @@ import { createCaseError } from '../../common/error'; import { validateMaxUserActions } from '../../common/validators'; import { constructFileKindIdByOwner } from '../../../common/files'; import { Operations } from '../../authorization'; -import { validateRegisteredAttachments } from './validators'; +import { validateUnifiedAttachments } from './validators'; import { buildAttachmentRequestFromFileJSON } from '../utils'; import { decodeWithExcessOrThrow } from '../../common/runtime_types'; @@ -77,7 +77,7 @@ export const addFile = async ( fileMetadata: createdFile.toJSON(), }); - validateRegisteredAttachments({ + validateUnifiedAttachments({ query: commentReq, unifiedAttachmentTypeRegistry, }); diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.test.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.test.ts index e207f3569353e..62f4ede9fdfd6 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { comment, actionComment, mockCases, mockCaseUnifiedAttachments } from '../../mocks'; +import { mockCases, mockCaseUnifiedAttachments } from '../../mocks'; import { createCasesClientMockArgs } from '../mocks'; import { MAX_COMMENT_LENGTH, @@ -24,6 +24,12 @@ import { commentAttachmentType } from '../../attachment_framework/attachments'; describe('bulkCreate', () => { const caseId = 'test-case'; + const comment = { + type: 'comment' as const, + data: { content: 'a comment' }, + owner: SECURITY_SOLUTION_OWNER, + }; + const clientArgs = createCasesClientMockArgs(); const userActionService = createUserActionServiceMock(); const caseService = createCaseServiceMock(); @@ -33,6 +39,12 @@ describe('bulkCreate', () => { clientArgs.services.caseService = caseService; clientArgs.services.attachmentService = attachmentService; + const registerCommentType = () => { + if (!clientArgs.unifiedAttachmentTypeRegistry.has(commentAttachmentType.id)) { + clientArgs.unifiedAttachmentTypeRegistry.register(commentAttachmentType); + } + }; + beforeEach(() => { jest.clearAllMocks(); }); @@ -44,6 +56,15 @@ describe('bulkCreate', () => { ).rejects.toThrow('invalid keys "foo"'); }); + it('rejects a legacy v1 body', async () => { + const v1Comment = { type: 'user', comment: 'a legacy comment', owner: SECURITY_SOLUTION_OWNER }; + + await expect( + // @ts-expect-error: legacy v1 shape is no longer accepted, client is unified-only + bulkCreate({ attachments: [v1Comment], caseId }, clientArgs) + ).rejects.toThrow(); + }); + it(`throws error when attachments are more than ${MAX_BULK_CREATE_ATTACHMENTS}`, async () => { const attachments = Array(MAX_BULK_CREATE_ATTACHMENTS + 1).fill(comment); @@ -65,36 +86,10 @@ describe('bulkCreate', () => { }); describe('comments', () => { - it('should throw an error if the comment length is too long', async () => { - const longComment = Array(MAX_COMMENT_LENGTH + 1) - .fill('x') - .toString(); - - await expect( - bulkCreate({ attachments: [{ ...comment, comment: longComment }], caseId }, clientArgs) - ).rejects.toThrow( - `Failed while bulk creating attachment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.` - ); + beforeEach(() => { + registerCommentType(); }); - it('should throw an error if the comment is an empty string', async () => { - await expect( - bulkCreate({ attachments: [{ ...comment, comment: '' }], caseId }, clientArgs) - ).rejects.toThrow( - 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' - ); - }); - - it('should throw an error if the description is a string with empty characters', async () => { - await expect( - bulkCreate({ attachments: [{ ...comment, comment: ' ' }], caseId }, clientArgs) - ).rejects.toThrow( - 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' - ); - }); - }); - - describe('actions', () => { it('should throw an error if the comment length is too long', async () => { const longComment = Array(MAX_COMMENT_LENGTH + 1) .fill('x') @@ -102,33 +97,27 @@ describe('bulkCreate', () => { await expect( bulkCreate( - { attachments: [{ ...actionComment, comment: longComment }], caseId }, + { attachments: [{ ...comment, data: { content: longComment } }], caseId }, clientArgs ) - ).rejects.toThrow( - `Failed while bulk creating attachment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.` - ); + ).rejects.toThrow(/Comment content exceeds maximum length/); }); it('should throw an error if the comment is an empty string', async () => { await expect( - bulkCreate({ attachments: [{ ...actionComment, comment: '' }], caseId }, clientArgs) - ).rejects.toThrow( - 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' - ); + bulkCreate({ attachments: [{ ...comment, data: { content: '' } }], caseId }, clientArgs) + ).rejects.toThrow(/Comment content must be a non-empty string/); }); - it('should throw an error if the description is a string with empty characters', async () => { + it('should throw an error if the comment is a string with empty characters', async () => { await expect( - bulkCreate({ attachments: [{ ...actionComment, comment: ' ' }], caseId }, clientArgs) - ).rejects.toThrow( - 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' - ); + bulkCreate({ attachments: [{ ...comment, data: { content: ' ' } }], caseId }, clientArgs) + ).rejects.toThrow(/Comment content must be a non-empty string/); }); }); - it('accepts unified type (v2) attachments without owner and uses case owner', async () => { - clientArgs.unifiedAttachmentTypeRegistry.register(commentAttachmentType); + it('accepts unified comments', async () => { + registerCommentType(); userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ [caseId]: 0 }); const theCase = { ...mockCases[0], id: caseId }; diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.ts index b09c13168bc85..6083415b85328 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_create.ts @@ -7,24 +7,22 @@ import { SavedObjectsUtils } from '@kbn/core/server'; -import type { AttachmentRequestV2 } from '../../../common/types/api'; -import { BulkCreateAttachmentsRequestRtV2 } from '../../../common/types/api/attachment/v2'; +import { BulkCreateUnifiedAttachmentsRequestRt } from '../../../common/types/api/attachment/v2'; import type { Case } from '../../../common/types/domain'; +import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; import { decodeWithExcessOrThrow } from '../../common/runtime_types'; import { CaseCommentModel } from '../../common/models'; import { createCaseError } from '../../common/error'; import type { CasesClientArgs } from '..'; -import { decodeCommentRequestV2 } from '../utils'; import type { OwnerEntity } from '../../authorization'; import { Operations } from '../../authorization'; import type { BulkCreateArgs } from './types'; -import { validateRegisteredAttachments } from './validators'; +import { validateUnifiedAttachments } from './validators'; import { validateMaxUserActions } from '../../common/validators'; import { emitAttachmentsAddedEvent } from './trigger_utils'; import { extractAndAddObservables } from './extract_observables'; -import { toUnifiedAttachmentPayload } from '../../common/attachments'; export const bulkCreate = async ( args: BulkCreateArgs, @@ -40,28 +38,29 @@ export const bulkCreate = async ( } = clientArgs; try { - decodeWithExcessOrThrow(BulkCreateAttachmentsRequestRtV2)(attachments); - attachments.forEach((attachment) => { - decodeCommentRequestV2(attachment, unifiedAttachmentTypeRegistry); - }); - const unifiedAttachments = attachments.map(toUnifiedAttachmentPayload); + const decodedAttachments = decodeWithExcessOrThrow(BulkCreateUnifiedAttachmentsRequestRt)( + attachments + ); + await validateMaxUserActions({ caseId, userActionService, - userActionsToAdd: unifiedAttachments.length, + userActionsToAdd: decodedAttachments.length, }); - unifiedAttachments.forEach((attachment) => { - validateRegisteredAttachments({ + decodedAttachments.forEach((attachment) => { + validateUnifiedAttachments({ query: attachment, unifiedAttachmentTypeRegistry, }); }); const [attachmentsWithIds, entities]: [ - Array<{ id: string } & AttachmentRequestV2>, + Array<{ id: string } & UnifiedAttachmentPayload>, OwnerEntity[] - ] = unifiedAttachments.reduce<[Array<{ id: string } & AttachmentRequestV2>, OwnerEntity[]]>( + ] = decodedAttachments.reduce< + [Array<{ id: string } & UnifiedAttachmentPayload>, OwnerEntity[]] + >( ([a, e], attachment) => { const savedObjectID = SavedObjectsUtils.generateId(); return [ @@ -95,7 +94,7 @@ export const bulkCreate = async ( } // This call never throws — failures are logged and do not abort the attachment creation. - await extractAndAddObservables(caseId, unifiedAttachments, updatedCase, clientArgs); + await extractAndAddObservables(caseId, decodedAttachments, updatedCase, clientArgs); return updatedCase; } catch (error) { diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_get.test.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_get.test.ts index 84bc68aee326e..6be20a8b3ab1c 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_get.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/bulk_get.test.ts @@ -12,8 +12,13 @@ import { createCasesClientMockArgs, createCasesClientMock } from '../mocks'; import { bulkGet } from './bulk_get'; describe('bulkGet', () => { - const attachmentSO = mockCaseComments[0]; + // The getter is mixed until every attachment type is migrated (see + // `toUnifiedAttributes`), so the response tolerates a leftover legacy shape too. + // Use a unified fixture for the error-construction tests below, which don't + // exercise attachment shape. + const attachmentSO = mockCaseUnifiedAttachments[0]; const unifiedAttachmentSO = mockCaseUnifiedAttachments[0]; + const legacyAttachmentSO = mockCaseComments[0]; describe('errors', () => { const casesClient = createCasesClientMock(); @@ -155,4 +160,36 @@ describe('bulkGet', () => { ); }); }); + + describe('returns a leftover legacy-shaped attachment', () => { + const casesClient = createCasesClientMock(); + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + clientArgs.authorization.getAndEnsureAuthorizedEntities.mockResolvedValue({ + authorized: [legacyAttachmentSO], + unauthorized: [], + }); + clientArgs.services.attachmentService.getter.bulkGet.mockResolvedValue({ + saved_objects: [legacyAttachmentSO], + }); + }); + + it('decodes and returns the legacy attachment instead of throwing', async () => { + const res = await bulkGet( + { savedObjectIds: [legacyAttachmentSO.id], caseID: 'mock-id-1' }, + clientArgs, + casesClient + ); + + expect(res.attachments[0]).toEqual( + expect.objectContaining({ + id: legacyAttachmentSO.id, + type: 'user', + comment: 'Wow, good luck catching that bad meanie!', + }) + ); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.test.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.test.ts index d0f1f1c3d1ca9..e428e8dadcc4b 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.test.ts @@ -5,11 +5,15 @@ * 2.0. */ -import type { AttachmentRequestV2 } from '../../../common/types/api'; -import { AttachmentType, type Case, type Observable } from '../../../common/types/domain'; +import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; +import { type Case, type Observable } from '../../../common/types/domain'; import { OBSERVABLE_TYPE_IPV4, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { LICENSING_CASE_OBSERVABLES_FEATURE } from '../../common/constants'; -import { SECURITY_ALERT_ATTACHMENT_TYPE } from '../../../common/constants/attachments'; +import { + COMMENT_ATTACHMENT_TYPE, + SECURITY_ALERT_ATTACHMENT_TYPE, + SECURITY_EVENT_ATTACHMENT_TYPE, +} from '../../../common/constants/attachments'; import { createCasesClientMockArgs } from '../mocks'; import { createCaseServiceMock, createLicensingServiceMock } from '../../services/mocks'; import { mockCases } from '../../mocks'; @@ -32,24 +36,23 @@ const makeCase = (extractObservables: boolean): Case => total_observables: 0, } as unknown as Case); -const legacyAlertAttachment: AttachmentRequestV2 = { - type: AttachmentType.alert, - alertId: 'alert-id-1', - index: 'alert-index-1', - rule: { id: 'rule-1', name: 'rule-1' }, +const alertAttachment: UnifiedAttachmentPayload = { + type: SECURITY_ALERT_ATTACHMENT_TYPE, + attachmentId: 'alert-id-1', + metadata: { index: 'alert-index-1', rule: { id: 'rule-1', name: 'rule-1' } }, owner: SECURITY_SOLUTION_OWNER, }; -const legacyEventAttachment: AttachmentRequestV2 = { - type: AttachmentType.event, - eventId: 'event-id-1', - index: 'event-index-1', +const eventAttachment: UnifiedAttachmentPayload = { + type: SECURITY_EVENT_ATTACHMENT_TYPE, + attachmentId: 'event-id-1', + metadata: { index: 'event-index-1' }, owner: SECURITY_SOLUTION_OWNER, }; -const commentAttachment: AttachmentRequestV2 = { - type: AttachmentType.user, - comment: 'a comment', +const commentAttachment: UnifiedAttachmentPayload = { + type: COMMENT_ATTACHMENT_TYPE, + data: { content: 'a comment' }, owner: SECURITY_SOLUTION_OWNER, }; @@ -74,7 +77,7 @@ describe('extractAndAddObservables', () => { it('returns early without calling alertsService when extractObservables is false', async () => { const theCase = makeCase(false); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(alertsService.getAlerts).not.toHaveBeenCalled(); }); @@ -94,7 +97,7 @@ describe('extractAndAddObservables', () => { const theCase = makeCase(true); await expect( - extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs) + extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs) ).resolves.toBeUndefined(); expect(alertsService.getAlerts).not.toHaveBeenCalled(); @@ -108,7 +111,7 @@ describe('extractAndAddObservables', () => { alertsService.getAlerts.mockResolvedValue({ docs: [] }); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(licensingService.notifyUsage).toHaveBeenCalledWith(LICENSING_CASE_OBSERVABLES_FEATURE); }); @@ -121,7 +124,7 @@ describe('extractAndAddObservables', () => { const theCase = makeCase(true); await expect( - extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs) + extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs) ).resolves.toBeUndefined(); expect(clientArgs.logger.warn).toHaveBeenCalledWith( @@ -131,7 +134,7 @@ describe('extractAndAddObservables', () => { }); describe('attachment shape normalization', () => { - it('collects AlertInfo from a legacy alert attachment (string id + index)', async () => { + it('collects AlertInfo from a unified alert attachment (string id + index)', async () => { licensingService.isAtLeastPlatinum.mockResolvedValue(true); alertsService.getAlerts.mockResolvedValue({ docs: [makeEcsDoc({ 'source.ip': '1.2.3.4' })], @@ -140,21 +143,20 @@ describe('extractAndAddObservables', () => { caseService.patchCase.mockResolvedValue(caseSO); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(alertsService.getAlerts).toHaveBeenCalledWith([ { id: 'alert-id-1', index: 'alert-index-1' }, ]); }); - it('collects AlertInfo from a legacy alert attachment with array ids', async () => { + it('collects AlertInfo from a unified alert attachment with array ids', async () => { licensingService.isAtLeastPlatinum.mockResolvedValue(true); alertsService.getAlerts.mockResolvedValue({ docs: [] }); - const multiAlert: AttachmentRequestV2 = { - type: AttachmentType.alert, - alertId: ['id-1', 'id-2'], - index: ['idx-1', 'idx-2'], - rule: { id: 'r', name: 'r' }, + const multiAlert: UnifiedAttachmentPayload = { + type: SECURITY_ALERT_ATTACHMENT_TYPE, + attachmentId: ['id-1', 'id-2'], + metadata: { index: ['idx-1', 'idx-2'], rule: { id: 'r', name: 'r' } }, owner: SECURITY_SOLUTION_OWNER, }; const theCase = makeCase(true); @@ -167,27 +169,27 @@ describe('extractAndAddObservables', () => { ]); }); - it('collects AlertInfo from a legacy event attachment', async () => { + it('collects AlertInfo from a unified event attachment', async () => { licensingService.isAtLeastPlatinum.mockResolvedValue(true); alertsService.getAlerts.mockResolvedValue({ docs: [] }); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyEventAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [eventAttachment], theCase, clientArgs); expect(alertsService.getAlerts).toHaveBeenCalledWith([ { id: 'event-id-1', index: 'event-index-1' }, ]); }); - it('collects AlertInfo from a unified alert attachment', async () => { + it('collects AlertInfo from a unified alert attachment with a distinct id + index', async () => { licensingService.isAtLeastPlatinum.mockResolvedValue(true); alertsService.getAlerts.mockResolvedValue({ docs: [] }); - const unifiedAlert: AttachmentRequestV2 = { + const unifiedAlert: UnifiedAttachmentPayload = { type: SECURITY_ALERT_ATTACHMENT_TYPE, attachmentId: 'unified-id-1', metadata: { index: 'unified-index-1', rule: { id: 'r', name: 'r' } }, owner: SECURITY_SOLUTION_OWNER, - } as unknown as AttachmentRequestV2; + }; const theCase = makeCase(true); await extractAndAddObservables('case-1', [unifiedAlert], theCase, clientArgs); @@ -208,7 +210,7 @@ describe('extractAndAddObservables', () => { await extractAndAddObservables( 'case-1', - [commentAttachment, legacyAlertAttachment], + [commentAttachment, alertAttachment], theCase, clientArgs ); @@ -228,7 +230,7 @@ describe('extractAndAddObservables', () => { }); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(caseService.patchCase).not.toHaveBeenCalled(); }); @@ -238,7 +240,7 @@ describe('extractAndAddObservables', () => { alertsService.getAlerts.mockResolvedValue({ docs: [] }); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(caseService.patchCase).not.toHaveBeenCalled(); }); @@ -252,7 +254,7 @@ describe('extractAndAddObservables', () => { caseService.patchCase.mockResolvedValue(caseSO); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(caseService.patchCase).toHaveBeenCalledWith( expect.objectContaining({ @@ -285,7 +287,7 @@ describe('extractAndAddObservables', () => { }); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(caseService.patchCase).not.toHaveBeenCalled(); }); @@ -299,7 +301,7 @@ describe('extractAndAddObservables', () => { caseService.patchCase.mockResolvedValue(caseSO); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(clientArgs.logger.debug).toHaveBeenCalledWith( expect.stringContaining('Added 1 observable') @@ -328,7 +330,7 @@ describe('extractAndAddObservables', () => { }); const theCase = makeCase(true); - await extractAndAddObservables('case-1', [legacyAlertAttachment], theCase, clientArgs); + await extractAndAddObservables('case-1', [alertAttachment], theCase, clientArgs); expect(caseService.patchCase).not.toHaveBeenCalled(); expect(clientArgs.casesEventBus.emitObservablesAdded).not.toHaveBeenCalled(); diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.ts index 44e5f206b652a..510acd3a59171 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/extract_observables.ts @@ -7,17 +7,14 @@ import { getFlattenedObject } from '@kbn/std'; -import type { AttachmentRequestV2 } from '../../../common/types/api'; +import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; import type { Case } from '../../../common/types/domain'; import type { AlertInfo } from '../../common/types'; import { LICENSING_CASE_OBSERVABLES_FEATURE } from '../../common/constants'; import { getObservablesFromEcs } from '../../../common/observables/get_observables_from_ecs'; import type { FlattedEcsData } from '../../../common/observables/get_observables_from_ecs'; import { toStringArray } from '../../../common/utils/attachments/string_utils'; -import { - isLegacyAlertAttachment, - isLegacyEventAttachment, -} from '../../../common/utils/attachments/v1_type_guards'; +import { getIndexFromMetadata } from '../../../common/utils/attachments/index_metadata'; import { isUnifiedAlertAttachment, isUnifiedEventAttachment, @@ -39,27 +36,15 @@ const zipIdsAndIndices = (ids: string[], indices: string[]): AlertInfo[] => { }; /** - * Extract AlertInfo (id + index pairs) from a single attachment request. - * Handles legacy and unified shapes for both alert and event attachments. + * Extract AlertInfo (id + index pairs) from a single unified attachment. * Non-alert/event attachments produce an empty array. */ -const getAlertInfoFromAttachment = (attachment: AttachmentRequestV2): AlertInfo[] => { - if (isLegacyAlertAttachment(attachment)) { - return zipIdsAndIndices(toStringArray(attachment.alertId), toStringArray(attachment.index)); - } - - if (isUnifiedAlertAttachment(attachment)) { - const metadata = (attachment.metadata ?? {}) as { index?: unknown }; - return zipIdsAndIndices(toStringArray(attachment.attachmentId), toStringArray(metadata.index)); - } - - if (isLegacyEventAttachment(attachment)) { - return zipIdsAndIndices(toStringArray(attachment.eventId), toStringArray(attachment.index)); - } - - if (isUnifiedEventAttachment(attachment)) { - const metadata = (attachment.metadata ?? {}) as { index?: unknown }; - return zipIdsAndIndices(toStringArray(attachment.attachmentId), toStringArray(metadata.index)); +const getAlertInfoFromAttachment = (attachment: UnifiedAttachmentPayload): AlertInfo[] => { + if (isUnifiedAlertAttachment(attachment) || isUnifiedEventAttachment(attachment)) { + return zipIdsAndIndices( + toStringArray(attachment.attachmentId), + toStringArray(getIndexFromMetadata(attachment.metadata)) + ); } return []; @@ -91,7 +76,7 @@ const toFlattedEcsData = (flatDoc: Record): FlattedEcsData[] => */ export const extractAndAddObservables = async ( caseId: string, - attachments: AttachmentRequestV2[], + attachments: UnifiedAttachmentPayload[], updatedCase: Case, clientArgs: CasesClientArgs ): Promise => { diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/types.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/types.ts index c5983d364f796..c1ab16b6511a4 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/types.ts @@ -9,10 +9,7 @@ import type { KueryNode } from '@kbn/es-query'; import type { Readable } from 'stream'; import type { ReplaySubject } from 'rxjs'; import type { AttachmentType } from '../../../common'; -import type { - BulkCreateAttachmentsRequestV2, - FindAttachmentsQueryParams, -} from '../../../common/types/api'; +import type { FindAttachmentsQueryParams } from '../../../common/types/api'; import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; /** @@ -33,10 +30,10 @@ export interface AddArgs { export interface BulkCreateArgs { caseId: string; /** - * Mixed v1/unified. Internal bulk_create and connectors still send v1; - * convert inside bulkCreate until those callers go unified-only. + * Unified payloads only. Callers with mixed wire shapes convert first via + * toUnifiedAttachmentRequest. */ - attachments: BulkCreateAttachmentsRequestV2; + attachments: UnifiedAttachmentPayload[]; } /** diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/update.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/update.ts index 2a4bb3975894b..c86a604298e0b 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/update.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/update.ts @@ -10,16 +10,14 @@ import Boom from '@hapi/boom'; import { UnifiedAttachmentPatchRequestRt } from '../../../common/types/api'; import { CaseCommentModel } from '../../common/models'; import { createCaseError } from '../../common/error'; -import { isCommentRequestTypeExternalReference } from '../../../common/utils/attachments'; import type { Case } from '../../../common/types/domain'; import { decodeWithExcessOrThrow } from '../../common/runtime_types'; import { CASE_SAVED_OBJECT } from '../../../common/constants'; import type { CasesClientArgs } from '..'; -import { decodeCommentRequestV2 } from '../utils'; import { Operations } from '../../authorization'; import type { UpdateArgs } from './types'; import { validateMaxUserActions } from '../../common/validators'; -import { validateRegisteredAttachments } from './validators'; +import { validateUnifiedAttachments } from './validators'; /** * Update an attachment. @@ -43,7 +41,6 @@ export async function update( version: queryCommentVersion, ...queryRestAttributes } = decodeWithExcessOrThrow(UnifiedAttachmentPatchRequestRt)(queryParams); - decodeCommentRequestV2(queryRestAttributes, unifiedAttachmentTypeRegistry); await validateMaxUserActions({ caseId: caseID, @@ -51,10 +48,9 @@ export async function update( userActionsToAdd: 1, }); - // Also enforce registry registration and the unified zod schema for - // migrated legacy subtypes (e.g. `.files`); mirrors the add/bulk_create - // paths so PATCH stays in sync with POST. - validateRegisteredAttachments({ + // Enforce registry registration and the unified zod schema; mirrors the + // add/bulk_create paths so PATCH stays in sync with POST. + validateUnifiedAttachments({ query: queryRestAttributes, unifiedAttachmentTypeRegistry, }); @@ -82,15 +78,6 @@ export async function update( throw Boom.badRequest(`You cannot change the owner of the comment.`); } - if ( - isCommentRequestTypeExternalReference(myComment.attributes) && - isCommentRequestTypeExternalReference(queryRestAttributes) && - myComment.attributes.externalReferenceStorage.type !== - queryRestAttributes.externalReferenceStorage.type - ) { - throw Boom.badRequest(`You cannot change the storage type of an external reference comment.`); - } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); if (caseRef == null || (caseRef != null && caseRef.id !== model.savedObject.id)) { throw Boom.notFound( diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.test.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.test.ts index f18490cc14dc1..249a0248cd1c7 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.test.ts @@ -6,26 +6,12 @@ */ import { z } from '@kbn/zod/v4'; -import { FILE_SO_TYPE } from '@kbn/files-plugin/common/constants'; -import { - COMMENT_ATTACHMENT_TYPE, - FILE_ATTACHMENT_TYPE, - LEGACY_FILE_ATTACHMENT_TYPE, - LENS_ATTACHMENT_TYPE, - LEGACY_LENS_ATTACHMENT_TYPE, - SECURITY_ENDPOINT_ATTACHMENT_TYPE, -} from '../../../common/constants/attachments'; +import { COMMENT_ATTACHMENT_TYPE } from '../../../common/constants/attachments'; import { CommentAttachmentPayloadSchema } from '../../../common/types/domain_zod/attachment/comment/v2'; -import { LensAttachmentPayloadSchema } from '../../../common/types/domain_zod/attachment/lens/v2'; -import { FileAttachmentPayloadSchema } from '../../../common/types/domain_zod/attachment/file/v2'; import { UnifiedAttachmentTypeRegistry } from '../../attachment_framework/unified_attachment_registry'; -import { - validateLegacyRegisteredAttachments, - validateUnifiedRegisteredAttachments, -} from './validators'; -import { AttachmentType, ExternalReferenceStorageType } from '../../../common/types/domain'; +import { validateUnifiedAttachments } from './validators'; -describe('validateUnifiedRegisteredAttachments', () => { +describe('validateUnifiedAttachments', () => { const validCommentPayload = { type: COMMENT_ATTACHMENT_TYPE, owner: 'cases', @@ -36,7 +22,7 @@ describe('validateUnifiedRegisteredAttachments', () => { const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); expect(() => - validateUnifiedRegisteredAttachments({ + validateUnifiedAttachments({ query: { ...validCommentPayload }, unifiedAttachmentTypeRegistry, }) @@ -49,7 +35,7 @@ describe('validateUnifiedRegisteredAttachments', () => { unifiedAttachmentTypeRegistry.register({ id: COMMENT_ATTACHMENT_TYPE } as never); expect(() => - validateUnifiedRegisteredAttachments({ + validateUnifiedAttachments({ query: { ...validCommentPayload }, unifiedAttachmentTypeRegistry, }) @@ -65,7 +51,7 @@ describe('validateUnifiedRegisteredAttachments', () => { }); expect(() => - validateUnifiedRegisteredAttachments({ + validateUnifiedAttachments({ query: { ...validCommentPayload }, unifiedAttachmentTypeRegistry, }) @@ -80,7 +66,7 @@ describe('validateUnifiedRegisteredAttachments', () => { }); expect(() => - validateUnifiedRegisteredAttachments({ + validateUnifiedAttachments({ query: { ...validCommentPayload, data: { content: '' } }, unifiedAttachmentTypeRegistry, }) @@ -95,7 +81,7 @@ describe('validateUnifiedRegisteredAttachments', () => { }); expect(() => - validateUnifiedRegisteredAttachments({ + validateUnifiedAttachments({ query: { ...validCommentPayload, data: { content: '' } }, unifiedAttachmentTypeRegistry, }) @@ -112,245 +98,10 @@ describe('validateUnifiedRegisteredAttachments', () => { }); expect(() => - validateUnifiedRegisteredAttachments({ + validateUnifiedAttachments({ query: { ...validCommentPayload }, unifiedAttachmentTypeRegistry, }) ).toThrow(/Invalid attachment payload for type 'comment'/); }); }); - -describe('validateLegacyRegisteredAttachments (migrated subtypes)', () => { - const validFileEntry = { - name: 'screenshot', - extension: 'png', - mimeType: 'image/png', - created: '2024-01-01T00:00:00.000Z', - }; - - const buildLegacyFilePayload = (overrides: Record = {}) => ({ - type: AttachmentType.externalReference, - externalReferenceAttachmentTypeId: LEGACY_FILE_ATTACHMENT_TYPE, - externalReferenceId: 'file-so-id', - externalReferenceStorage: { - type: ExternalReferenceStorageType.savedObject, - soType: FILE_SO_TYPE, - }, - externalReferenceMetadata: { files: [validFileEntry] }, - owner: 'securitySolution', - ...overrides, - }); - - const buildLegacyLensPayload = (overrides: Record = {}) => ({ - type: AttachmentType.persistableState, - persistableStateAttachmentTypeId: LEGACY_LENS_ATTACHMENT_TYPE, - persistableStateAttachmentState: { state: { attributes: { state: { query: {} } } } }, - owner: 'securitySolution', - ...overrides, - }); - - describe('migrated external reference (file)', () => { - it('accepts a valid legacy `.files` payload after transforming and validating against the unified zod schema', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: FILE_ATTACHMENT_TYPE, - schema: FileAttachmentPayloadSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyFilePayload() as never, - unifiedAttachmentTypeRegistry, - }) - ).not.toThrow(); - }); - - it('rejects a legacy `.files` payload with an invalid file entry (extra keys are strict)', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: FILE_ATTACHMENT_TYPE, - schema: FileAttachmentPayloadSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyFilePayload({ - externalReferenceMetadata: { - files: [{ ...validFileEntry, extra: 'not-allowed' }], - }, - }) as never, - unifiedAttachmentTypeRegistry, - }) - ).toThrow(/Invalid attachment payload for type 'file'/); - }); - - it('rejects a legacy `.files` payload missing required file entry fields', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: FILE_ATTACHMENT_TYPE, - schema: FileAttachmentPayloadSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyFilePayload({ - externalReferenceMetadata: { files: [{ name: 'screenshot' }] }, - }) as never, - unifiedAttachmentTypeRegistry, - }) - ).toThrow(/Invalid attachment payload for type 'file'/); - }); - - it('rejects a legacy `.files` payload with zero files', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: FILE_ATTACHMENT_TYPE, - schema: FileAttachmentPayloadSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyFilePayload({ - externalReferenceMetadata: { files: [] }, - }) as never, - unifiedAttachmentTypeRegistry, - }) - ).toThrow(/Invalid attachment payload for type 'file'/); - }); - }); - - describe('migrated external reference (endpoint, with data.content lift)', () => { - // Mirrors the shape of `EndpointAttachmentPayloadSchema` registered by - // security_solution. We redeclare it here (rather than importing across - // plugin boundaries) to assert the cases-plugin lift + validator contract - // independently of the security_solution registration. - const endpointSchema = z - .object({ - type: z.literal(SECURITY_ENDPOINT_ATTACHMENT_TYPE), - owner: z.string(), - attachmentId: z.string(), - data: z.object({ content: z.string() }).strict(), - metadata: z - .object({ - command: z.string(), - targets: z - .array( - z - .object({ - endpointId: z.string(), - hostname: z.string(), - agentType: z.string(), - }) - .strict() - ) - .min(1), - }) - .strict(), - }) - .strict(); - - const buildLegacyEndpointPayload = (overrides: Record = {}) => ({ - type: AttachmentType.externalReference, - externalReferenceAttachmentTypeId: 'endpoint', - externalReferenceId: 'action-1', - externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc }, - externalReferenceMetadata: { - command: 'isolate', - comment: 'host isolated', - targets: [{ endpointId: 'ep-1', hostname: 'host-1', agentType: 'endpoint' }], - }, - owner: 'securitySolution', - ...overrides, - }); - - it('accepts a legacy endpoint payload: lifts metadata.comment to data.content before validating', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: SECURITY_ENDPOINT_ATTACHMENT_TYPE, - schema: endpointSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyEndpointPayload() as never, - unifiedAttachmentTypeRegistry, - }) - ).not.toThrow(); - }); - - it('rejects a legacy endpoint payload whose externalReferenceMetadata has no `comment` (no data.content to lift)', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: SECURITY_ENDPOINT_ATTACHMENT_TYPE, - schema: endpointSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyEndpointPayload({ - externalReferenceMetadata: { - command: 'isolate', - targets: [{ endpointId: 'ep-1', hostname: 'host-1', agentType: 'endpoint' }], - }, - }) as never, - unifiedAttachmentTypeRegistry, - }) - ).toThrow(/Invalid attachment payload for type 'security\.endpoint'/); - }); - - it('rejects a legacy endpoint payload with an empty targets array', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: SECURITY_ENDPOINT_ATTACHMENT_TYPE, - schema: endpointSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyEndpointPayload({ - externalReferenceMetadata: { - command: 'isolate', - comment: 'host isolated', - targets: [], - }, - }) as never, - unifiedAttachmentTypeRegistry, - }) - ).toThrow(/Invalid attachment payload for type 'security\.endpoint'/); - }); - }); - - describe('migrated persistable state (lens)', () => { - it('accepts a valid legacy `.lens` payload after transforming and validating against the unified zod schema', () => { - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: LENS_ATTACHMENT_TYPE, - schema: LensAttachmentPayloadSchema, - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyLensPayload() as never, - unifiedAttachmentTypeRegistry, - }) - ).not.toThrow(); - }); - - it('routes the transformed legacy `.lens` payload through the unified zod schema', () => { - // Register a stricter custom schema for `lens` and expect it to reject the - // transformed payload, proving the legacy → unified validation path is wired. - const unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: LENS_ATTACHMENT_TYPE, - schema: z.object({ never: z.literal('matches') }).strict(), - }); - - expect(() => - validateLegacyRegisteredAttachments({ - query: buildLegacyLensPayload() as never, - unifiedAttachmentTypeRegistry, - }) - ).toThrow(/Invalid attachment payload for type 'lens'/); - }); - }); -}); diff --git a/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.ts b/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.ts index a5e07a6e2c0e0..ee5b0fcab9dd9 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/attachments/validators.ts @@ -8,19 +8,7 @@ import Boom from '@hapi/boom'; import type { z } from '@kbn/zod/v4'; import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; -import { - isCommentRequestTypeExternalReference, - isCommentRequestTypePersistableState, - isLegacyAttachmentRequest, - isUnifiedAttachmentRequest, - isPersistableType, - toUnifiedPersistableStateAttachmentType, -} from '../../../common/utils/attachments'; -import { EXTERNAL_REFERENCE_TYPE_MAP } from '../../../common/constants/attachments'; -import type { AttachmentRequest, AttachmentRequestV2 } from '../../../common/types/api'; import type { UnifiedAttachmentTypeRegistry } from '../../attachment_framework/unified_attachment_registry'; -import { externalReferenceAttachmentTransformer } from '../../common/attachments/external_reference'; -import { persistableStateAttachmentTransformer } from '../../common/attachments/persistable_state'; /** Throws `Boom.badRequest` with a `path: message` summary of every zod issue. */ export const parseUnifiedAttachmentWithSchema = ( @@ -38,67 +26,7 @@ export const parseUnifiedAttachmentWithSchema = ( throw Boom.badRequest(`Invalid attachment payload for type '${type}': ${summary}`); }; -/** - * A legacy attachment is only valid when its type maps to a migrated unified - * type that is registered; otherwise it is rejected as unregistered. - */ -const assertMigratedUnifiedType = ( - legacyTypeId: string, - unifiedTypeId: string | undefined, - unifiedAttachmentTypeRegistry: UnifiedAttachmentTypeRegistry -): void => { - if (unifiedTypeId === undefined) { - throw Boom.badRequest(`Attachment type ${legacyTypeId} is not registered.`); - } - if (!unifiedAttachmentTypeRegistry.has(unifiedTypeId)) { - throw Boom.badRequest( - `Attachment type ${legacyTypeId} (unified: ${unifiedTypeId}) is not registered in unified attachment type registry.` - ); - } -}; - -export const validateLegacyRegisteredAttachments = ({ - query, - unifiedAttachmentTypeRegistry, -}: { - query: AttachmentRequest; - unifiedAttachmentTypeRegistry: UnifiedAttachmentTypeRegistry; -}) => { - // Each legacy branch resolves its unified type id, asserts it is a registered - // migrated type, then transforms the legacy payload into its unified shape and - // re-validates via the unified zod schema so legacy clients get the same - // strictness as unified clients. - if (isCommentRequestTypeExternalReference(query)) { - const legacyTypeId = query.externalReferenceAttachmentTypeId; - assertMigratedUnifiedType( - legacyTypeId, - EXTERNAL_REFERENCE_TYPE_MAP[legacyTypeId], - unifiedAttachmentTypeRegistry - ); - validateUnifiedRegisteredAttachments({ - query: externalReferenceAttachmentTransformer.toUnifiedPayload(query), - unifiedAttachmentTypeRegistry, - }); - return; - } - - if (isCommentRequestTypePersistableState(query)) { - const legacyTypeId = query.persistableStateAttachmentTypeId; - assertMigratedUnifiedType( - legacyTypeId, - isPersistableType(legacyTypeId) - ? toUnifiedPersistableStateAttachmentType(legacyTypeId) - : undefined, - unifiedAttachmentTypeRegistry - ); - validateUnifiedRegisteredAttachments({ - query: persistableStateAttachmentTransformer.toUnifiedPayload(query), - unifiedAttachmentTypeRegistry, - }); - } -}; - -export const validateUnifiedRegisteredAttachments = ({ +export const validateUnifiedAttachments = ({ query, unifiedAttachmentTypeRegistry, }: { @@ -112,37 +40,9 @@ export const validateUnifiedRegisteredAttachments = ({ } const attachmentType = unifiedAttachmentTypeRegistry.get(query.type); - if (!attachmentType) { - throw Boom.badRequest( - `Attachment type ${query.type} is not registered in unified attachment type registry.` - ); - } - if (!attachmentType.schema) { throw Boom.badRequest(`Attachment type '${query.type}' does not define a schema.`); } parseUnifiedAttachmentWithSchema(attachmentType.schema, query, query.type); }; - -export const validateRegisteredAttachments = ({ - query, - unifiedAttachmentTypeRegistry, -}: { - query: AttachmentRequestV2; - unifiedAttachmentTypeRegistry: UnifiedAttachmentTypeRegistry; -}) => { - if (isLegacyAttachmentRequest(query)) { - validateLegacyRegisteredAttachments({ - query, - unifiedAttachmentTypeRegistry, - }); - } else if (isUnifiedAttachmentRequest(query)) { - validateUnifiedRegisteredAttachments({ - query, - unifiedAttachmentTypeRegistry, - }); - } else { - throw Boom.badRequest(`Invalid attachment request type: ${typeof query}`); - } -}; diff --git a/x-pack/platform/plugins/shared/cases/server/client/decode_unified_comment_request.test.ts b/x-pack/platform/plugins/shared/cases/server/client/decode_unified_comment_request.test.ts deleted file mode 100644 index c46180d0533db..0000000000000 --- a/x-pack/platform/plugins/shared/cases/server/client/decode_unified_comment_request.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { COMMENT_ATTACHMENT_TYPE } from '../../common/constants/attachments'; -import { CommentAttachmentPayloadSchema } from '../../common/types/domain_zod/attachment/comment/v2'; -import { UnifiedAttachmentTypeRegistry } from '../attachment_framework/unified_attachment_registry'; -import { decodeUnifiedCommentRequest } from './utils'; - -describe('decodeUnifiedCommentRequest', () => { - const validCommentPayload = { - type: COMMENT_ATTACHMENT_TYPE, - owner: 'cases', - data: { content: 'hello world' }, - } as const; - - it('throws when the type is not registered', () => { - const unifiedRegistry = new UnifiedAttachmentTypeRegistry(); - - expect(() => decodeUnifiedCommentRequest({ ...validCommentPayload }, unifiedRegistry)).toThrow( - /is not registered in unified attachment type registry/ - ); - }); - - it('throws a Boom badRequest when a registered type has no schema (runtime misuse)', () => { - const unifiedRegistry = new UnifiedAttachmentTypeRegistry(); - // Simulate a type registered via `as any` that bypasses the required-schema type. - unifiedRegistry.register({ id: COMMENT_ATTACHMENT_TYPE } as never); - - expect(() => decodeUnifiedCommentRequest({ ...validCommentPayload }, unifiedRegistry)).toThrow( - /Attachment type 'comment' does not define a schema/ - ); - }); - - describe('when `schema` is set', () => { - it('accepts a valid payload', () => { - const unifiedRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedRegistry.register({ - id: COMMENT_ATTACHMENT_TYPE, - schema: CommentAttachmentPayloadSchema, - }); - - expect(() => - decodeUnifiedCommentRequest({ ...validCommentPayload }, unifiedRegistry) - ).not.toThrow(); - }); - - it('rejects an invalid payload with a Boom badRequest including the failing path', () => { - const unifiedRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedRegistry.register({ - id: COMMENT_ATTACHMENT_TYPE, - schema: CommentAttachmentPayloadSchema, - }); - - expect(() => - decodeUnifiedCommentRequest( - { ...validCommentPayload, data: { content: '' } }, - unifiedRegistry - ) - ).toThrow(/data\.content: Comment content must be a non-empty string/); - }); - }); -}); diff --git a/x-pack/platform/plugins/shared/cases/server/client/utils.ts b/x-pack/platform/plugins/shared/cases/server/client/utils.ts index 95fbcaad22773..b3dc47767f746 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/utils.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/utils.ts @@ -25,181 +25,26 @@ import type { CaseSeverity, CaseStatuses, CustomFieldsConfiguration, - ExternalReferenceAttachmentPayload, TemplatesConfiguration, CustomFieldTypes, } from '../../common/types/domain'; -import { - ActionsAttachmentPayloadRt, - AlertAttachmentPayloadRt, - EventAttachmentPayloadRt, - ExternalReferenceNoSOAttachmentPayloadRt, - ExternalReferenceSOAttachmentPayloadRt, - ExternalReferenceStorageType, - PersistableStateAttachmentPayloadRt, - UserCommentAttachmentPayloadRt, -} from '../../common/types/domain'; import type { SavedObjectFindOptionsKueryNode } from '../common/types'; import type { CasesSearchParams } from './types'; -import { decodeWithExcessOrThrow } from '../common/runtime_types'; import { CASE_SAVED_OBJECT, FILE_ATTACHMENT_TYPE, NO_ASSIGNEES_FILTERING_KEYWORD, OWNER_FIELD, } from '../../common/constants'; -import { - isCommentRequestTypeExternalReference, - isCommentRequestTypePersistableState, - isUnifiedAttachmentRequest, - isUnifiedReferenceAttachmentRequest, - isUnifiedValueAttachmentRequest, - isLegacyAttachmentRequest, - isLegacyCommentAttachment, -} from '../../common/utils/attachments'; import { combineFilterWithAuthorizationFilter } from '../authorization/utils'; import { SEVERITY_EXTERNAL_TO_ESMODEL, STATUS_EXTERNAL_TO_ESMODEL } from '../common/constants'; -import { - getIDsAndIndicesAsArrays, - isCommentRequestTypeAlert, - isCommentRequestTypeActions, - assertUnreachable, - isCommentRequestTypeEvent, -} from '../common/utils'; -import type { UnifiedAttachmentTypeRegistry } from '../attachment_framework/unified_attachment_registry'; +import { isCommentRequestTypeAlert } from '../common/utils'; import type { UnifiedAttachmentPayload } from '../../common/types/domain/attachment/v2'; -import { parseUnifiedAttachmentWithSchema } from './attachments/validators'; -import type { - AttachmentRequest, - AttachmentRequestV2, - CasesFindRequestSortFields, -} from '../../common/types/api'; +import type { AttachmentRequest, CasesFindRequestSortFields } from '../../common/types/api'; import type { ICasesCustomField } from '../custom_fields'; import { casesCustomFields } from '../custom_fields'; -// TODO: I think we can remove most of this function since we're using a different excess -export const decodeCommentRequest = (comment: AttachmentRequest) => { - if (isLegacyCommentAttachment(comment)) { - decodeWithExcessOrThrow(UserCommentAttachmentPayloadRt)(comment); - } else if (isCommentRequestTypeActions(comment)) { - decodeWithExcessOrThrow(ActionsAttachmentPayloadRt)(comment); - } else if (isCommentRequestTypeAlert(comment)) { - decodeWithExcessOrThrow(AlertAttachmentPayloadRt)(comment); - - const { ids, indices } = getIDsAndIndicesAsArrays(comment); - - /** - * The alertId and index field must either be both of type string or they must both be string[] and be the same length. - * Having a one-to-one relationship between the id and index of an alert avoids accidentally updating or - * retrieving the wrong alert. Elasticsearch only guarantees that the _id (the field we use for alertId) to be - * unique within a single index. So if we attempt to update or get a specific alert across multiple indices we could - * update or receive the wrong one. - * - * Consider the situation where we have a alert1 with _id = '100' in index 'my-index-awesome' and also in index - * 'my-index-hi'. - * If we attempt to update the status of alert1 using an index pattern like `my-index-*` or even providing multiple - * indices, there's a chance we'll accidentally update too many alerts. - * - * This check doesn't enforce that the API request has the correct alert ID to index relationship it just guards - * against accidentally making a request like: - * { - * alertId: [1,2,3], - * index: awesome, - * } - * - * Instead this requires the requestor to provide: - * { - * alertId: [1,2,3], - * index: [awesome, awesome, awesome] - * } - * - * Ideally we'd change the format of the comment request to be an array of objects like: - * { - * alerts: [{id: 1, index: awesome}, {id: 2, index: awesome}] - * } - * - * But we'd need to also implement a migration because the saved object document currently stores the id and index - * in separate fields. - */ - if (ids.length !== indices.length) { - throw badRequest( - `Received an alert comment with ids and indices arrays of different lengths ids: ${JSON.stringify( - ids - )} indices: ${JSON.stringify(indices)}` - ); - } - } else if (isCommentRequestTypeEvent(comment)) { - decodeWithExcessOrThrow(EventAttachmentPayloadRt)(comment); - } else if (isCommentRequestTypeExternalReference(comment)) { - decodeExternalReferenceAttachment(comment); - } else if (isCommentRequestTypePersistableState(comment)) { - decodeWithExcessOrThrow(PersistableStateAttachmentPayloadRt)(comment); - } else { - /** - * This assertion ensures that TS will show an error - * when we add a new attachment type. This way, we rely on TS - * to remind us that we have to do a check for the new attachment. - */ - assertUnreachable(comment); - } -}; - -const decodeExternalReferenceAttachment = (attachment: ExternalReferenceAttachmentPayload) => { - if (attachment.externalReferenceStorage.type === ExternalReferenceStorageType.savedObject) { - decodeWithExcessOrThrow(ExternalReferenceSOAttachmentPayloadRt)(attachment); - } else { - decodeWithExcessOrThrow(ExternalReferenceNoSOAttachmentPayloadRt)(attachment); - } -}; - -/** Validates a unified attachment via the registered `schema`. */ -const decodeUnifiedAttachment = ( - attachment: UnifiedAttachmentPayload, - unifiedRegistry: UnifiedAttachmentTypeRegistry -) => { - if (!unifiedRegistry.has(attachment.type)) { - throw badRequest( - `Attachment type ${attachment.type} is not registered in unified attachment type registry.` - ); - } - - const attachmentType = unifiedRegistry.get(attachment.type); - - if (!attachmentType.schema) { - throw badRequest(`Attachment type '${attachment.type}' does not define a schema.`); - } - - parseUnifiedAttachmentWithSchema(attachmentType.schema, attachment, attachment.type); -}; - -export const decodeUnifiedCommentRequest = ( - attachment: UnifiedAttachmentPayload, - unifiedRegistry: UnifiedAttachmentTypeRegistry -) => { - if ( - isUnifiedValueAttachmentRequest(attachment) || - isUnifiedReferenceAttachmentRequest(attachment) - ) { - decodeUnifiedAttachment(attachment, unifiedRegistry); - } else { - assertUnreachable(attachment); - } -}; - -export const decodeCommentRequestV2 = ( - attachment: AttachmentRequestV2, - unifiedRegistry: UnifiedAttachmentTypeRegistry -) => { - if (isLegacyAttachmentRequest(attachment)) { - decodeCommentRequest(attachment); - } else if (isUnifiedAttachmentRequest(attachment)) { - decodeUnifiedCommentRequest(attachment, unifiedRegistry); - } else { - assertUnreachable(attachment); - } -}; - /** * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. */ @@ -776,7 +621,7 @@ export const buildAttachmentRequestFromFileJSON = ({ }: { owner: string; fileMetadata: FileJSON; -}): AttachmentRequestV2 => ({ +}): UnifiedAttachmentPayload => ({ owner, type: FILE_ATTACHMENT_TYPE, attachmentId: fileMetadata.id, diff --git a/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.test.ts b/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.test.ts index 128da67e10d15..4054fd7cff344 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.test.ts @@ -5,35 +5,95 @@ * 2.0. */ -import type { - AlertAttachmentAttributes, - EventAttachmentAttributes, -} from '../../../common/types/domain'; -import { AttachmentType, CaseStatuses } from '../../../common/types/domain'; -import type { SavedObject } from '@kbn/core-saved-objects-api-server'; +import { CaseStatuses } from '../../../common/types/domain'; import { createCasesClientMockArgs } from '../../client/mocks'; -import { - alertComment, - comment, - eventComment, - mockCaseComments, - mockCases, - multipleAlert, -} from '../../mocks'; +import { mockCaseComments, mockCases } from '../../mocks'; import { CaseCommentModel } from './case_with_comments'; import { MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES, SECURITY_SOLUTION_OWNER, } from '../../../common/constants'; import { + COMMENT_ATTACHMENT_TYPE, + FILE_ATTACHMENT_TYPE, + LENS_ATTACHMENT_TYPE, + OSQUERY_ATTACHMENT_TYPE, SECURITY_ALERT_ATTACHMENT_TYPE, SECURITY_EVENT_ATTACHMENT_TYPE, } from '../../../common/constants/attachments'; -import { - commentExternalReference, - commentFileExternalReference, - commentPersistableState, -} from '../../client/cases/mock'; + +// Unified request fixtures. `CaseCommentModel` only ever receives already-unified +// payloads (real callers convert legacy shapes before reaching this model), so these +// replace the legacy `alertId`/`eventId`/`comment` fixtures that used to live in +// `../../mocks` for this test file specifically. +const unifiedComment = { + type: COMMENT_ATTACHMENT_TYPE, + owner: SECURITY_SOLUTION_OWNER, + data: { content: 'a comment' }, +}; + +const unifiedAlertComment = { + type: SECURITY_ALERT_ATTACHMENT_TYPE, + owner: SECURITY_SOLUTION_OWNER, + attachmentId: 'alert-id-1', + metadata: { + index: 'alert-index-1', + rule: { id: 'rule-id-1', name: 'rule-name-1' }, + }, +}; + +const unifiedEventComment = { + type: SECURITY_EVENT_ATTACHMENT_TYPE, + owner: SECURITY_SOLUTION_OWNER, + attachmentId: 'event-id-1', + metadata: { index: 'mock-index' }, +}; + +const unifiedMultipleAlert = { + ...unifiedAlertComment, + attachmentId: ['test-id-3', 'test-id-4', 'test-id-5'], + metadata: { + ...unifiedAlertComment.metadata, + index: ['test-index-3', 'test-index-4', 'test-index-5'], + }, +}; + +const unifiedPersistableState = { + type: LENS_ATTACHMENT_TYPE, + owner: SECURITY_SOLUTION_OWNER, + data: { state: { foo: 'foo' } }, +}; + +const unifiedExternalReference = { + type: OSQUERY_ATTACHMENT_TYPE, + owner: SECURITY_SOLUTION_OWNER, + data: { query: 'select * from users' }, +}; + +const unifiedFileExternalReference = { + type: FILE_ATTACHMENT_TYPE, + owner: SECURITY_SOLUTION_OWNER, + attachmentId: 'file-id-1', + metadata: { + files: [ + { + name: 'report.pdf', + extension: 'pdf', + mimeType: 'application/pdf', + created: '2019-11-25T21:55:00.177Z', + }, + ], + }, +}; + +// Bulk-create mock args are typed as saved-object create args (attributes untyped); +// tests assert on the unified attributes shape instead of repeating this cast. +interface UnifiedAttachmentSOForTest { + attributes: { type?: string; attachmentId: string[]; metadata: { index: string[] } }; +} + +const asUnifiedAttachmentSO = (attachment: unknown): UnifiedAttachmentSOForTest => + attachment as UnifiedAttachmentSOForTest; describe('CaseCommentModel', () => { const theCase = mockCases[0]; @@ -72,147 +132,66 @@ describe('CaseCommentModel', () => { it('does not remove comments when filtering out duplicate alerts', async () => { await model.createComment({ id: 'comment-1', - commentReq: comment, + commentReq: unifiedComment, createdDate, }); - expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "attributes": Object { - "comment": "a comment", - "created_at": "2023-04-07T12:18:36.941Z", - "created_by": Object { - "email": "damaged_raccoon@elastic.co", - "full_name": "Damaged Raccoon", - "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", - "username": "damaged_raccoon", - }, - "owner": "securitySolution", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - }, - "id": "comment-1", - "references": Array [ - Object { - "id": "mock-id-1", - "name": "associated-cases", - "type": "cases", - }, - ], - "refresh": true, - }, - ], - ] - `); + const [[createArgs]] = clientArgs.services.attachmentService.create.mock.calls; + + expect(createArgs.attributes).toMatchObject({ + type: COMMENT_ATTACHMENT_TYPE, + data: { content: 'a comment' }, + owner: 'securitySolution', + }); + expect(createArgs.id).toBe('comment-1'); + expect(createArgs.references).toEqual([ + { id: 'mock-id-1', name: 'associated-cases', type: 'cases' }, + ]); }); it('does not remove alerts not attached to the case', async () => { await model.createComment({ id: 'comment-1', - commentReq: alertComment, + commentReq: unifiedAlertComment, createdDate, }); - expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "attributes": Object { - "alertId": Array [ - "alert-id-1", - ], - "created_at": "2023-04-07T12:18:36.941Z", - "created_by": Object { - "email": "damaged_raccoon@elastic.co", - "full_name": "Damaged Raccoon", - "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", - "username": "damaged_raccoon", - }, - "index": Array [ - "alert-index-1", - ], - "owner": "securitySolution", - "pushed_at": null, - "pushed_by": null, - "rule": Object { - "id": "rule-id-1", - "name": "rule-name-1", - }, - "type": "alert", - "updated_at": null, - "updated_by": null, - }, - "id": "comment-1", - "references": Array [ - Object { - "id": "mock-id-1", - "name": "associated-cases", - "type": "cases", - }, - ], - "refresh": true, - }, - ], - ] - `); + const [[createArgs]] = clientArgs.services.attachmentService.create.mock.calls; + + expect(createArgs.attributes).toMatchObject({ + type: SECURITY_ALERT_ATTACHMENT_TYPE, + attachmentId: ['alert-id-1'], + metadata: { + index: ['alert-index-1'], + rule: { id: 'rule-id-1', name: 'rule-name-1' }, + }, + owner: 'securitySolution', + }); + expect(createArgs.id).toBe('comment-1'); + expect(createArgs.references).toEqual([ + { id: 'mock-id-1', name: 'associated-cases', type: 'cases' }, + ]); }); it('remove alerts attached to the case', async () => { await model.createComment({ id: 'comment-1', - commentReq: multipleAlert, + commentReq: unifiedMultipleAlert, createdDate, }); - expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "attributes": Object { - "alertId": Array [ - "test-id-3", - "test-id-5", - ], - "created_at": "2023-04-07T12:18:36.941Z", - "created_by": Object { - "email": "damaged_raccoon@elastic.co", - "full_name": "Damaged Raccoon", - "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", - "username": "damaged_raccoon", - }, - "index": Array [ - "test-index-3", - "test-index-5", - ], - "owner": "securitySolution", - "pushed_at": null, - "pushed_by": null, - "rule": Object { - "id": "rule-id-1", - "name": "rule-name-1", - }, - "type": "alert", - "updated_at": null, - "updated_by": null, - }, - "id": "comment-1", - "references": Array [ - Object { - "id": "mock-id-1", - "name": "associated-cases", - "type": "cases", - }, - ], - "refresh": true, - }, - ], - ] - `); + const [[createArgs]] = clientArgs.services.attachmentService.create.mock.calls; + + // test-id-4 is omitted because it is returned by getAllAlertIds, see the top of this file + expect(createArgs.attributes).toMatchObject({ + type: SECURITY_ALERT_ATTACHMENT_TYPE, + attachmentId: ['test-id-3', 'test-id-5'], + metadata: { + index: ['test-index-3', 'test-index-5'], + rule: { id: 'rule-id-1', name: 'rule-name-1' }, + }, + owner: 'securitySolution', + }); }); it('remove multiple alerts', async () => { @@ -222,52 +201,21 @@ describe('CaseCommentModel', () => { await model.createComment({ id: 'comment-1', - commentReq: multipleAlert, + commentReq: unifiedMultipleAlert, createdDate, }); - expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "attributes": Object { - "alertId": Array [ - "test-id-4", - ], - "created_at": "2023-04-07T12:18:36.941Z", - "created_by": Object { - "email": "damaged_raccoon@elastic.co", - "full_name": "Damaged Raccoon", - "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", - "username": "damaged_raccoon", - }, - "index": Array [ - "test-index-4", - ], - "owner": "securitySolution", - "pushed_at": null, - "pushed_by": null, - "rule": Object { - "id": "rule-id-1", - "name": "rule-name-1", - }, - "type": "alert", - "updated_at": null, - "updated_by": null, - }, - "id": "comment-1", - "references": Array [ - Object { - "id": "mock-id-1", - "name": "associated-cases", - "type": "cases", - }, - ], - "refresh": true, - }, - ], - ] - `); + const [[createArgs]] = clientArgs.services.attachmentService.create.mock.calls; + + expect(createArgs.attributes).toMatchObject({ + type: SECURITY_ALERT_ATTACHMENT_TYPE, + attachmentId: ['test-id-4'], + metadata: { + index: ['test-index-4'], + rule: { id: 'rule-id-1', name: 'rule-name-1' }, + }, + owner: 'securitySolution', + }); }); it('does not create attachments if all alerts are attached to the case', async () => { @@ -277,7 +225,7 @@ describe('CaseCommentModel', () => { await model.createComment({ id: 'comment-1', - commentReq: multipleAlert, + commentReq: unifiedMultipleAlert, createdDate, }); @@ -291,7 +239,7 @@ describe('CaseCommentModel', () => { await model.createComment({ id: 'comment-1', - commentReq: alertComment, + commentReq: unifiedAlertComment, createdDate, }); @@ -301,7 +249,7 @@ describe('CaseCommentModel', () => { it('partial updates the case', async () => { await model.createComment({ id: 'comment-1', - commentReq: comment, + commentReq: unifiedComment, createdDate, }); @@ -329,7 +277,7 @@ describe('CaseCommentModel', () => { await model.createComment({ id: 'comment-1', - commentReq: comment, + commentReq: unifiedComment, createdDate, }); @@ -358,7 +306,7 @@ describe('CaseCommentModel', () => { await model.createComment({ id: 'comment-1', - commentReq: alertComment, + commentReq: unifiedAlertComment, createdDate, }); @@ -381,7 +329,7 @@ describe('CaseCommentModel', () => { await expect( model.createComment({ id: 'comment-1', - commentReq: commentPersistableState, + commentReq: unifiedPersistableState, createdDate, }) ).rejects.toThrow( @@ -393,7 +341,7 @@ describe('CaseCommentModel', () => { await expect( model.createComment({ id: 'comment-1', - commentReq: commentExternalReference, + commentReq: unifiedExternalReference, createdDate, }) ).rejects.toThrow( @@ -407,7 +355,7 @@ describe('CaseCommentModel', () => { await expect( model.createComment({ id: 'comment-1', - commentReq: commentFileExternalReference, + commentReq: unifiedFileExternalReference, createdDate, }) ).resolves.not.toThrow(); @@ -422,36 +370,15 @@ describe('CaseCommentModel', () => { await expect( modelForClosedCase.createComment({ id: 'comment-1', - commentReq: alertComment, + commentReq: unifiedAlertComment, createdDate, }) - ).rejects.toThrow(); + ).rejects.toThrow('Alert cannot be attached to a closed case'); await expect( modelForClosedCase.createComment({ id: 'comment-1', - commentReq: eventComment, - createdDate, - }) - ).rejects.toThrow(); - }); - - it('throws if trying to add a unified (v2) event to a closed case', async () => { - clientArgs.services.caseService.getCase.mockResolvedValue(closedCase); - - const modelForClosedCase = await CaseCommentModel.create(closedCase.id, clientArgs); - - const unifiedEventComment = { - type: SECURITY_EVENT_ATTACHMENT_TYPE, - owner: SECURITY_SOLUTION_OWNER, - attachmentId: 'event-id-1', - metadata: { index: 'idx-1' }, - }; - - await expect( - modelForClosedCase.createComment({ - id: 'comment-1', - commentReq: unifiedEventComment as never, + commentReq: unifiedEventComment, createdDate, }) ).rejects.toThrow('Event cannot be attached to a closed case'); @@ -465,15 +392,15 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...comment, + ...unifiedComment, }, { id: 'comment-2', - ...alertComment, + ...unifiedAlertComment, }, { id: 'comment-3', - ...multipleAlert, + ...unifiedMultipleAlert, }, ], }); @@ -481,20 +408,23 @@ describe('CaseCommentModel', () => { const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; - const singleAlertCall = attachments[1] as SavedObject; - const multipleAlertsCall = attachments[2] as SavedObject; + const singleAlertCall = asUnifiedAttachmentSO(attachments[1]); + const multipleAlertsCall = asUnifiedAttachmentSO(attachments[2]); expect(attachments.length).toBe(3); - expect(attachments[0].attributes.type).toBe('user'); - expect(attachments[1].attributes.type).toBe('alert'); - expect(attachments[2].attributes.type).toBe('alert'); + expect(attachments[0].attributes.type).toBe(COMMENT_ATTACHMENT_TYPE); + expect(attachments[1].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); + expect(attachments[2].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); - expect(singleAlertCall.attributes.alertId).toEqual(['alert-id-1']); - expect(singleAlertCall.attributes.index).toEqual(['alert-index-1']); + expect(singleAlertCall.attributes.attachmentId).toEqual(['alert-id-1']); + expect(singleAlertCall.attributes.metadata.index).toEqual(['alert-index-1']); // test-id-4 is omitted because it is returned by getAllAlertIds, see the top of this file - expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); - expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); + expect(multipleAlertsCall.attributes.attachmentId).toEqual(['test-id-3', 'test-id-5']); + expect(multipleAlertsCall.attributes.metadata.index).toEqual([ + 'test-index-3', + 'test-index-5', + ]); }); it('does not remove events when filtering out duplicate alerts', async () => { @@ -502,15 +432,15 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...eventComment, + ...unifiedEventComment, }, { id: 'comment-2', - ...alertComment, + ...unifiedAlertComment, }, { id: 'comment-3', - ...multipleAlert, + ...unifiedMultipleAlert, }, ], }); @@ -518,46 +448,23 @@ describe('CaseCommentModel', () => { const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; - const singleAlertCall = attachments[1] as SavedObject; - const multipleAlertsCall = attachments[2] as SavedObject; + const singleAlertCall = asUnifiedAttachmentSO(attachments[1]); + const multipleAlertsCall = asUnifiedAttachmentSO(attachments[2]); expect(attachments.length).toBe(3); - expect(attachments[0].attributes.type).toBe('event'); - expect(attachments[1].attributes.type).toBe('alert'); - expect(attachments[2].attributes.type).toBe('alert'); + expect(attachments[0].attributes.type).toBe(SECURITY_EVENT_ATTACHMENT_TYPE); + expect(attachments[1].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); + expect(attachments[2].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); - expect(singleAlertCall.attributes.alertId).toEqual(['alert-id-1']); - expect(singleAlertCall.attributes.index).toEqual(['alert-index-1']); + expect(singleAlertCall.attributes.attachmentId).toEqual(['alert-id-1']); + expect(singleAlertCall.attributes.metadata.index).toEqual(['alert-index-1']); // test-id-4 is omitted because it is returned by getAllAlertIds, see the top of this file - expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); - expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); - }); - - it('drops only matching event ids from a multi-id legacy event attachment', async () => { - clientArgs.services.attachmentService.getter.getAllEventIds.mockResolvedValueOnce( - new Set(['event-id-2']) - ); - - await model.bulkCreate({ - attachments: [ - { - id: 'comment-1', - type: AttachmentType.event, - owner: SECURITY_SOLUTION_OWNER, - eventId: ['event-id-1', 'event-id-2', 'event-id-3'], - index: ['idx-1', 'idx-2', 'idx-3'], - }, - ], - }); - - const attachments = - clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; - - expect(attachments.length).toBe(1); - const eventCall = attachments[0] as SavedObject; - expect(eventCall.attributes.eventId).toEqual(['event-id-1', 'event-id-3']); - expect(eventCall.attributes.index).toEqual(['idx-1', 'idx-3']); + expect(multipleAlertsCall.attributes.attachmentId).toEqual(['test-id-3', 'test-id-5']); + expect(multipleAlertsCall.attributes.metadata.index).toEqual([ + 'test-index-3', + 'test-index-5', + ]); }); it('dedupes event ids that repeat within the same bulk create batch', async () => { @@ -565,17 +472,17 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - type: AttachmentType.event, + type: SECURITY_EVENT_ATTACHMENT_TYPE, owner: SECURITY_SOLUTION_OWNER, - eventId: ['event-id-1', 'event-id-2'], - index: ['idx-1', 'idx-2'], + attachmentId: ['event-id-1', 'event-id-2'], + metadata: { index: ['idx-1', 'idx-2'] }, }, { id: 'comment-2', - type: AttachmentType.event, + type: SECURITY_EVENT_ATTACHMENT_TYPE, owner: SECURITY_SOLUTION_OWNER, - eventId: ['event-id-2', 'event-id-3'], - index: ['idx-2', 'idx-3'], + attachmentId: ['event-id-2', 'event-id-3'], + metadata: { index: ['idx-2', 'idx-3'] }, }, ], }); @@ -584,17 +491,17 @@ describe('CaseCommentModel', () => { clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; expect(attachments.length).toBe(2); - const first = attachments[0] as SavedObject; - const second = attachments[1] as SavedObject; + const first = asUnifiedAttachmentSO(attachments[0]); + const second = asUnifiedAttachmentSO(attachments[1]); - expect(first.attributes.eventId).toEqual(['event-id-1', 'event-id-2']); - expect(first.attributes.index).toEqual(['idx-1', 'idx-2']); + expect(first.attributes.attachmentId).toEqual(['event-id-1', 'event-id-2']); + expect(first.attributes.metadata.index).toEqual(['idx-1', 'idx-2']); // event-id-2 is dropped from the second attachment because the first one already claimed it - expect(second.attributes.eventId).toEqual(['event-id-3']); - expect(second.attributes.index).toEqual(['idx-3']); + expect(second.attributes.attachmentId).toEqual(['event-id-3']); + expect(second.attributes.metadata.index).toEqual(['idx-3']); }); - it('drops the legacy event attachment entirely when every id is already attached to the case', async () => { + it('drops the unified event attachment entirely when every id is already attached to the case', async () => { clientArgs.services.attachmentService.getter.getAllEventIds.mockResolvedValueOnce( new Set(['event-id-1', 'event-id-2']) ); @@ -603,10 +510,10 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - type: AttachmentType.event, + type: SECURITY_EVENT_ATTACHMENT_TYPE, owner: SECURITY_SOLUTION_OWNER, - eventId: ['event-id-1', 'event-id-2'], - index: ['idx-1', 'idx-2'], + attachmentId: ['event-id-1', 'event-id-2'], + metadata: { index: ['idx-1', 'idx-2'] }, }, ], }); @@ -636,9 +543,7 @@ describe('CaseCommentModel', () => { clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; expect(attachments.length).toBe(1); - const unifiedCall = attachments[0] as unknown as { - attributes: { attachmentId: string[]; metadata: { index: string[] } }; - }; + const unifiedCall = asUnifiedAttachmentSO(attachments[0]); expect(unifiedCall.attributes.attachmentId).toEqual(['event-id-1', 'event-id-3']); expect(unifiedCall.attributes.metadata.index).toEqual(['idx-1', 'idx-3']); }); @@ -684,9 +589,7 @@ describe('CaseCommentModel', () => { clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; expect(attachments.length).toBe(1); - const unifiedCall = attachments[0] as unknown as { - attributes: { attachmentId: string[]; metadata: { index: string[] } }; - }; + const unifiedCall = asUnifiedAttachmentSO(attachments[0]); expect(unifiedCall.attributes.attachmentId).toEqual([ 'event-id-1', 'event-id-2', @@ -704,37 +607,20 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...alertComment, - }, - ], - }); - - const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0] - .attachments as Array>; - - expect(attachments.length).toBe(1); - expect(attachments[0].attributes.type).toBe('alert'); - expect(attachments[0].attributes.alertId).toEqual(['alert-id-1']); - expect(attachments[0].attributes.index).toEqual(['alert-index-1']); - }); - - it('remove alerts attached to the case', async () => { - await model.bulkCreate({ - attachments: [ - { - id: 'comment-1', - ...multipleAlert, + ...unifiedAlertComment, }, ], }); - const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0] - .attachments as Array>; + const attachments = + clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments.map( + asUnifiedAttachmentSO + ); expect(attachments.length).toBe(1); - expect(attachments[0].attributes.type).toBe('alert'); - expect(attachments[0].attributes.alertId).toEqual(['test-id-3', 'test-id-5']); - expect(attachments[0].attributes.index).toEqual(['test-index-3', 'test-index-5']); + expect(attachments[0].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); + expect(attachments[0].attributes.attachmentId).toEqual(['alert-id-1']); + expect(attachments[0].attributes.metadata.index).toEqual(['alert-index-1']); }); it('remove multiple alerts', async () => { @@ -746,46 +632,29 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...multipleAlert, + ...unifiedMultipleAlert, }, ], }); - const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0] - .attachments as Array>; + const attachments = + clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments.map( + asUnifiedAttachmentSO + ); expect(attachments.length).toBe(1); - expect(attachments[0].attributes.type).toBe('alert'); - expect(attachments[0].attributes.alertId).toEqual(['test-id-4']); - expect(attachments[0].attributes.index).toEqual(['test-index-4']); - }); - - it('does not create attachments if all alerts are attached to the case', async () => { - clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( - new Set(['test-id-3', 'test-id-4', 'test-id-5']) - ); - - await model.bulkCreate({ - attachments: [ - { - id: 'comment-1', - ...multipleAlert, - }, - ], - }); - - expect(clientArgs.services.attachmentService.bulkCreate).not.toHaveBeenCalled(); + expect(attachments[0].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); + expect(attachments[0].attributes.attachmentId).toEqual(['test-id-4']); + expect(attachments[0].attributes.metadata.index).toEqual(['test-index-4']); }); it('does not create attachments if the alert is attached to the case', async () => { clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( - new Set(['test-id-1']) + new Set(['alert-id-1']) ); - await model.createComment({ - id: 'comment-1', - commentReq: alertComment, - createdDate, + await model.bulkCreate({ + attachments: [{ id: 'comment-1', ...unifiedAlertComment }], }); expect(clientArgs.services.attachmentService.bulkCreate).not.toHaveBeenCalled(); @@ -796,23 +665,23 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...comment, + ...unifiedComment, }, { id: 'comment-2', - ...alertComment, + ...unifiedAlertComment, }, { id: 'comment-3', - ...alertComment, + ...unifiedAlertComment, }, { id: 'comment-4', - ...multipleAlert, + ...unifiedMultipleAlert, }, { id: 'comment-5', - ...multipleAlert, + ...unifiedMultipleAlert, }, ], }); @@ -820,19 +689,22 @@ describe('CaseCommentModel', () => { const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; - const singleAlertCall = attachments[1] as SavedObject; - const multipleAlertsCall = attachments[2] as SavedObject; + const singleAlertCall = asUnifiedAttachmentSO(attachments[1]); + const multipleAlertsCall = asUnifiedAttachmentSO(attachments[2]); expect(attachments.length).toBe(3); - expect(attachments[0].attributes.type).toBe('user'); - expect(attachments[1].attributes.type).toBe('alert'); - expect(attachments[2].attributes.type).toBe('alert'); + expect(attachments[0].attributes.type).toBe(COMMENT_ATTACHMENT_TYPE); + expect(attachments[1].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); + expect(attachments[2].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); - expect(singleAlertCall.attributes.alertId).toEqual(['alert-id-1']); - expect(singleAlertCall.attributes.index).toEqual(['alert-index-1']); + expect(singleAlertCall.attributes.attachmentId).toEqual(['alert-id-1']); + expect(singleAlertCall.attributes.metadata.index).toEqual(['alert-index-1']); - expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); - expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); + expect(multipleAlertsCall.attributes.attachmentId).toEqual(['test-id-3', 'test-id-5']); + expect(multipleAlertsCall.attributes.metadata.index).toEqual([ + 'test-index-3', + 'test-index-5', + ]); }); it('remove alerts from multiple attachments on the same request', async () => { @@ -840,23 +712,29 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...comment, + ...unifiedComment, }, { id: 'comment-2', - ...alertComment, + ...unifiedAlertComment, }, { id: 'comment-3', - ...multipleAlert, - alertId: ['alert-id-1', 'test-id-2'], - index: ['alert-index-1', 'test-index-2'], + ...unifiedMultipleAlert, + attachmentId: ['alert-id-1', 'test-id-2'], + metadata: { + ...unifiedMultipleAlert.metadata, + index: ['alert-index-1', 'test-index-2'], + }, }, { id: 'comment-4', - ...multipleAlert, - alertId: ['test-id-2', 'test-id-4', 'test-id-5'], - index: ['test-index-1', 'test-index-4', 'test-index-5'], + ...unifiedMultipleAlert, + attachmentId: ['test-id-2', 'test-id-4', 'test-id-5'], + metadata: { + ...unifiedMultipleAlert.metadata, + index: ['test-index-1', 'test-index-4', 'test-index-5'], + }, }, ], }); @@ -864,25 +742,25 @@ describe('CaseCommentModel', () => { const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; - const alertOne = attachments[1] as SavedObject; - const alertTwo = attachments[2] as SavedObject; - const alertThree = attachments[3] as SavedObject; + const alertOne = asUnifiedAttachmentSO(attachments[1]); + const alertTwo = asUnifiedAttachmentSO(attachments[2]); + const alertThree = asUnifiedAttachmentSO(attachments[3]); expect(attachments.length).toBe(4); - expect(attachments[0].attributes.type).toBe('user'); - expect(attachments[1].attributes.type).toBe('alert'); - expect(attachments[2].attributes.type).toBe('alert'); - expect(attachments[3].attributes.type).toBe('alert'); + expect(attachments[0].attributes.type).toBe(COMMENT_ATTACHMENT_TYPE); + expect(attachments[1].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); + expect(attachments[2].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); + expect(attachments[3].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); - expect(alertOne.attributes.alertId).toEqual(['alert-id-1']); - expect(alertOne.attributes.index).toEqual(['alert-index-1']); + expect(alertOne.attributes.attachmentId).toEqual(['alert-id-1']); + expect(alertOne.attributes.metadata.index).toEqual(['alert-index-1']); - expect(alertTwo.attributes.alertId).toEqual(['test-id-2']); - expect(alertTwo.attributes.index).toEqual(['test-index-2']); + expect(alertTwo.attributes.attachmentId).toEqual(['test-id-2']); + expect(alertTwo.attributes.metadata.index).toEqual(['test-index-2']); // test-id-4 is omitted because it is returned by getAllAlertIds, see the top of this file - expect(alertThree.attributes.alertId).toEqual(['test-id-5']); - expect(alertThree.attributes.index).toEqual(['test-index-5']); + expect(alertThree.attributes.attachmentId).toEqual(['test-id-5']); + expect(alertThree.attributes.metadata.index).toEqual(['test-index-5']); }); it('filters duplicate ids from unified (v2) alert attachments while preserving order', async () => { @@ -890,16 +768,6 @@ describe('CaseCommentModel', () => { new Set(['test-id-4']) ); - const unifiedMultipleAlert = { - type: SECURITY_ALERT_ATTACHMENT_TYPE, - owner: SECURITY_SOLUTION_OWNER, - attachmentId: ['test-id-3', 'test-id-4', 'test-id-5'], - metadata: { - index: ['test-index-3', 'test-index-4', 'test-index-5'], - rule: { id: 'rule-id-1', name: 'rule-name-1' }, - }, - } as unknown as typeof multipleAlert & { attachmentId: string[] }; - await model.bulkCreate({ attachments: [ { @@ -913,9 +781,7 @@ describe('CaseCommentModel', () => { clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; expect(attachments.length).toBe(1); - const unifiedCall = attachments[0] as unknown as { - attributes: { attachmentId: string[]; metadata: { index: string[] } }; - }; + const unifiedCall = asUnifiedAttachmentSO(attachments[0]); // test-id-4 was already on the case → must be filtered out from both attachmentId and metadata.index expect(unifiedCall.attributes.attachmentId).toEqual(['test-id-3', 'test-id-5']); expect(unifiedCall.attributes.metadata.index).toEqual(['test-index-3', 'test-index-5']); @@ -951,15 +817,15 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...comment, + ...unifiedComment, }, { id: 'comment-2', - ...alertComment, + ...unifiedAlertComment, }, { id: 'comment-3', - ...multipleAlert, + ...unifiedMultipleAlert, }, ], }); @@ -967,14 +833,17 @@ describe('CaseCommentModel', () => { const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; - const multipleAlertsCall = attachments[1] as SavedObject; + const multipleAlertsCall = asUnifiedAttachmentSO(attachments[1]); expect(attachments.length).toBe(2); - expect(attachments[0].attributes.type).toBe('user'); - expect(attachments[1].attributes.type).toBe('alert'); + expect(attachments[0].attributes.type).toBe(COMMENT_ATTACHMENT_TYPE); + expect(attachments[1].attributes.type).toBe(SECURITY_ALERT_ATTACHMENT_TYPE); - expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); - expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); + expect(multipleAlertsCall.attributes.attachmentId).toEqual(['test-id-3', 'test-id-5']); + expect(multipleAlertsCall.attributes.metadata.index).toEqual([ + 'test-index-3', + 'test-index-5', + ]); }); it('partial updates the case', async () => { @@ -982,7 +851,7 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'comment-1', - ...comment, + ...unifiedComment, }, ], }); @@ -1014,27 +883,27 @@ describe('CaseCommentModel', () => { attachments: [ { id: 'mock-comment-1', - ...comment, + ...unifiedComment, }, { id: 'mock-comment-2', - ...comment, + ...unifiedComment, }, { id: 'mock-comment-3', - ...comment, + ...unifiedComment, }, { id: 'mock-comment-4', - ...alertComment, + ...unifiedAlertComment, }, { id: 'mock-comment-5', - ...alertComment, + ...unifiedAlertComment, }, { id: 'mock-comment-6', - ...alertComment, + ...unifiedAlertComment, }, ], }); @@ -1057,7 +926,7 @@ describe('CaseCommentModel', () => { it('throws if limit is reached when creating persistable state attachment', async () => { await expect( model.bulkCreate({ - attachments: [commentPersistableState], + attachments: [{ id: 'comment-1', ...unifiedPersistableState }], }) ).rejects.toThrow( `Case has reached the maximum allowed number (${MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES}) of attached persistable state and external reference attachments.` @@ -1065,7 +934,9 @@ describe('CaseCommentModel', () => { }); it('throws if limit is reached when creating external reference', async () => { - await expect(model.bulkCreate({ attachments: [commentExternalReference] })).rejects.toThrow( + await expect( + model.bulkCreate({ attachments: [{ id: 'comment-1', ...unifiedExternalReference }] }) + ).rejects.toThrow( `Case has reached the maximum allowed number (${MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES}) of attached persistable state and external reference attachments.` ); }); @@ -1075,7 +946,7 @@ describe('CaseCommentModel', () => { await expect( model.bulkCreate({ - attachments: [commentFileExternalReference], + attachments: [{ id: 'comment-1', ...unifiedFileExternalReference }], }) ).resolves.not.toThrow(); }); @@ -1088,9 +959,7 @@ describe('CaseCommentModel', () => { updateRequest: { id: 'comment-id', version: 'comment-version', - type: AttachmentType.user, - comment: 'my updated comment', - owner: SECURITY_SOLUTION_OWNER, + ...unifiedComment, }, updatedAt: createdDate, owner: SECURITY_SOLUTION_OWNER, @@ -1120,9 +989,7 @@ describe('CaseCommentModel', () => { updateRequest: { id: 'comment-id', version: 'comment-version', - type: AttachmentType.user, - comment: 'my updated comment', - owner: SECURITY_SOLUTION_OWNER, + ...unifiedComment, }, updatedAt: createdDate, owner: SECURITY_SOLUTION_OWNER, @@ -1153,11 +1020,7 @@ describe('CaseCommentModel', () => { 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, + ...unifiedAlertComment, }, updatedAt: createdDate, owner: SECURITY_SOLUTION_OWNER, @@ -1169,20 +1032,22 @@ describe('CaseCommentModel', () => { expect(args.updatedAttributes.total_comments).toEqual(1); }); - it('does not treat a legacy actions payload as a comment attachment', async () => { - // `actions` has its own `comment` field but is not Lens-reference-eligible + it('does not treat a unified security.endpoint payload as a comment attachment', async () => { + // `security.endpoint` also has a `data.content` field but is not Lens-reference-eligible + // (only the unified `comment` type is) — guards against a too-loose `data.content` check. await expect( model.updateComment({ updateRequest: { id: 'comment-id', version: 'comment-version', - type: AttachmentType.actions, - comment: 'Isolating this for investigation', - actions: { + type: 'security.endpoint', + owner: SECURITY_SOLUTION_OWNER, + attachmentId: 'legacy-actions', + data: { content: 'Isolating this for investigation' }, + metadata: { + command: 'isolate', targets: [{ endpointId: '123', hostname: 'windows-host-1' }], - type: 'isolate', }, - owner: SECURITY_SOLUTION_OWNER, }, updatedAt: createdDate, owner: SECURITY_SOLUTION_OWNER, @@ -1202,7 +1067,7 @@ describe('CaseCommentModel', () => { await expect( model.createComment({ id: 'comment-1', - commentReq: alertComment, + commentReq: unifiedAlertComment, createdDate, }) ).rejects.toThrow('not authorized'); @@ -1218,8 +1083,8 @@ describe('CaseCommentModel', () => { await expect( model.bulkCreate({ attachments: [ - { id: 'comment-1', ...comment }, - { id: 'comment-2', ...alertComment }, + { id: 'comment-1', ...unifiedComment }, + { id: 'comment-2', ...unifiedAlertComment }, ], }) ).rejects.toThrow('not authorized'); @@ -1235,7 +1100,7 @@ describe('CaseCommentModel', () => { await expect( model.createComment({ id: 'comment-1', - commentReq: eventComment, + commentReq: unifiedEventComment, createdDate, }) ).rejects.toThrow('document not found'); @@ -1253,7 +1118,7 @@ describe('CaseCommentModel', () => { await expect( model.bulkCreate({ - attachments: [{ id: 'comment-1', ...eventComment }], + attachments: [{ id: 'comment-1', ...unifiedEventComment }], }) ).rejects.toThrow('document not found'); @@ -1262,7 +1127,7 @@ describe('CaseCommentModel', () => { it('does not call ensureDocumentsExist for a batch with no event attachments', async () => { await model.bulkCreate({ - attachments: [{ id: 'comment-1', ...alertComment }], + attachments: [{ id: 'comment-1', ...unifiedAlertComment }], }); expect(clientArgs.services.alertsService.ensureDocumentsExist).not.toHaveBeenCalled(); @@ -1270,7 +1135,7 @@ describe('CaseCommentModel', () => { it('does not call ensureAlertsAuthorized for a batch with no alert attachments', async () => { await model.bulkCreate({ - attachments: [{ id: 'comment-1', ...eventComment }], + attachments: [{ id: 'comment-1', ...unifiedEventComment }], }); expect(clientArgs.services.alertsService.ensureAlertsAuthorized).not.toHaveBeenCalled(); @@ -1279,7 +1144,7 @@ describe('CaseCommentModel', () => { it('validates alert authorization before the saved object is created', async () => { await model.createComment({ id: 'comment-1', - commentReq: alertComment, + commentReq: unifiedAlertComment, createdDate, }); diff --git a/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.ts b/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.ts index ba23757fee905..fc4d22faafb9f 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/models/case_with_comments.ts @@ -14,19 +14,13 @@ import type { SavedObjectsFindResponse, } from '@kbn/core/server'; import { isSavedObjectErrorResult } from '@kbn/core/server'; -import { - isLegacyAttachmentRequest, - isUnifiedAttachmentRequest, - isAlertAttachmentType, - isEventAttachmentType, -} from '../../../common/utils/attachments'; +import { isAlertAttachmentType, isEventAttachmentType } from '../../../common/utils/attachments'; import type { AttachmentAttributes, Case } from '../../../common/types/domain'; import { CaseRt, CaseStatuses, UserActionActions, UserActionTypes, - AttachmentType, } from '../../../common/types/domain'; import { CASE_SAVED_OBJECT, MAX_DOCS_PER_PAGE } from '../../../common/constants'; @@ -43,40 +37,27 @@ import { flattenAttachmentSavedObjects, transformNewComment, getOrUpdateLensReferences, - isCommentRequestTypeAlert, getAlertInfoFromComments, getEventInfoFromComments, getIDsAndIndicesAsArrays, - isCommentRequestTypeEvent, countEventsForID, } from '../utils'; -import { - extractCommentContent, - isLegacyPayloadCommentAttachment, - isUnifiedPayloadCommentAttachment, -} from '../attachments/comment'; -import type { - AttachmentRequest, - AttachmentPatchRequestV2, - AttachmentRequestV2, -} from '../../../common/types/api'; +import { extractCommentContent, isUnifiedPayloadCommentAttachment } from '../attachments/comment'; import type { AttachmentAttributesV2, UnifiedAttachmentPayload, + UnifiedReferenceAttachmentPayload, } from '../../../common/types/domain/attachment/v2'; type CaseCommentModelParams = Omit; -type CommentRequestWithId = Array<{ id: string } & (AttachmentRequest | UnifiedAttachmentPayload)>; +type CommentRequestWithId = Array<{ id: string } & UnifiedAttachmentPayload>; /** - * Only comment attachments (legacy `user` / unified `comment`) are Lens-reference-eligible. - * A generic `'comment' in payload` check would also match legacy `actions`, which has its - * own unrelated `comment` field. + * Only comment attachments (unified `comment`) are Lens-reference-eligible. + * A generic `'comment' in payload` check would also match `security.endpoint`, which has + * its own unrelated `data.content` field. */ -const getCommentTextFromPayload = (payload: AttachmentRequestV2): string | undefined => { - if (isLegacyPayloadCommentAttachment(payload)) { - return payload.comment; - } +const getCommentTextFromPayload = (payload: UnifiedAttachmentPayload): string | undefined => { if (isUnifiedPayloadCommentAttachment(payload)) { return payload.data.content; } @@ -118,7 +99,7 @@ export class CaseCommentModel { updatedAt, owner, }: { - updateRequest: AttachmentPatchRequestV2; + updateRequest: UnifiedAttachmentPayload & { id: string; version: string }; updatedAt: string; owner: string; }): Promise { @@ -136,9 +117,7 @@ export class CaseCommentModel { refresh: false, }; - const patchCommentText = getCommentTextFromPayload( - queryRestAttributes as AttachmentRequestV2 - ); + const patchCommentText = getCommentTextFromPayload(queryRestAttributes); if (patchCommentText != null) { const currentComment = await this.params.services.attachmentService.getter.get({ @@ -261,7 +240,7 @@ export class CaseCommentModel { private async createUpdateCommentUserAction( comment: SavedObjectsUpdateResponse, - updateRequest: AttachmentPatchRequestV2, + updateRequest: UnifiedAttachmentPayload & { id: string; version: string }, owner: string ) { const { id, version, ...queryRestAttributes } = updateRequest; @@ -289,7 +268,7 @@ export class CaseCommentModel { id, }: { createdDate: string; - commentReq: AttachmentRequestV2; + commentReq: UnifiedAttachmentPayload; id: string; }): Promise { try { @@ -347,28 +326,18 @@ export class CaseCommentModel { const removeItemsByPosition = (items: string[], positionsToRemove: number[]): string[] => items.filter((_, itemIndex) => !positionsToRemove.some((position) => position === itemIndex)); - const assertIdsAndIndicesHaveMatchingLengths = (ids: string[], indices: string[]): void => { - if (ids.length !== indices.length) { - throw Boom.badRequest( - `attachmentId and metadata.index must have matching lengths. Received attachmentId.length=${ids.length} and metadata.index.length=${indices.length}.` - ); - } - }; - const dedupedAttachments: CommentRequestWithId = []; const idsAlreadySeen = new Set(); - // Dedup helper for unified (v2) alert/event attachments. The unified contract for + // Dedup helper for unified alert/event attachments. The unified contract for // metadata.index is "scalar broadcast OR 1-to-1 array of matching length"; an array // whose length does not match attachmentId has no sensible interpretation and is - // rejected, mirroring the legacy paired-array strictness. - const dedupeUnifiedAttachment = < - T extends { attachmentId: string | string[]; metadata?: unknown } - >( + // rejected. + const dedupeUnifiedAttachment = ( attachment: T, idsAlreadyInCase: Set ): T | undefined => { - const { ids } = getIDsAndIndicesAsArrays(attachment as unknown as AttachmentRequestV2); + const { ids } = getIDsAndIndicesAsArrays(attachment); const existingMetadata = attachment.metadata && typeof attachment.metadata === 'object' ? (attachment.metadata as Record) @@ -434,77 +403,24 @@ export class CaseCommentModel { attachments.forEach((attachment) => { if (isAlertAttachmentType(attachment.type)) { - if (isLegacyAttachmentRequest(attachment) && isCommentRequestTypeAlert(attachment)) { - const { ids, indices } = getIDsAndIndicesAsArrays(attachment); - const idPositionsThatAlreadyExistInCase: number[] = []; - - ids.forEach((id, index) => { - if (alertsAttachedToCase.has(id) || idsAlreadySeen.has(id)) { - idPositionsThatAlreadyExistInCase.push(index); - } - - idsAlreadySeen.add(id); - }); - - assertIdsAndIndicesHaveMatchingLengths(ids, indices); - - const alertIdsNotAlreadyAttachedToCase = removeItemsByPosition( - ids, - idPositionsThatAlreadyExistInCase - ); - const alertIndicesNotAlreadyAttachedToCase = removeItemsByPosition( - indices, - idPositionsThatAlreadyExistInCase - ); - - if (alertIdsNotAlreadyAttachedToCase.length > 0) { - dedupedAttachments.push({ - ...attachment, - alertId: alertIdsNotAlreadyAttachedToCase, - index: alertIndicesNotAlreadyAttachedToCase, - }); - } - } else if ('attachmentId' in attachment) { - const deduped = dedupeUnifiedAttachment(attachment, alertsAttachedToCase); - if (deduped) { - dedupedAttachments.push(deduped); - } + if (!('attachmentId' in attachment)) { + throw Boom.badRequest(`Alert attachment is missing required field 'attachmentId'`); + } + const deduped = dedupeUnifiedAttachment(attachment, alertsAttachedToCase); + if (deduped) { + dedupedAttachments.push(deduped); } return; } if (isEventAttachmentType(attachment.type)) { - if (isLegacyAttachmentRequest(attachment) && isCommentRequestTypeEvent(attachment)) { - const { ids, indices } = getIDsAndIndicesAsArrays(attachment); - const idPositionsThatAlreadyExistInCase: number[] = []; - - ids.forEach((id, index) => { - if (eventsAttachedToCase.has(id) || idsAlreadySeen.has(id)) { - idPositionsThatAlreadyExistInCase.push(index); - } - - idsAlreadySeen.add(id); - }); - - assertIdsAndIndicesHaveMatchingLengths(ids, indices); - - const newIds = removeItemsByPosition(ids, idPositionsThatAlreadyExistInCase); - const newIndices = removeItemsByPosition(indices, idPositionsThatAlreadyExistInCase); - - if (newIds.length > 0) { - dedupedAttachments.push({ - ...attachment, - eventId: newIds, - index: newIndices, - }); - } - } else if ('attachmentId' in attachment) { - const deduped = dedupeUnifiedAttachment(attachment, eventsAttachedToCase); - if (deduped) { - dedupedAttachments.push(deduped); - } + if (!('attachmentId' in attachment)) { + throw Boom.badRequest(`Event attachment is missing required field 'attachmentId'`); + } + const deduped = dedupeUnifiedAttachment(attachment, eventsAttachedToCase); + if (deduped) { + dedupedAttachments.push(deduped); } - return; } @@ -514,7 +430,7 @@ export class CaseCommentModel { return dedupedAttachments; } - private async validateCreateCommentRequest(req: Array) { + private async validateCreateCommentRequest(req: Array) { if (this.caseInfo.attributes.status === CaseStatuses.closed) { const hasAlertsInRequest = req.some((a) => isAlertAttachmentType(a.type)); @@ -554,27 +470,13 @@ export class CaseCommentModel { ]; } - private getCommentReferences(commentReq: AttachmentRequestV2) { + private getCommentReferences(commentReq: UnifiedAttachmentPayload) { let references: SavedObjectReference[] = []; - if ( - isLegacyAttachmentRequest(commentReq) && - commentReq.type === AttachmentType.user && - commentReq?.comment - ) { - const commentStringReferences = getOrUpdateLensReferences( - this.params.lensEmbeddableFactory, - commentReq.comment - ); - references = [...references, ...commentStringReferences]; - } else if ( - isUnifiedAttachmentRequest(commentReq) && - commentReq.type === 'comment' && - commentReq.data?.content - ) { + if (isUnifiedPayloadCommentAttachment(commentReq)) { const commentStringReferences = getOrUpdateLensReferences( this.params.lensEmbeddableFactory, - commentReq.data?.content as string + commentReq.data.content ); references = [...references, ...commentStringReferences]; } @@ -586,7 +488,7 @@ export class CaseCommentModel { * Validates alert/event attachments before the saved object is persisted, so a failure here * never leaves an already-created attachment on the case. */ - private async ensureIndexedAttachmentsValid(attachments: AttachmentRequestV2[]) { + private async ensureIndexedAttachmentsValid(attachments: UnifiedAttachmentPayload[]) { const alertAttachments = attachments.filter((a) => isAlertAttachmentType(a.type)); const alerts = getAlertInfoFromComments(alertAttachments, true); @@ -602,7 +504,7 @@ export class CaseCommentModel { } } - private async handleAlertComments(attachments: AttachmentRequestV2[]) { + private async handleAlertComments(attachments: UnifiedAttachmentPayload[]) { const alertAttachments = attachments.filter((a) => isAlertAttachmentType(a.type)); const alerts = getAlertInfoFromComments(alertAttachments); @@ -634,7 +536,7 @@ export class CaseCommentModel { private async createCommentUserAction( comment: SavedObject, - req: AttachmentRequestV2 + req: UnifiedAttachmentPayload ) { await this.params.services.userActionService.creator.createUserAction({ userAction: { @@ -654,7 +556,7 @@ export class CaseCommentModel { private async bulkCreateCommentUserAction( attachments: Array< - { id: string; savedObjectType: AttachmentSavedObjectType } & AttachmentRequestV2 + { id: string; savedObjectType: AttachmentSavedObjectType } & UnifiedAttachmentPayload > ) { await this.params.services.userActionService.creator.bulkCreateAttachmentCreation({ diff --git a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts index 60e1488d894a3..d5d20146a8360 100644 --- a/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts +++ b/x-pack/platform/plugins/shared/cases/server/connectors/cases/cases_connector_executor.ts @@ -30,7 +30,8 @@ import { import { COMMENT_ATTACHMENT_TYPE } from '../../../common/constants/attachments'; import { toUnifiedAttachmentType } from '../../../common/utils/attachments'; import { getCaseSettings } from '../../../common/utils/case_settings'; -import type { AttachmentRequestV2, BulkCreateCasesRequest } from '../../../common/types/api'; +import type { BulkCreateCasesRequest } from '../../../common/types/api'; +import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; import type { Case, CaseSeverity } from '../../../common'; import { ConnectorTypes, AttachmentType } from '../../../common'; import { INITIAL_ORACLE_RECORD_COUNTER, MAX_CONCURRENT_ES_REQUEST } from './constants'; @@ -1344,7 +1345,7 @@ export class CasesConnectorExecutor { const bulkCreateAlertsRequest: BulkCreateAlertsReq[] = casesUnderAlertLimit.map( ({ theCase, alerts, comments }) => { - const extraComments: AttachmentRequestV2[] = + const extraComments: UnifiedAttachmentPayload[] = comments?.map((comment) => ({ type: COMMENT_ATTACHMENT_TYPE, data: { content: comment }, @@ -1366,7 +1367,7 @@ export class CasesConnectorExecutor { { alertIds: [], alertIndices: [] } ); - const alertAttachment: AttachmentRequestV2 = { + const alertAttachment: UnifiedAttachmentPayload = { type: toUnifiedAttachmentType(AttachmentType.alert, theCase.owner), attachmentId: alertIds, metadata: { index: alertIndices, rule: rulePayload }, @@ -1387,15 +1388,9 @@ export class CasesConnectorExecutor { */ async (req: BulkCreateAlertsReq) => { if (this.logger.isLevelEnabled('debug')) { - const attachmentIdsForLogging = req.attachments.flatMap((attachment) => { - if ('alertId' in attachment) { - return toStringArray(attachment.alertId); - } - if ('attachmentId' in attachment) { - return toStringArray(attachment.attachmentId); - } - return []; - }); + const attachmentIdsForLogging = req.attachments.flatMap((attachment) => + 'attachmentId' in attachment ? toStringArray(attachment.attachmentId) : [] + ); this.logger.debug( `[CasesConnector][CasesConnectorExecutor][attachAlertsToCases] Attaching ${req.attachments.length} alerts to case with ID ${req.caseId}`, diff --git a/x-pack/platform/plugins/shared/cases/server/mocks.ts b/x-pack/platform/plugins/shared/cases/server/mocks.ts index e7c4e7ce36266..0e5eb8bcab24b 100644 --- a/x-pack/platform/plugins/shared/cases/server/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/server/mocks.ts @@ -590,7 +590,13 @@ export const mockCaseUnifiedAttachments: Array + const hasUnifiedOnlyAttachment = rawAttachments.some((attachment) => isUnifiedOnlyAttachment(attachment) ); + // The client accepts unified payloads only; convert the mixed wire body here. + const attachments = rawAttachments.map((attachment) => + toUnifiedAttachmentRequest(attachment) + ); const created: caseDomainV1.Case = await casesClient.attachments.bulkCreate({ caseId, attachments, diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_get_attachments.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_get_attachments.ts index 01378850cd0ca..e43455ac22b33 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_get_attachments.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_get_attachments.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import { BulkGetAttachmentsRequestRt } from '../../../../common/types/api/attachment/v1'; import { decodeWithExcessOrThrow } from '../../../common/runtime_types'; -import type { attachmentApiV2 } from '../../../../common/types/api'; +import type { attachmentApiV2, attachmentApiV2Union } from '../../../../common/types/api'; import { INTERNAL_BULK_GET_ATTACHMENTS_URL } from '../../../../common/constants'; import { createCaseError } from '../../../common/error'; @@ -38,10 +38,11 @@ export const bulkGetAttachmentsRoute = createCasesRoute({ BulkGetAttachmentsRequestRt )(request.body); - const res: attachmentApiV2.BulkGetAttachmentsResponseV2 = await client.attachments.bulkGet({ - caseID: request.params.case_id, - savedObjectIds: requestBody.ids, - }); + const res: attachmentApiV2Union.BulkGetAttachmentsResponseV2 = + await client.attachments.bulkGet({ + caseID: request.params.case_id, + savedObjectIds: requestBody.ids, + }); return response.ok({ body: res, diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/internal/find_user_actions.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/internal/find_user_actions.ts index 05b2ff74854c6..116e770101b34 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/internal/find_user_actions.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/internal/find_user_actions.ts @@ -9,7 +9,7 @@ import { castArray } from 'lodash'; import { schema } from '@kbn/config-schema'; import { isCommentUserAction } from '../../../../common/utils/user_actions'; -import type { attachmentApiV2, userActionApiV1 } from '../../../../common/types/api'; +import type { attachmentApiV2Union, userActionApiV1 } from '../../../../common/types/api'; import { UserActionInternalFindRequestRt } from '../../../../common/types/api'; import { INTERNAL_CASE_FIND_USER_ACTIONS_URL } from '../../../../common/constants'; import { createCaseError } from '../../../common/error'; @@ -58,7 +58,7 @@ export const findUserActionsRoute = createCasesRoute({ } const commentIds = Array.from(uniqueCommentIds); - let attachmentRes: attachmentApiV2.BulkGetAttachmentsResponseV2 = { + let attachmentRes: attachmentApiV2Union.BulkGetAttachmentsResponseV2 = { attachments: [], errors: [], }; @@ -70,8 +70,8 @@ export const findUserActionsRoute = createCasesRoute({ }); } - // Internal-only field; kept unified on purpose (see the "should return - // latest attachments" FTR test). + // Historical attachments may predate migration and still be legacy-shaped + // (see `latestAttachments: AttachmentsV2` in UserActionInternalFindResponse). const res: userActionApiV1.UserActionInternalFindResponse = { ...userActionsResponse, latestAttachments: attachmentRes.attachments, diff --git a/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_alerts.ts b/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_alerts.ts index 2181cbde06477..a2e9ea9d341a1 100644 --- a/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_alerts.ts +++ b/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_alerts.ts @@ -12,7 +12,7 @@ import { type AddAlertsStepInput, } from '../../../common/workflows/steps/add_alerts'; import { LEGACY_ALERT_TYPE } from '../../../common/constants/attachments'; -import type { AttachmentRequestV2 } from '../../../common/types/api'; +import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; import { toLegacyCaseResponse } from '../../common/attachments'; import type { CasesClient } from '../../client'; import { createCasesStepHandler, safeParseCaseForWorkflowOutput, withCaseOwner } from './utils'; @@ -51,7 +51,7 @@ export const addAlertsStepDefinition = ( ...addAlertsStepCommonDefinition, handler: createCasesStepHandler(getCasesClient, async (client, input: AddAlertsStepInput) => { return withCaseOwner(client, input.case_id, async (owner) => { - const attachments: AttachmentRequestV2[] = [ + const attachments: UnifiedAttachmentPayload[] = [ ...groupAlertsByRule(input.alerts).values(), ].map((group) => { const [first] = group; diff --git a/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_attachments.ts b/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_attachments.ts index f42673864c3bd..d03029f003f68 100644 --- a/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_attachments.ts +++ b/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_attachments.ts @@ -11,7 +11,7 @@ import { buildAddAttachmentsStepCommonDefinition, type AddAttachmentsStepInput, } from '../../../common/workflows/steps/add_attachments'; -import type { BulkCreateAttachmentsRequestV2 } from '../../../common/types/api'; +import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; import { toLegacyCaseResponse } from '../../common/attachments'; import type { UnifiedAttachmentTypeRegistry } from '../../attachment_framework/unified_attachment_registry'; import type { CasesClient } from '../../client'; @@ -48,7 +48,7 @@ export const addAttachmentsStepDefinition = ( const attachments = input.attachments.map((attachment) => ({ ...(attachment as Record), owner, - })) as BulkCreateAttachmentsRequestV2; + })) as UnifiedAttachmentPayload[]; const updatedCase = await client.attachments.bulkCreate({ caseId: input.case_id, diff --git a/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_events.ts b/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_events.ts index cc60c756d5b19..78d3a79b96614 100644 --- a/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_events.ts +++ b/x-pack/platform/plugins/shared/cases/server/workflows/steps/add_events.ts @@ -11,7 +11,7 @@ import { addEventsStepCommonDefinition, type AddEventsStepInput, } from '../../../common/workflows/steps/add_events'; -import type { AttachmentRequestV2 } from '../../../common/types/api'; +import type { UnifiedAttachmentPayload } from '../../../common/types/domain/attachment/v2'; import { toLegacyCaseResponse } from '../../common/attachments'; import type { CasesClient } from '../../client'; import { createCasesStepHandler, safeParseCaseForWorkflowOutput, withCaseOwner } from './utils'; @@ -41,7 +41,7 @@ export const addEventsStepDefinition = ( ...addEventsStepCommonDefinition, handler: createCasesStepHandler(getCasesClient, async (client, input: AddEventsStepInput) => { return withCaseOwner(client, input.case_id, async (owner) => { - const attachments: AttachmentRequestV2[] = [ + const attachments: UnifiedAttachmentPayload[] = [ ...groupEventsByIndex(input.events).values(), ].map((group) => ({ type: toUnifiedAttachmentType(LEGACY_EVENT_TYPE, owner), diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts index 9bc21dbb052dd..52db18cc1b1c2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts @@ -11,7 +11,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { CasesClient } from '@kbn/cases-plugin/server'; import type { Logger } from '@kbn/logging'; import { v4 as uuidv4 } from 'uuid'; -import type { BulkCreateAttachmentsRequestV2 } from '@kbn/cases-plugin/common/types/api/attachment/v2'; +import type { BulkCreateUnifiedAttachmentsRequest } from '@kbn/cases-plugin/common/types/api/attachment/v2'; import { i18n } from '@kbn/i18n'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { PackagePolicy } from '@kbn/fleet-plugin/common'; @@ -418,7 +418,7 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient agentType: this.agentType, })); - const attachments: BulkCreateAttachmentsRequestV2 = [ + const attachments: BulkCreateUnifiedAttachmentsRequest = [ { type: SECURITY_ENDPOINT_ATTACHMENT_TYPE, attachmentId: actionId,