Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@

export * from './v1';
export * from './v2';
export * from './v2_union';
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { AttachmentType } from '../../domain/attachment/v1';
import { AttachmentRequestRtV2, BulkCreateAttachmentsRequestRtV2 } from './v2';
import { AttachmentRequestRtV2, BulkCreateAttachmentsRequestRtV2 } from './v2_union';
import {
AttachmentRequestSchemaV2,
BulkCreateAttachmentsRequestSchemaV2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,34 @@
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, UnifiedAttachmentRt } from '../../domain/attachment/v2';
import { limitedArraySchema } from '../../../schema';

// Same shape in v1 and v2 (just saved object ids); re-exported under the V2
// alias for attachmentApiV2 namespace completeness.
export type { BulkGetAttachmentsRequest as BulkGetAttachmentsRequestV2 };

// --- Unified-only: no legacy (v1) form, no wire back-compat to preserve.
// Version-spanning (v1 ∪ unified) types live in ./v2_union (attachmentApiV2Union) ---

export const UnifiedAttachmentPatchRequestRt = rt.intersection([
UnifiedAttachmentPayloadRt,
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,
// Unified-only bulk payload. Client/service accept only unified; the route
// converts the mixed wire body before calling the client.
export const BulkCreateUnifiedAttachmentsRequestRt = limitedArraySchema({
codec: UnifiedAttachmentPayloadRt,
min: 0,
max: MAX_BULK_CREATE_ATTACHMENTS,
fieldName: 'attachments',
});

export const BulkGetAttachmentsResponseRtV2 = rt.strict({
attachments: AttachmentsRtV2,
// Internal route only, no legacy wire contract to preserve. The client is
// unified-only (post mode-removal), so the response is unified-only too.
export const BulkGetUnifiedAttachmentsResponseRt = rt.strict({
attachments: rt.array(UnifiedAttachmentRt),
errors: rt.array(
rt.strict({
error: rt.string,
Expand All @@ -62,8 +46,9 @@ export const BulkGetAttachmentsResponseRtV2 = rt.strict({
),
});

export type AttachmentRequestV2 = rt.TypeOf<typeof AttachmentRequestRtV2>;
export type AttachmentPatchRequestV2 = rt.TypeOf<typeof AttachmentPatchRequestRtV2>;
export type AttachmentsFindResponseV2 = rt.TypeOf<typeof AttachmentsFindResponseRtV2>;
export type BulkCreateAttachmentsRequestV2 = rt.TypeOf<typeof BulkCreateAttachmentsRequestRtV2>;
export type BulkGetAttachmentsResponseV2 = rt.TypeOf<typeof BulkGetAttachmentsResponseRtV2>;
export type BulkCreateUnifiedAttachmentsRequest = rt.TypeOf<
typeof BulkCreateUnifiedAttachmentsRequestRt
>;
export type BulkGetUnifiedAttachmentsResponse = rt.TypeOf<
typeof BulkGetUnifiedAttachmentsResponseRt
>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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, UnifiedAttachmentPayloadRt } from '../../domain/attachment/v2';
import { limitedArraySchema } from '../../../schema';
import { UnifiedAttachmentPatchRequestRt } from './v2';

// --- V2 union: version-spanning (v1 legacy ∪ unified). Used at read/response
// boundaries and any write boundary that still accepts both wire shapes.
// Unified-only types live in ./v2 (attachmentApiV2) ---

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,
min: 0,
max: MAX_BULK_CREATE_ATTACHMENTS,
fieldName: 'attachments',
});

export type AttachmentRequestV2 = rt.TypeOf<typeof AttachmentRequestRtV2>;
export type AttachmentPatchRequestV2 = rt.TypeOf<typeof AttachmentPatchRequestRtV2>;
export type AttachmentsFindResponseV2 = rt.TypeOf<typeof AttachmentsFindResponseRtV2>;
export type BulkCreateAttachmentsRequestV2 = rt.TypeOf<typeof BulkCreateAttachmentsRequestRtV2>;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

still using union because actual routes accept both right now, #290934 updates the route to be unified only

caseId: request.caseId,
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });

Expand All @@ -53,7 +51,7 @@ export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs):
],
});

validateRegisteredAttachments({
validateUnifiedAttachments({
query,
unifiedAttachmentTypeRegistry,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,7 +77,7 @@ export const addFile = async (
fileMetadata: createdFile.toJSON(),
});

validateRegisteredAttachments({
validateUnifiedAttachments({
query: commentReq,
unifiedAttachmentTypeRegistry,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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();
});
Expand Down Expand Up @@ -65,70 +77,38 @@ 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}.`
);
});

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.'
);
beforeEach(() => {
registerCommentType();
});

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')
.toString();

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);
registerCommentType();
userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ [caseId]: 0 });

const theCase = { ...mockCases[0], id: caseId };
Expand Down
Loading
Loading